-
Notifications
You must be signed in to change notification settings - Fork 4
Folder Structure
JongJin Kim edited this page Apr 24, 2026
·
1 revision
This project follows FSD. Each layer has a strict responsibility boundary.
app (μ΅μμ)
β
widgets
β
features
β
entities
β
shared (μ΅νμ)
μμ λ μ΄μ΄λ νμ λ μ΄μ΄λ§ importν μ μλ€. κ°μ λ μ΄μ΄ κ° cross-slice importλ κΈμ§.
- Next.js route handlers, layouts, global providers
-
app/api/[...path]/route.tsβ API νλ‘μ (ν ν° μ²¨λΆ λ° κ°±μ ) -
app/layout.tsxβ MSW μ΄κΈ°ν, Overlay λ§μ΄νΈ, QueryClient μ 곡 - λΉμ¦λμ€ λ‘μ§ μμ. μ‘°ν©λ§.
- μ¬λ¬ feature/entityλ₯Ό μ‘°ν©ν λ 립μ μΈ UI λΈλ‘
- νμ΄μ§μ λ°λ‘ λ°°μΉλ μ μλ λ¨μ
- μ:
TodoBoard,UserProfileCard,NotificationDrawer -
μ§μ API νΈμΆ κΈμ§ β
goalApi.createGoal(...)κ°μ νΈμΆμ widget λ΄λΆμ λμ§ μλλ€ - λ°μ΄ν° μ‘°ν:
entities/{domain}/query/queryOptions μ¬μ© - λ°μ΄ν° λ³κ²½(mutation):
features/{domain}/mutation/ν μ¬μ©
// β widgetμμ goalApi μ§μ νΈμΆ
const handleSubmit = async () => {
await goalApi.createGoal({ ... });
queryClient.invalidateQueries({ queryKey: ["personal", "goals"] });
};
// β
featuresμ mutation ν
μ¬μ©
const { mutate: createGoal } = useCreatePersonalGoalMutation({ onSuccess: () => router.back() });
const handleSubmit = () => createGoal({ name, dueDate });- μ¬μ©μ νλ(mutation, form submit, λΉμ¦λμ€ μ‘μ ) λ¨μ
- μ:
CreateTodo,DeleteTodo,LoginForm,ToggleTodoComplete - ꡬμ±:
ui/,model/,store/,hooks/,mutation/ -
μ§μ fetch/axios νΈμΆ κΈμ§ β λ°λμ
entities/{domain}/api/κ²½μ - mutation ν
μ
features/{domain}/mutation/use{Action}Mutation.tsμ μμ± -
onSuccessμμqueryClient.invalidateQueriesλ‘ μΊμ 무ν¨ν, navigation λ± side effectλonSuccessμ½λ°±μΌλ‘ μμ
- λλ©μΈ λͺ¨λΈ μ μ
- ꡬμ±:
api/,query/,types/,ui/(μ ν) -
api/β apiClientλ₯Ό μ¬μ©ν API ν¨μ -
query/β React Query queryOptions, queryKey -
types/β λλ©μΈ νμ μ μ (Request/Response) - μ:
entities/todo/,entities/user/,entities/auth/
// β
apiClient νΈμΆ κ²°κ³Όλ₯Ό κ·Έλλ‘ return
export const goalApi = {
toggleFavorite: (goalId: number) =>
apiClient.post<{ success: boolean }>(`/api/goals/${goalId}/favorite`),
};
// β async/await λν κΈμ§ β λΆνμν Promise μ€μ²©, return λλ½ μν
// β window.dispatchEvent, queryClient.invalidateQueries λ± μ¬μ΄λ μ΄ννΈ κΈμ§
// β μΊμ 무ν¨νΒ·μ΄λ²€νΈ λ°νμ features/mutation ν
μμ μ²λ¦¬
// β throw new Error(...) λ± μ ν¨μ± κ²μ¬ κΈμ§
// β μΈμ μ ν¨μ±μ νΈμΆ μΈ‘(features)μμ 보μ₯, api ν¨μλ μμ HTTP νΈμΆλ§
export const goalApi = {
toggleFavorite: async (goalId: number) => {
const result = await apiClient.post(...);
window.dispatchEvent(new CustomEvent("goal-favorite-toggled", ...)); // β
return result;
},
};- λλ©μΈ 무κ΄ν μ¬μ¬μ© κ°λ₯ν μμ λ¨μ
- ꡬμ±:
ui/,hooks/,lib/,utils/,store/,mock/ - λλ©μΈ κ°λ (todo, user, auth λ±) μ λ ν¬ν¨ κΈμ§
- μ:
Button,Modal,useToggle,formatDate,apiClient
src/
βββ app/
β βββ api/[...path]/route.ts
β βββ layout.tsx
β βββ (routes)/
β βββ todo/
β βββ page.tsx
βββ widgets/
β βββ todo-board/
β βββ ui/TodoBoard.tsx
β βββ index.ts
βββ features/
β βββ create-todo/
β βββ ui/CreateTodoForm.tsx
β βββ hooks/useCreateTodo.ts
β βββ index.ts
βββ entities/
β βββ todo/
β βββ api/todoApi.ts
β βββ query/todo.queryOptions.ts
β βββ types/index.ts
β βββ index.ts
βββ shared/
βββ ui/
β βββ Button/
β βββ Icon/
β βββ AsyncBoundary/
βββ hooks/
β βββ useOverlay/
βββ lib/
β βββ api/client.ts
βββ store/
β βββ overlay.store.ts
βββ utils/
βββ formatDate.ts
FSDμμ λͺ¨λ slice/segmentλ λ°λμ index.tsλ₯Ό ν΅ν΄μλ§ μΈλΆμ λ
ΈμΆνλ€.
λ΄λΆ κ²½λ‘ μ§μ importλ μ΄λ€ κ²½μ°μλ κΈμ§.
// β λ΄λΆ κ²½λ‘ μ§μ import κΈμ§
import { CreateTodoForm } from "@/features/create-todo/ui/CreateTodoForm";
import { todoQueryOptions } from "@/entities/todo/query/todo.queryOptions";
import { Button } from "@/shared/ui/Button/Button";
// β
λ°λμ index.tsλ₯Ό ν΅ν΄ import
import { CreateTodoForm } from "@/features/create-todo";
import { todoQueryOptions } from "@/entities/todo";
import { Button } from "@/shared/ui/Button";| λ μ΄μ΄ | index.ts μμΉ | μ€λͺ |
|---|---|---|
shared/ui |
segment λ¨μ | shared/ui/Button/index.ts |
shared/hooks |
segment λ¨μ | shared/hooks/useToggle/index.ts |
shared/lib |
segment λ¨μ | shared/lib/api/index.ts |
entities |
slice λ¨μ | entities/todo/index.ts |
features |
slice λ¨μ | features/create-todo/index.ts |
widgets |
slice λ¨μ | widgets/todo-board/index.ts |
// β
named export λͺ
μμ μΌλ‘ μμ±
// entities/todo/index.ts
export { getTodos, getTodoById, createTodo } from "./api/todoApi";
export { todoQueryOptions } from "./query/todo.queryOptions";
export type { Todo, TodoResponse, CreateTodoRequest } from "./types";
// β export * λ¨μ© κΈμ§ β μΈλΆμ λκ° λ
ΈμΆλλμ§ λΆλͺ
νν΄μ§
export * from "./api/todoApi";
export * from "./types";// shared/ui/Button/index.ts
export { Button } from "./Button";
export type { ButtonProps } from "./Button";
// entities/todo/index.ts
export { getTodos, createTodo, updateTodo, deleteTodo } from "./api/todoApi";
export { todoQueryOptions } from "./query/todo.queryOptions";
export type {
Todo,
TodoResponse,
TodoListResponse,
CreateTodoRequest,
UpdateTodoRequest,
} from "./types";
// features/create-todo/index.ts
export { CreateTodoForm } from "./ui/CreateTodoForm";
export { useCreateTodo } from "./hooks/useCreateTodo";
// widgets/todo-board/index.ts
export { TodoBoard } from "./ui/TodoBoard";pnpm steiger # FSD κ·μΉ μλ° μ 체 κ²μ¬
pnpm steiger:watch # νμΌ λ³κ²½ μ μλ κ²μ¬steigerκ° μλ κ°μ§νλ νλͺ©:
- λ΄λΆ κ²½λ‘ μ§μ import
- μμ λ μ΄μ΄ β νμ λ μ΄μ΄ μλ°©ν₯ import
- κ°μ λ μ΄μ΄ cross-slice import
μ κΈ°λ₯μ μΆκ°ν λ λ€μ μμλ‘ μμ νλ€:
-
entities/{domain}/types/β Request/Response νμ μ μ -
entities/{domain}/api/β apiClient κΈ°λ° API ν¨μ μμ± -
entities/{domain}/query/β queryOptions, queryKey μ μ -
features/{domain-action}/β mutation hook, form UI μμ± -
widgets/β feature + entity μ‘°ν© (νμ μ) -
app/(routes)/β pageμμ widget λ°°μΉ
Client (browser)
β /api/todos (Next.js catch-all route)
β BACKEND_URL/todos (μ€μ λ°±μλ)
-
src/app/api/[...path]/route.tsκ° λͺ¨λ ν΄λΌμ΄μΈνΈ μμ²μ μ€κ³ - μΏ ν€μμ
accessTokenμ μ½μ΄Authorizationν€λ μ²¨λΆ - 401/403 μλ΅ μ ν ν° κ°±μ ν μλ μμ² μ¬μλ
- ν΄λΌμ΄μΈνΈλ νμ
/api/...λ‘λ§ νΈμΆ (src/shared/lib/api/client.ts)
entities/todo/query/todo.queryOptions.ts
export const todoQueryOptions = {
list: (params: TodoListParams) =>
queryOptions({
queryKey: ["todo", "list", params],
queryFn: () => getTodos(params),
staleTime: 60_000,
}),
detail: (id: number) =>
queryOptions({
queryKey: ["todo", "detail", id],
queryFn: () => getTodoById(id),
}),
};- cursor-based pagination μ¬μ©
- sort modeμ λ°λΌ cursor νλ λ€λ¦:
- λ§κ°μΌ μ λ ¬:
cursorDueDate - μμ±μΌ μ λ ¬:
cursorCreatedAt+cursorId
- λ§κ°μΌ μ λ ¬:
- infinite queryλ λ°λμ
entities/{domain}/query/μinfiniteQueryOptionsλ‘ μ μ
// entities/goal/query/goal.queryOptions.ts
getFavoriteGoalListInfinite: () =>
infiniteQueryOptions({
queryKey: ["favoriteGoals", "infinite"],
queryFn: async ({ pageParam }) => {
const response = await goalApi.getFavoriteGoalList(pageParam ?? {});
return response.data;
},
initialPageParam: { size: 20 } as FavoriteGoalsQueryParams,
getNextPageParam: (lastPage): FavoriteGoalsQueryParams | undefined =>
lastPage.hasNext
? { size: 20, cursorId: lastPage.nextCursorId, cursorCreatedAt: lastPage.nextCursorCreatedAt }
: undefined,
staleTime: STALE_TIME.DEFAULT,
}),
// μ¬μ© (widgets)
const { ref, data, isFetchingNextPage } = useInfiniteScroll(
goalQueryOptions.getFavoriteGoalListInfinite(),
);-
throwOnError: trueμ€μ β μλ¬λErrorBoundaryλ‘ μ ν - μ»΄ν¬λνΈμμ try/catch μ²λ¦¬ κΈμ§,
AsyncBoundaryμ¬μ©
| μν μ’ λ₯ | μμΉ |
|---|---|
| μλ² μν | React Query (entities/{domain}/query/) |
| μ μ UI μν (overlay λ±) | shared/store/ |
| λλ©μΈ UI μν | features/{domain}/store/ |
| λ‘컬 μ»΄ν¬λνΈ μν | useState |
| μΈμ¦ μν |
features/auth/store/ (persist + immer) |