https://feature-sliced.design/docs
프론트엔드 아키텍처, 가독성 좋고 요구사항 변경에 쉽게 대응할 수 있는 구조
사용자 앱에만 적용 가능 (디자인 시스템, 라이브러리는 적용 x)
구조
출처: https://feature-sliced.design/docs/get-started/overview
- Layers
- 프로젝트마다 동일한 계층 구조
app
: 공통 설정, 스타일, providerspages
: 페이지, entities, features, widgets를 합쳐서 만듬widgets
: 의미있는 블록 (e.g. IssuesList, UserProfile ) entities와 features를 합쳐서 만듬features
: business와 관련 있는 유저 상호작용, actions (e.g. SendComment, AddToCard, UsersSearch)entities
: business entities (e.g. User, Product, Order)shared
: 재사용 가능한 기능 (project, business와 관련 없이) (e.g. UIKit, libs, API)- 개발하면서 재사용 가능한 친구들을 추출하는 방식 추천
- 프로젝트마다 동일한 계층 구조
- Slices
- business domain에 따라 분리된 코드
- 논리적으로 관련 있는 모듈을 한 곳에 모은다.
- slices는 같은 layer의 다른 slices를 참조할 수 없다.
- Segments
- slices안의 모듈들을 기술적 목적에 따라 분리
ui
,model
(store, actions),api
,lib
(utils/hooks)
- 모듈
index.ts
로 외부 노출하고 싶은 애만 노출
장점
- 통일성
- 코드들이 구조화 되어 있다. (영향도 - layers, 도메인 - slices, 기술적 목적 - segments)
- 제한된 재사용 로직
- 각 컴포넌트는 목적과 예측가능한 의존성을 가짐
- 변경, 리팩토링 시 안정성
- 특정 layer의 모듈은 같거나 그 위의 layer의 다른 모듈을 참조할 수 없다.
features/a
는features/a/b
참조 가능, 그 반대는 x
- 특정 모듈 수정 시 사이드이펙트 방지
- 특정 layer의 모듈은 같거나 그 위의 layer의 다른 모듈을 참조할 수 없다.
- 가독성 좋음
- 도메인에 따라 나눠져 있으므로 보기 편함
튜토리얼
https://feature-sliced.design/docs/get-started/tutorial
Next.js App router와 쓰기
https://feature-sliced.design/docs/guides/tech/with-nextjs
- 공식문서 추천 방법: FSD 관련 폴더는 src 디렉토리 내부에, NextJS app 폴더는 루트 디렉토리에 놓고 pages를 import 해서 쓴다.
├── app # NextJS app folder
├── src
│ ├── app # FSD app folder
│ ├── entities
│ ├── features
│ ├── pages
│ ├── shared
│ ├── widgets
- 내 생각
pages와 app(라우팅 부분) 디렉토리를 하나로 합쳐서 app (NextJS)에 놓고, app의 나머지 부분(config, styles) 등을 common 디렉토리에 놓는 식으로 구성하는 편이 좋은 것 같다.
├── src
│ ├── app # NextJS app folder + FSD app foler (라우트)
│ ├── common # FSD app folder (라우트 제외, styles, providers, configs)
│ ├── entities
│ ├── features
│ ├── shared
│ ├── widgets
React Query와 쓰기
https://feature-sliced.design/docs/guides/tech/with-react-query
Query key 위치
1. entity 별로 분리
└── src/ #
├── app/ #
| ... #
├── pages/ #
| ... #
├── entities/ #
| ├── {entity}/ #
| ... └── api/ #
| ├── `{entity}.query` # Query-factory where are the keys and functions
| ├── `get-{entity}` # Entity getter function
| ├── `create-{entity}` # Entity creation function
| ├── `update-{entity}` # Entity update function
| ├── `delete-{entity}` # Entity delete function
| ... #
| #
├── features/ #
| ... #
├── widgets/ #
| ... #
└── shared/ #
... #
2. share에 몰아 넣음
└── src/ #
... #
└── shared/ #
├── api/ #
... ├── `queries` # Query-factories
| ├── `document.ts` #
| ├── `background-jobs.ts` #
| ... #
└── index.ts #
Mutations 위치
1. 사용하는 코드 근처 api
segment에 custom hook으로 선언
// @/features/update-post/api/use-update-title.ts
export const useUpdateTitle = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, newTitle }) =>
apiClient
.patch(`/posts/${id}`, { title: newTitle })
.then((data) => console.log(data)),
onSuccess: (newPost) => {
queryClient.setQueryData(postsQueries.ids(id), newPost);
},
});
};
2. Shared 또는 Entities에 mutation 함수 선언, 컴포넌트에서 직접 useMutation
으로 호출
const { mutateAsync, isPending } = useMutation({
mutationFn: postApi.createPost,
});
// @/pages/post-create/ui/post-create-page.tsx
export const CreatePost = () => {
const { classes } = useStyles();
const [title, setTitle] = useState("");
const { mutate, isPending } = useMutation({
mutationFn: postApi.createPost,
});
const handleChange = (e: ChangeEvent<HTMLInputElement>) =>
setTitle(e.target.value);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
mutate({ title, userId: DEFAULT_USER_ID });
};
return (
<form className={classes.create_form} onSubmit={handleSubmit}>
<TextField onChange={handleChange} value={title} />
<LoadingButton type="submit" variant="contained" loading={isPending}>
Create
</LoadingButton>
</form>
);
};
request 구조화
1. Query factory: 쿼리키 생성 함수들
// @/entities/post/api/post.queries.ts
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { getPosts } from "./get-posts";
import { getDetailPost } from "./get-detail-post";
import { PostDetailQuery } from "./query/post.query";
export const postQueries = {
all: () => ["posts"],
lists: () => [...postQueries.all(), "list"],
list: (page: number, limit: number) =>
queryOptions({
queryKey: [...postQueries.lists(), page, limit],
queryFn: () => getPosts(page, limit),
placeholderData: keepPreviousData,
}),
details: () => [...postQueries.all(), "detail"],
detail: (query?: PostDetailQuery) =>
queryOptions({
queryKey: [...postQueries.details(), query?.id],
queryFn: () => getDetailPost({ id: query?.id }),
staleTime: 5000,
}),
};
2. app 코드에서 Query factory 사용
import { useParams } from "react-router-dom";
import { postApi } from "@/entities/post";
import { useQuery } from "@tanstack/react-query";
type Params = {
postId: string;
};
export const PostPage = () => {
const { postId } = useParams<Params>();
const id = parseInt(postId || "");
const {
data: post,
error,
isLoading,
isError,
} = useQuery(postApi.postQueries.detail({ id }));
if (isLoading) {
return <div>Loading...</div>;
}
if (isError || !post) {
return <>{error?.message}</>;
}
return (
<div>
<p>Post id: {post.id}</p>
<div>
<h1>{post.title}</h1>
<div>
<p>{post.body}</p>
</div>
</div>
<div>Owner: {post.userId}</div>
</div>
);
};
3. Query factory 방식 장점
- request 구조화: 모든 API 요청을 한 곳에서 관리
- query와 key 접근 편함
- refetch 용이: query key 안 바꾸고도 refetch하기 편함
QueryProvider
구조화
1. QueryProvider
생성
// @/app/providers/query-provider.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactNode } from "react";
type Props = {
children: ReactNode;
client: QueryClient;
};
export const QueryProvider = ({ client, children }: Props) => {
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
);
};
2. QueryClient
생성
// @/shared/api/query-client.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 5 * 60 * 1000,
},
},
});
API Client
- share layer에
api client
만듬 - api config를 한 곳에서 관리
// @/shared/api/api-client.ts
import { API_URL } from "@/shared/config";
export class ApiClient {
private baseUrl: string;
constructor(url: string) {
this.baseUrl = url;
}
async handleResponse<TResult>(response: Response): Promise<TResult> {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
try {
return await response.json();
} catch (error) {
throw new Error("Error parsing JSON response");
}
}
public async get<TResult = unknown>(
endpoint: string,
queryParams?: Record<string, string | number>,
): Promise<TResult> {
const url = new URL(endpoint, this.baseUrl);
if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.append(key, value.toString());
});
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
return this.handleResponse<TResult>(response);
}
public async post<TResult = unknown, TData = Record<string, unknown>>(
endpoint: string,
body: TData,
): Promise<TResult> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
return this.handleResponse<TResult>(response);
}
}
export const apiClient = new ApiClient(API_URL);
예제
https://feature-sliced.design/docs/guides/examples
Auth, PageLayout, Types, Theme, Autocomplete, i18n, White Labels, Monorepo, Browser API, CMS, Feedback, Metric, Desktop/Touch platforms, SSR