yoowonyoungabout

20개 슬라이스, 6개 레이어 — FSD 점진적 도입기

Entity/Feature 분리, ESLint 레이어 강제, 점진적 마이그레이션

TL;DR

프론트엔드 프로젝트가 커지면 “이 컴포넌트 어디에 둬야 하지?”라는 질문이 반복된다. 폴더를 기능별로 나눠도, 도메인별로 나눠도, 시간이 지나면 경계가 무너진다. Feature-Sliced Design(FSD)은 이 문제를 6개 레이어와 명확한 의존성 규칙으로 해결한다. 이 글은 20개 Entity · 17개 Feature 슬라이스를 가진 환전 백오피스 프로젝트에서 FSD를 점진적으로 도입하고, ESLint로 레이어 규칙을 강제하며, Entity와 Feature를 분리하는 과정에서 겪은 실전 경험을 공유한다.


1. 문제 — 폴더 구조가 스케일하지 않는다

전형적인 React 프로젝트 구조의 한계

src/
├── components/     # 200개 넘으면 찾기 어려움
├── hooks/          # 어떤 도메인 훅인지 불명확
├── pages/          # 비즈니스 로직이 여기에 쌓임
├── services/       # API 호출 + 상태 + 유틸 혼재
└── utils/          # 무한 증식

이 구조는 소규모 프로젝트에서는 문제가 없다. 하지만 도메인이 20개(환전, 자산, 계정, 태그, 환불, 정산, 대시보드…)로 늘어나면:

  • components/ExchangeTable.tsxcomponents/AccountTable.tsx서로 다른 도메인인데 같은 폴더에 있다
  • hooks/useExchange.ts읽기(조회)와 쓰기(상태변경)를 모두 담당한다
  • services/exchangeService.ts가 API 호출, 캐시 관리, 비즈니스 로직을 전부 포함한다
  • 새 개발자가 "환전 관련 코드"를 찾으려면 5개 폴더를 뒤져야 한다

핵심 질문

"읽기 전용 도메인 모델"과 “쓰기 기능"을 같은 폴더에 두면, 왜 의존성이 꼬이는가?”

환전 목록(읽기)은 계정 페이지, 자산 페이지, 대시보드 등 여러 곳에서 재사용된다. 하지만 환전 상태 변경(쓰기)은 환전 관리 페이지에서만 사용된다. 이 둘을 한 모듈에 넣으면, 계정 페이지가 환전 쓰기 로직까지 의존하게 된다.


2. 해결 — Feature-Sliced Design 도입

FSD 6-레이어 구조

