> [!date] published: 2025-05-13
## Motivation (왜 필요한가)
복잡하고 점점 더 커지는 프로젝트의 개발을 용이하게 하기 위해서
??? : 기존 솔루션이 많은데 왜 또 새로운 방법론이 필요한가요?
1. 기존 원칙들이 있긴 하지만 구체적으로 프로젝트에 적용하기에는 쉽지 않다.
2. 문서화, 테스트로 문제를 해결할 수 있지만 근본적인 아키텍쳐 문제를 해결할 수는 없고 (문제를 없애는 방법이 아니라 보완하는 방법일 뿐) 아키텍쳐가 복잡해질수록 신규 인원이 왔을 때 읽어야 하는 문서도 많아짐 → 오히려 복잡해진다!
3. 프레임워크가 정해주는 아키텍쳐를 그대로 사용할수도 있지만 특정 기술에 종속되는 해결 방법이라 역시 근본적인 해결이 아님 (모든 문제 상황에 프레임워크를 적용하는 식으로 문제를 해결할수는 없기 때문에)
그래서 요즘 프론트엔드 프로젝트에서 자주 보이는 문제점은...
- 각 프로젝트가 "눈송이처럼 독특한 형태"로 남게 됨 (진짜 공감된다...)
- 새로운 개발자가 참여할 때 마다 긴 적응 기간이 필요함
- 일부 인원만이 프로젝트를 이해할 수 있다.
왜 개발자에게 방법론이 필요한가??
- 설계에 대해 고민하는 시간을 줄이고 비지니스 로직 개발에 집중할 수 있다.
- 많은 사람들의 경험으로 입증된 best practice를 사용해서 미래에 발생될 수 있는 문제를 미리 막을 수 있음
## 필요 중심 (Needs-driven) 접근
프론트엔드는 사용자의 문제를 해결하고 요구사항을 충족하는 인터페이스를 제공함. 사용자는 서비스를 사용할 때 "자신의 문제를 해결하거나 필요를 충족"하려고 한다.
> **사용자의 목표가 곧 개발자의 작업**이다.
이런 개념이 개발에 미치는 영향
- 작업을 단계적으로 분해할 때 그 코드가 해결하는 작업의 본질을 반영해서 각 Entity를 정의한다. 이 과정에서 그 **모든 작업들이 사용자의 문제 해결을 위한 것**이라는 것을 생각해야 한다.
- 개발자가 Entity의 이름을 고민하는 과정에서 불명확한 작업을 미리 발견할 수 있다.
이점
- 사용자의 문제를 좀 더 잘 이해해서 기술적 제약이 있을 때에도 비즈니스적으로도 좋은 결정을 할 수 있다.
- 사고 과정과 코드가 자연스럽게 체계화된다.
- 각 기능에는 핵심 로직이 담기게 된다. 그 때 그 기능 하나 하나는 사용자에게 '명확한 가치'를 전달해야 한다. => 모호한 기능들이 사라진다.
- 코드가 곧 비즈니스 로직이 되므로 신규 팀원이 코드를 이해하면서 동시에 비즈니스도 이해하게 됨
## Naming
개발자들은 같은 대상을 각자의 경험과 관점에 따라 다르게 부르곤 한다 (ㄹㅇ...)
FSD에서는 표준 네이밍 규칙을 제시한다. (각 개념은 아래에 다시 설명)
Layers (계층)
- `app`
- `processes`
- `pages`
- `features`
- `entities`
- `shared`
Segments (세그먼트)
- `ui`
- `model`
- `lib`
- `api`
- `config`
## 기본 개념
![[d1a23c37-043b-481e-860f-4f403250b7cd.png]]
### Layers (레이어)
```
- 📂 app 👈
- 📁 routes
- 📁 analytics
- 📂 pages 👈
- 📁 home
- 📂 article-reader
- 📁 ui
- 📁 api
- 📁 settings
- 📂 shared 👈
- 📁 ui
- 📁 api
```
모든 FSD 프로젝트에서 표준화되어 있다. 모두 사용할 필요는 없지만 이름은 중요하다. (Naming 참고..)
**수직적 구조**를 갖는다. 상위 레이어는 하위 레이어만 참조한다. (하위 레이어만 import 할 수 있다.)
1. **App** - 앱을 실행하는 모든 것 - 라우팅, 진입점, 전역 스타일, 프로바이더.
2. ~~**Processes**~~(더 이상 사용되지 않음) - 페이지 간 복잡한 시나리오.
3. **Pages** - 전체 페이지 또는 중첩 라우팅에서 페이지의 주요 부분.
4. **Widgets** - 독립적으로 작동하는 대규모 기능 또는 UI 컴포넌트, 보통 하나의 완전한 기능.
5. **Features** - 제품 전반에 걸쳐 재사용되는 기능 구현체로, 사용자에게 실질적인 비즈니스 가치를 제공하는 동작.
6. **Entities** - 프로젝트가 다루는 비즈니스 엔티티, 예를 들어 user 또는 product.
7. **Shared** - 재사용 가능한 기능, 특히 프로젝트/비즈니스의 특성과 분리되어 있을 때 (반드시 그럴 필요는 없음).
**App**과 **Shared**는 다른 레이어들과 달리 슬라이스를 가지지 않으며, 직접 세그먼트로 구성된다. (도메인과 연관된 부분이 아니니까!)
### Slices (슬라이스)
```
- 📂 app
- 📁 routes
- 📁 analytics
- 📂 pages
- 📁 home 👈
- 📂 article-reader 👈
- 📁 ui
- 📁 api
- 📁 settings 👈
- 📂 shared
- 📁 ui
- 📁 api
```
도메인별로 분할된 코드... 논리적으로 연관된 코드들을 하나로 묶는다.
각 슬라이스들은 서로를 참조할 수 없다! => 결합도를 낮추고 응집도를 높임... ㅇㅇ
### Segments (세그먼트)
```
- 📂 app
- 📁 routes 👈
- 📁 analytics 👈
- 📂 pages
- 📁 home
- 📂 article-reader
- 📁 ui 👈
- 📁 api 👈
- 📁 settings
- 📂 shared
- 📁 ui 👈
- 📁 api 👈
```
세그먼트는 기능적인 목적에 따라 코드를 그룹화한 것 (도메인과 다르다는게 좀 헷갈리는 것 같음..)
정해진 네이밍 규칙은 없지만 관례적으로는 이런 이름들을 쓴다.
- `ui` - UI와 관련된 모든 것: UI 컴포넌트, 날짜 포맷터, 스타일 등.
- `api` - 백엔드 상호작용: request 함수, 데이터 타입, mapper 등.
- `model` - 데이터 모델: 스키마, 인터페이스, 스토어, 비즈니스 로직.
- `lib` - 슬라이스 안에 있는 다른 모듈이 필요로 하는 라이브러리 코드.
- `config` - 설정 파일과 기능 플래그.
## 예제로 구조 맛보기
[Example 페이지](https://feature-sliced.github.io/documentation/kr/examples)에 예제 프로젝트들이 많은데, 그 중 하나를 가지고 기본 개념들을 좀 이해해보기로 했다.
[GitHub - ruslan4432013/fsd-react-query-example](https://github.com/ruslan4432013/fsd-react-query-example)
Layer는 표준화된 기본 구조를 사용한다.
```
src
├── app/
├── entities/
├── features/
├── pages/
└── shared/
```
### app layer
app 안에는
- **라우팅** (router.tsx)
- **진입점** (providers/)
- **전역 스타일** (global.css)
- **프로바이더** (QueryClientProvider, RouterProvider)
가 들어가 있다.
```
app
├── global.css
├── index.tsx
├── providers/
└── router.tsx
```
```tsx
// src/app/providers/index.tsx
export const Providers = ({ router, client }: Props) => {
return (
<QueryClientProvider client={client}>
<RouterProvider router={router} />
<ReactQueryDevtools />
</QueryClientProvider>
);
};
```
### pages layer
pages는 **전체 페이지 또는 중첩 라우팅에서 페이지의 주요 부분** 을 담는 디렉토리라고 했다.
```
pages
├── create-post/
│ ├── index.ts
│ └── ui/
│ ├── created-post.tsx
│ ├── index.tsx
│ └── styles.ts
├── home/
│ ├── index.ts
│ ├── lib/
│ │ └── use-page-param.ts
│ └── ui/
│ ├── index.tsx
│ ├── posts.skeleton.tsx
│ ├── posts.tsx
│ └── styles.ts
└── post/
├── index.ts
└── ui/
└── index.tsx
```
라우터를 보면 요렇게 라우팅이 되고 있고, 그대로 pages의 디렉토리에 드러나 있다. (모두 pages (바로 하위 레이어)를 import하고 있다는 것도 포인트가 될 듯) 그리고 이게 곧 하나의 도메인(슬라이스)이 된다는 것 같다.
```tsx
import { HomePage } from "@/pages/home";
import { PostPage } from "@/pages/post";
import { CreatePostPage } from "@/pages/create-post";
export const router = createBrowserRouter([
{
path: "/",
element: <HomePage />,
},
{
path: "/posts/:postId",
element: <PostPage />,
},
{
path: "/create-post",
element: <CreatePostPage />,
},
]);
```
각 슬라이스 하위에는 세그먼트들이 있다. 가장 뭐가 많은 home 디렉토리를 보면
```
├── home/
│ ├── index.ts
│ ├── lib/
│ │ └── use-page-param.ts
│ └── ui/
│ ├── index.tsx
│ ├── posts.skeleton.tsx
│ ├── posts.tsx
│ └── styles.ts
```
index.ts는 home ui를 export 하는 역할을 하고
```ts
// src/pages/home/index.ts
export { HomePage } from "./ui";
```
lib 디렉토리에는 페이지 단위로 사용되는 유틸함수가 들어있다.
ui부분이 좀 흥미로운데 데이터를 페칭해와서 단순히 뿌려주는 코드만 여기에 있다.
```tsx
// src/pages/home/ui/index.tsx
export const HomePage = () => {
const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN
const [page, setPage] = usePageParam(DEFAULT_PAGE)
const { data, isFetching, isLoading } = useQuery(postApi.postQueries.list(page, itemsOnScreen)) // 1️⃣ 데이터 페칭해서
const { classes } = useStyles({ isFetching })
return (
<Box className={classes.root}>
{/* 생략 */}
{/* 2️⃣ 뿌려주기 */}
<Box className={classes.post_wrapper}>
{isLoading ? (
<PostsSkeleton limit={itemsOnScreen}/>
) : (
<>
<Posts posts={data?.posts}/>
<Box className={classes.backdrop}>
<CircularProgress color="primary"/>
</Box>
</>
)}
</Box>
{/* 생략 */}
</Box>
)
```
```tsx
// src/pages/home/ui/posts.tsx
import { Grid } from "@mui/material";
import { Post, PostCard } from "@/entities/post";
type Props = {
posts?: Post[];
};
export const Posts = ({ posts }: Props) => {
return (
<Grid container spacing={2}>
{posts?.map((post) => (
<Grid key={post.id} item xs={4}>
<PostCard post={post} />
</Grid>
))}
</Grid>
);
};
```
- [fsd-react-query-example/src/pages/home/ui/index.tsx at main · ruslan4432013/fsd-react-query-example · GitHub](https://github.com/ruslan4432013/fsd-react-query-example/blob/main/src/pages/home/ui/index.tsx)
- [fsd-react-query-example/src/pages/home/ui/posts.tsx at main · ruslan4432013/fsd-react-query-example · GitHub](https://github.com/ruslan4432013/fsd-react-query-example/blob/main/src/pages/home/ui/posts.tsx)
### features layer
features 안에는 "사용자에게 실질적인 비즈니스 가치를 제공하는 동작"이 들어간다고 했다. 실제로 사용자의 문제를 해결할 수 있는 "기능"들이 들어간다는 것.
이 프로젝트의 "기능"에는 post를 생성하는 것 하나가 있기 때문에 이게 features 안에 들어가 있다.
```
feature
└── create-post/
├── api/
│ └── use-create-post.ts
├── index.ts
└── ui/
├── index.tsx
└── styles.ts
```
api segment 안에는 실제로 post를 생성하기 위한 useMutation 훅이 들어가 있다. (생성 "기능")
```ts
// src/features/create-post/api/use-create-post.ts
import { useMutation } from "@tanstack/react-query";
import { postApi } from "@/entities/post";
export const useCreatePost = () =>
useMutation({
mutationFn: postApi.createPost,
});
```
ui segment 안에는 post 생성을 위한 form ui가 들어 있다.
```tsx
import { TextField } from "@mui/material";
import { useStyles } from "./styles";
import { ChangeEvent, FormEvent, useState } from "react";
import { Post } from "@/entities/post";
import { LoadingButton } from "@mui/lab";
import { useCreatePost } from "../api/use-create-post";
type Props = {
onCreate?: (post: Post) => void | Promise<void>;
};
export const CreatePostForm = ({ onCreate }: Props) => {
const { classes } = useStyles();
const [title, setTitle] = useState("");
const { mutateAsync, isPending } = useCreatePost();
return (
<form className={classes.create_form} onSubmit={handleSubmit}>
<TextField
onChange={handleChange}
label="Title"
name="title"
variant="outlined"
value={title}
/>
<LoadingButton type="submit" variant="contained" loading={isPending}>
Create
</LoadingButton>
</form>
);
};
```
### entities layer
entities 디렉토리 안에는 프로젝트가 다루는 비즈니스 엔티티가 들어 있다고 했다. 이 프로젝트는 post를 다루는 프로젝트니까 post가 들어 있다. 그리고 또 그 하위에 세그먼트들이 들어 있음
```
entities
└── post/
├── api/
│ ├── create-post.ts
│ ├── dto/
│ ├── get-detail-post.ts
│ ├── get-posts.ts
│ ├── index.ts
│ ├── mapper/
│ ├── post.queries.ts
│ ├── query/
│ └── rdo/
├── index.ts
├── model/
│ ├── detail-post.ts
│ ├── post-with-pagination.ts
│ └── post.ts
├── types.ts
└── ui/
└── index.tsx
```
features 레이어의 api와 entities 레이어의 api의 차이점은 사용자와의 거리 (...?) 에 있는 것 같다.
features 레이어에서는 useMutation을 호출했다면 entities 레이어에서는 실제로 POST 요청을 보낸다.
```ts
// src/entities/post/api/create-post.ts
import { apiClient } from "@/shared/api/base";
import { CreatePostDto } from "./dto/create-post.dto";
import { mapPost } from "./mapper/map-post";
import { CreatePostRdo } from "./rdo/create-post.rdo";
export const createPost = async (body: CreatePostDto) => {
const res = await apiClient.post<CreatePostRdo>("/posts/add", body);
return mapPost(res);
};
```
mapper는 api에서 사용하는 dto와 프로젝트에서 사용할 model을 매핑하는 역할을 한다. (매번 맞추면 좋겠지만 다를 수 밖에 없을수도 있으니까)
```ts
import { DetailPostDto } from "../dto/detail-post.dto";
import { DetailPost } from "../../model/detail-post";
export const mapDetailPost = (dto: DetailPostDto): DetailPost => ({
title: dto.title,
id: dto.id.toString(),
body: dto.body,
tags: dto.tags,
userId: dto.userId.toString(),
reactions: dto.reactions.toString(),
});
```
매번 api를 설계할 때 프론트엔드에서 사용하는 데이터 타입과 api에서 사용하는 데이터 타입이 동일했어서 왜 api 세그먼트 내부에도 타입이 있고, model 세그먼트에도 타입이 있는지 잘 이해가 안되었는데, mapper 함수를 보고 좀 이해가 됐다... ㅎㅎ model 세그먼트 안에는 api와는 별개로 실제 프론트엔드에서 사용하는 스키마 정의가 들어간다!
```ts
// src/entities/post/model/detail-post.ts
export type DetailPost = {
id: string; // 👈 id 타입이 다름
title: string;
body: string;
userId: string; // 👈 id 타입이 다름
tags: string[];
reactions: string; // 👈 타입이 다름
};
// src/entities/post/api/dto/detail-post.dto.ts
export type DetailPostDto = {
id: number; // 👈 id 타입이 다름
title: string;
body: string;
userId: number; // 👈 id 타입이 다름
tags: string[];
reactions: number; // 👈 타입이 다름
};
```
entitiy 내부에도 ui segment가 있는데 pages의 ui랑 좀 헷갈리긴 한데, post라는 **하나의 도메인을 보여주는 가장 작은 단위의 ui**라서 아마 entity 내부에 들어가 있는게 아닌가 싶다... 이건 다른 예제들도 좀 더 찾아봐야 함.
```tsx
import { Post } from "../model/post";
import { Link } from "react-router-dom";
import { Box, Button, Typography } from "@mui/material";
type Props = {
post: Post;
};
export const PostCard = ({ post }: Props) => {
const { title, id } = post;
return (
<Box>
<Typography>Post: {title}</Typography>
<Button component={Link} to={`/posts/${id}`}>
Show detail
</Button>
</Box>
);
};
```
### shared layer
shared layer에는 도메인에 종속되지 않고 공통적으로 사용되는 것들이 들어가 있다.
ApiClient 클래스 정의라던가 queryClient 객체, API_URL 같은 상수들이나 두루두루 사용되는 유틸함수들이 shared 안에 속한다.
```
shared
├── api/
│ ├── base.ts
│ └── query-client.ts
├── config/
│ └── index.ts
└── lib/
├── map-collection.ts
└── range.ts
```