Skip to content

Folder Structure

JongJin Kim edited this page Apr 24, 2026 · 1 revision

Architecture

Feature-Sliced Design (FSD)

This project follows FSD. Each layer has a strict responsibility boundary.

Layer Dependency Flow

app         (μ΅œμƒμœ„)
  ↓
widgets
  ↓
features
  ↓
entities
  ↓
shared      (μ΅œν•˜μœ„)

μƒμœ„ λ ˆμ΄μ–΄λŠ” ν•˜μœ„ λ ˆμ΄μ–΄λ§Œ importν•  수 μžˆλ‹€. 같은 λ ˆμ΄μ–΄ κ°„ cross-slice importλŠ” κΈˆμ§€.


Layer Responsibilities

app/

  • Next.js route handlers, layouts, global providers
  • app/api/[...path]/route.ts β€” API ν”„λ‘μ‹œ (토큰 첨뢀 및 κ°±μ‹ )
  • app/layout.tsx β€” MSW μ΄ˆκΈ°ν™”, Overlay 마운트, QueryClient 제곡
  • λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μ—†μŒ. μ‘°ν•©λ§Œ.

widgets/

  • μ—¬λŸ¬ 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 });

features/

  • μ‚¬μš©μž 행동(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 콜백으둜 μœ„μž„

entities/

  • 도메인 λͺ¨λΈ μ •μ˜
  • ꡬ성: api/, query/, types/, ui/ (선택)
  • api/ β€” apiClientλ₯Ό μ‚¬μš©ν•œ API ν•¨μˆ˜
  • query/ β€” React Query queryOptions, queryKey
  • types/ β€” 도메인 νƒ€μž… μ •μ˜ (Request/Response)
  • 예: entities/todo/, entities/user/, entities/auth/

entities/api μž‘μ„± κ·œμΉ™

// βœ… 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;
  },
};

shared/

  • 도메인 λ¬΄κ΄€ν•œ μž¬μ‚¬μš© κ°€λŠ₯ν•œ μ›μ‹œ λ‹¨μœ„
  • ꡬ성: ui/, hooks/, lib/, utils/, store/, mock/
  • 도메인 κ°œλ…(todo, user, auth λ“±) μ ˆλŒ€ 포함 κΈˆμ§€
  • 예: Button, Modal, useToggle, formatDate, apiClient

Folder Structure Example

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

Public API Rule (index.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 μœ„μΉ˜ κΈ°μ€€

λ ˆμ΄μ–΄ 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

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";

λ ˆμ΄μ–΄λ³„ index.ts μ˜ˆμ‹œ

// 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";

steiger둜 μœ„λ°˜ 감지

pnpm steiger        # FSD κ·œμΉ™ μœ„λ°˜ 전체 검사
pnpm steiger:watch  # 파일 λ³€κ²½ μ‹œ μžλ™ 검사

steigerκ°€ μžλ™ κ°μ§€ν•˜λŠ” ν•­λͺ©:

  • λ‚΄λΆ€ 경둜 직접 import
  • μƒμœ„ λ ˆμ΄μ–΄ β†’ ν•˜μœ„ λ ˆμ΄μ–΄ μ—­λ°©ν–₯ import
  • 같은 λ ˆμ΄μ–΄ cross-slice import

New Feature Checklist

μƒˆ κΈ°λŠ₯을 μΆ”κ°€ν•  λ•Œ λ‹€μŒ μˆœμ„œλ‘œ μž‘μ—…ν•œλ‹€:

  1. entities/{domain}/types/ β€” Request/Response νƒ€μž… μ •μ˜
  2. entities/{domain}/api/ β€” apiClient 기반 API ν•¨μˆ˜ μž‘μ„±
  3. entities/{domain}/query/ β€” queryOptions, queryKey μ •μ˜
  4. features/{domain-action}/ β€” mutation hook, form UI μž‘μ„±
  5. widgets/ β€” feature + entity μ‘°ν•© (ν•„μš” μ‹œ)
  6. app/(routes)/ β€” pageμ—μ„œ widget 배치

API Proxy Architecture

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)

Data Fetching Patterns

Query Options μž‘μ„± μœ„μΉ˜

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),
    }),
};

Infinite Scroll

  • 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(),
);

Error Handling

  • throwOnError: true μ„€μ • β†’ μ—λŸ¬λŠ” ErrorBoundary둜 μ „νŒŒ
  • μ»΄ν¬λ„ŒνŠΈμ—μ„œ try/catch 처리 κΈˆμ§€, AsyncBoundary μ‚¬μš©

State Management Rules

μƒνƒœ μ’…λ₯˜ μœ„μΉ˜
μ„œλ²„ μƒνƒœ React Query (entities/{domain}/query/)
μ „μ—­ UI μƒνƒœ (overlay λ“±) shared/store/
도메인 UI μƒνƒœ features/{domain}/store/
둜컬 μ»΄ν¬λ„ŒνŠΈ μƒνƒœ useState
인증 μƒνƒœ features/auth/store/ (persist + immer)