flowchart TD A["app
앱 설정, 프로바이더"] --> B["pages
페이지 컴포넌트"] B --> C["widgets
복합 UI 조합"] C --> D["features
쓰기 기능 단위"] D --> E["entities
읽기 도메인 모델"] E --> F["shared
공통 모듈"]

핵심 규칙: 상위 레이어는 하위 레이어만 import 가능. 역방향 금지.

실제 프로젝트 구조

src/
├── app/              # QueryClient, Router, 프로바이더
├── pages/            # 페이지 컴포넌트
├── widgets/          # Layout, Header 등 복합 UI
├── features/         # 17개 쓰기 기능 슬라이스
├── entities/         # 20개 읽기 도메인 슬라이스
├── shared/           # API, UI, lib, config
└── mocks/            # MSW 목 데이터

3. 핵심 설계 — Entity vs Feature 분리

왜 같은 도메인을 두 레이어에 나누는가?

구분 Entity (entities/) Feature (features/)
역할 읽기 (GET, useQuery) 쓰기 (POST/PUT/DELETE, useMutation)
재사용성 여러 페이지에서 공유 특정 시나리오에서만 사용
의존 방향 shared만 의존 entities + shared 의존
예시 환전 목록 조회, 자산 상세 조회 환전 상태 변경, 자산 생성/수정

만약 분리하지 않으면?

flowchart TD A["계정 페이지"] -->|"환전 목록 조회"| B["exchange 모듈"] C["대시보드"] -->|"환전 통계 조회"| B D["환전 관리"] -->|"상태 변경"| B B -->|"역방향 의존"| A

Entity와 Feature를 합치면, 계정 페이지가 환전 상태 변경 코드까지 함께 import하게 된다. FSD에서는 이를 구조적으로 불가능하게 만든다.


4. 실전 구현 — 슬라이스 내부 구조

Entity 슬라이스 예시: entities/exchange/

entities/exchange/
├── api/
│   ├── exchangeApi.ts          # API 호출 함수
│   └── useExchangeQueries.ts   # useQuery 훅 + queryKeys
├── model/
│   ├── constants.ts            # 상태 라벨, 검색 옵션
│   ├── stepper.ts              # 스테퍼 상태 머신
│   ├── transitions.ts          # 상태 전이 규칙
│   └── types.ts                # 타입 정의
└── index.ts                    # Public API

Feature 슬라이스 예시: features/exchange-management/

features/exchange-management/
├── api/
│   ├── exchangeMutationApi.ts          # 상태 변경 API
│   └── useManagerExchangeMutations.ts  # useMutation 훅
├── model/
│   └── role-actions.ts                 # 역할별 액션 검증
└── index.ts                            # Public API

Public API 패턴 (index.ts)

각 슬라이스는 index.ts를 통해서만 외부에 노출된다. 내부 구현을 캡슐화하고, 변경 영향 범위를 최소화한다.

// entities/exchange/index.ts
// 타입 + 조회 훅 + 도메인 상수만 노출
export type { ExchangeDetail, ExchangeListItem } from './api/exchangeApi'
export { exchangeKeys, useExchangeDetail, useExchanges } from './api/useExchangeQueries'
export { STATUS_LABELS, STATUS_VARIANTS } from './model/constants'
export { getNextStatuses, canTransitionTo } from './model/transitions'
// features/exchange-management/index.ts — 쓰기 기능만 노출
export { useExchangeStatusMutation } from './api/useExchangeMutations'
export { canExecuteAction, getAvailableActions } from './model/role-actions'

Note: Entity의 index.ts는 타입 + 조회 훅 + 도메인 상수를 노출한다. Feature의 index.ts는 변경 훅 + 액션 로직만 노출한다. 소비자는 "읽기만 필요하면 Entity, 쓰기도 필요하면 Feature"로 명확히 구분할 수 있다.


5. 쿼리 키 팩토리 패턴

TanStack Query의 캐시 무효화를 예측 가능하게 만드는 핵심 패턴이다.

// entities/asset/api/useAssetQueries.ts
export const assetKeys = {
  all: ['assets'] as const,
  lists: () => [...assetKeys.all, 'list'] as const,
  list: (params?: AssetListParams) =>
    [...assetKeys.lists(), params] as const,
  details: () => [...assetKeys.all, 'detail'] as const,
  detail: (id: string) =>
    [...assetKeys.details(), id] as const,
  admins: (id: string) =>
    [...assetKeys.all, 'admins', id] as const,
}

Feature에서 Mutation 성공 시 Entity의 queryKey를 사용해 캐시를 무효화한다:

// features/asset-management/api/useAssetMutations.ts
export const useCreateAsset = () => {
  return useMutationWithToast({
    mutationFn: assetMutationApi.createAsset,
    invalidateKeys: [assetKeys.lists()],  // Entity의 키 참조
  })
}

이 구조에서 의존 방향은 항상 Feature → Entity로 유지된다.


6. ESLint로 레이어 규칙 강제하기

FSD의 레이어 규칙은 코드 리뷰로만 지키기 어렵다. simple-import-sort 플러그인으로 import 순서를 강제하여 위반을 즉시 감지한다.

// eslint.config.js
'simple-import-sort/imports': ['error', {
  groups: [
    ['^react'],           // 1. react
    ['^@?\\w'],           // 2. 외부 패키지
    ['^@/app'],           // 3. FSD 레이어 순서
    ['^@/pages'],
    ['^@/widgets'],
    ['^@/features'],
    ['^@/entities'],
    ['^@/shared'],
    ['^\\.'],             // 4. 상대 경로
  ],
}],

위반 시 즉시 에러

// Bad: entities에서 features를 import → ESLint 에러
import { useCreateAsset } from '@/features/asset-management'
//                                 ^^^^^^^^ 역방향 import!

// Good: features에서 entities를 import → 정상
import { assetKeys, useAssets } from '@/entities/asset'

Point: import 정렬만으로 역방향 의존을 완전히 차단할 수는 없지만, 개발자가 잘못된 import를 작성하는 순간 IDE에서 빨간 줄이 뜨므로 사실상 가드 역할을 한다. value import와 type import를 분리(\u0000 패턴)하여 타입과 런타임 의존도 구분한다.


7. 상태 관리 분리 전략

FSD에서 상태 관리는 서버 상태클라이언트 상태를 명확히 분리한다.

상태 유형 도구 위치 예시
서버 상태 TanStack Query entities/*/api/ 환전 목록, 자산 상세
클라이언트 상태 Zustand entities/*/model/ 또는 shared/store/ 세션, 토스트
URL 상태 nuqs features//model/ 또는 pages// 필터, 페이지네이션

Zustand 스토어 예시 — 세션 관리

// entities/session/model/store.ts
export const useSessionStore = create<SessionStore>()(
  persist(
    (set) => ({
      accessToken: null,
      refreshToken: null,
      user: null,
      isAuthenticated: false,

      setSession: ({ accessToken, refreshToken, user }) =>
        set({ accessToken, refreshToken, user, isAuthenticated: true }),
      logout: () =>
        set({ accessToken: null, refreshToken: null, user: null, isAuthenticated: false }),
    }),
    { name: 'app-session', storage: createJSONStorage(() => localStorage) },
  ),
)

Note: Entity에 두는 이유: 세션 정보는 여러 레이어에서 읽기 목적으로 참조한다. Feature(auth)에서는 로그인/로그아웃 쓰기 액션만 담당한다.


8. 점진적 도입 과정

처음부터 FSD를 완벽히 적용한 것이 아니라, 단계적으로 마이그레이션했다.

단계 작업 결과
Phase 1 shared 레이어 정립 (API client, UI, lib) 공통 모듈의 경계 확정
Phase 2 Entity 추출 — 읽기 로직을 entities/로 분리 도메인별 조회 로직 캡슐화
Phase 3 Feature 분리 — 쓰기 로직을 features/로 이동 Entity ↔ Feature 의존 방향 확립
Phase 4 접미사 제거 (application → 제거), dead code 정리 네이밍 일관성 확보
Phase 5 ESLint import 정렬 규칙 적용 레이어 규칙 자동 강제

핵심 교훈: “한 번에 다 바꾸지 않는다”

  • 새 기능은 처음부터 FSD 구조로 작성
  • 기존 코드는 해당 파일을 수정할 때 함께 마이그레이션
  • 타입 체크 0 에러, lint 0 에러를 유지하면서 점진적 전환

9. 네이밍 컨벤션

항목 규칙 예시
Entity 슬라이스 도메인 명사 (kebab-case) exchange, virtual-account
Feature 슬라이스 CRUD 묶음: {noun}-management exchange-management, tag-management
Feature 슬라이스 독립 기능: 단순 명사/동사 auth, manual-exchange
쿼리 키 팩토리 {entity}Keys assetKeys, exchangeKeys

10. 현재 규모와 결과

지표 수치
Entity 슬라이스 20개
Feature 슬라이스 17개
TypeScript 에러 0건
ESLint 에러 0건
역방향 import 0건 (ESLint 강제)

도입 전후 비교

도입 전 도입 후
“환전 코드 어디?” 5개 폴더 탐색 entities/exchange + features/exchange-management 2곳만 확인
읽기/쓰기 분리 하나의 서비스 파일 Entity(useQuery) / Feature(useMutation)
의존성 방향 순환 참조 가능 단방향 강제 (ESLint)
새 도메인 추가 어디에 둘지 고민 entities/ + features/ 생성 → 끝

새 도메인 추가 시 소요 작업

작업 도입 전 도입 후
폴더 구조 결정 팀원마다 다른 기준, 논의 필요 entities/{domain} + features/{domain}-management 템플릿 그대로
API 레이어 설계 어떤 파일에 어떤 함수를 둘지 고민 api/{domain}Api.ts + api/use{Domain}Queries.ts 패턴 고정
Public API (index.ts) 없음 (직접 import) 슬라이스당 1개, 내부 구현 캡슐화
쿼리 키 설계 임의로 문자열 작성 {domain}Keys 팩토리 패턴 적용

Note: FSD 도입 후 새 도메인 추가 시 “어디에 둘지”라는 의사결정이 완전히 제거되었다. 템플릿 구조(api/ + model/ + index.ts)를 복사하고 도메인 로직만 채우면 되므로, 구조 설계에 드는 시간이 사실상 0이 되었다.

팀 협업 임팩트

  • 코드 리뷰 시간 단축: 폴더 구조와 네이밍이 예측 가능하므로, 리뷰어는 "이 코드가 어디에 있어야 하는지"를 논의할 필요 없이 도메인 로직에 집중할 수 있었다.
  • 온보딩 비용 감소: 새 팀원이 합류하더라도 6개 레이어 규칙만 이해하면 독립적으로 작업 가능. “이 파일 수정하면 어디까지 영향이 갈까?” 같은 불안이 사라졌다.
  • 교차 도메인 작업 충돌 최소화: 도메인별로 슬라이스가 독립되어 있어, 다른 도메인 작업자와 Git 충돌이 발생할 확률이 현저히 낮아졌다.

마무리 — FSD는 "정답"이 아니라 "가드레일"이다

FSD가 모든 프로젝트에 적합한 것은 아니다. 소규모 프로젝트에서는 오버엔지니어링이 될 수 있다. 하지만 도메인이 10개를 넘고, 읽기/쓰기의 소비자가 다르며, 팀이 2명 이상이라면 — "이 코드 어디에 둬야 하지?"라는 질문이 사라지는 것만으로도 도입 가치가 충분하다.

핵심은 세 가지다:

  1. Entity = 읽기, Feature = 쓰기 — 같은 도메인이 두 레이어에 나뉘는 것은 의도된 설계
  2. index.ts로 Public API 강제 — 내부 구현 변경이 외부에 영향을 주지 않음
  3. ESLint로 레이어 규칙 자동화 — 코드 리뷰 부담 없이 구조 유지