Feature-Sliced Design

Next.js App Router 도입 제안서

도메인 기반 코드 분리로 Git 충돌 감소, AI 토큰 효율 극대화, 타입 안전성 강화까지

01 — Why FSD

도입 시 즉시 체감할 수 있는 핵심 이점

FSD가 가져다주는 가장 분명하고 실질적인 메리트들

🔀

동시 작업 시 Git 충돌 최소화

기존: 여러 사람이 components/, hooks/, api/ 같은 공유 폴더를 동시에 수정 → merge conflict 빈발. FSD: 각자 다른 도메인 슬라이스(features/buyer/, features/order/)에서 작업 → 파일이 겹칠 일이 구조적으로 없음.

충돌 가능성 ~80% ↓
🤖

AI 토큰 사용 효율 극대화

"바이어 이메일 수정해줘" — 기존: 관련 없는 20개 API 함수, 80개 타입이 포함된 파일 전체를 컨텍스트에 넣어야 함(~1,300줄). FSD: 해당 슬라이스만 넣으면 됨(~100줄). 같은 토큰으로 10배 더 정확한 결과.

컨텍스트 노이즈 ~88% ↓
🗑️

기능 추가/삭제가 폴더 단위

기존: 기능 하나를 삭제하려면 api/, hooks/, components/, types/, store/ 등 6~10개 폴더를 수동 정리. FSD: rm -rf features/edit-buyer-email 한 줄 + import 제거로 완전히 정리 완료.

수정 파일 수 ~70% ↓
📂

"이 기능 어디있어?" 즉답 가능

기존: "바이어 태그 관련 코드가 api/에도, hooks/에도, components/에도…". FSD: features/buyer/manage-tags/ — 여기가 전부. 새 팀원도 폴더 구조만 보면 프로젝트를 파악할 수 있음.

온보딩 시간 ~50% ↓
🔒

순환 의존성 구조적 차단

"feature A가 feature B를 import하고, B가 다시 A를 import하는" 순환 참조 — FSD에서는 같은 레이어의 다른 슬라이스 import가 규칙으로 금지되며, ESLint로 커밋 시점에 자동 차단됩니다.

순환 참조 0건
📈

도메인 확장 시 기존 코드 변경 0

새 도메인(Supplier, Order 등)을 추가할 때, 기존 entities/buyerfeatures/buyer/한 줄도 수정할 필요가 없음. 새 슬라이스를 만들기만 하면 됩니다. 기존 코드 비대화 문제 해결.

기존 코드 변경 0
02 — Layer Architecture

우리 프로젝트의 FSD 레이어 설계

Next.js App Router와 결합한 실용적 구조

Next.js App Router: FSD의 app·pages 레이어 역할을 Next.js app/ 디렉토리가 수행합니다. app/에서 라우팅 + 레이아웃을 담당하고, widgets/features/entities를 조립합니다. src/ 내부에는 순수 FSD 4-Layer만 배치합니다.

📁
app/
Next.js — 라우팅, 레이아웃, 페이지 조립
↑ assembles
W
widgets
독립 UI 영역 (명사) — BuyerProfile, Header
F
features
사용자 액션 (동사) — EditEmail, FilterBuyers
E
entities
도메인 모델 — Buyer, Product (Query + 타입)
S
shared
공용 인프라 — HTTP, UI Kit, DTO, 유틸

핵심 규칙

  • 위 레이어는 아래 레이어만 import 가능
  • 같은 레이어의 다른 슬라이스 import 금지
  • 외부에서는 반드시 index.ts(Public API)를 통해 접근
  • 세그먼트: ui / api / model / lib (형태가 아닌 목적 기준)
✓ 전체 폴더 구조
├── app/                          ← Next.js 라우팅
│   ├── layout.tsx
│   ├── providers.tsx
│   ├── buyers/
│   │   ├── page.tsx
│   │   └── [id]/page.tsx
│   └── products/page.tsx
│
└── src/
    ├── widgets/
    │   ├── buyer/                ← 도메인별 그룹
    │   │   ├── buyer-profile/
    │   │   └── buyer-list-table/
    │   └── layout/
    │       └── header/
    │
    ├── features/
    │   ├── buyer/                ← 도메인별 그룹
    │   │   ├── edit-email/
    │   │   ├── edit-address/
    │   │   ├── filter-list/
    │   │   └── manage-tags/
    │   └── product/
    │       └── search/
    │
    ├── entities/
    │   ├── buyer/
    │   │   ├── api/
    │   │   ├── model/
    │   │   ├── ui/
    │   │   └── index.ts
    │   ├── product/
    │   └── order/
    │
    └── shared/
        ├── api/
        │   ├── httpClient.ts
        │   └── dto.ts            ← 전체 DTO 한 파일
        ├── ui/
        ├── lib/
        └── config/
03 — Current Code

현재 코드의 구조적 개선 포인트

타입 시스템 관점에서의 분석

현재 Buyer API 코드
export async function getBuyers(params: BuyerListParameters, config?: AxiosRequestConfig) {
  return await http.get<any, BuyerListResponse>(BASE_PATH, { ...config, params })
}
// http.get<any, Response> — 첫 번째 제네릭이 any
// → TypeScript 공식: "any는 타입 검사를 수행하지 않음"
// → @typescript-eslint: "타입 시스템의 escape hatch"로 규정, 지양 권장

export async function patchBuyerAddress(
  id: number,
  data: {                              ← 인라인 타입
    addressCountry?: string
    addressZipCode?: string
    addressState?: string
    address?: string
    detailAddress?: string
    latitude?: number
    longitude?: number
  }, ...
) { return await http.patch<any, BuyerResponse>(...) }

export async function patchBuyerProfileMedium(
  id: number,
  profileMedium: any,                  ← 파라미터 자체가 any
) { ... }

// 추가 이슈:
// - 20+개 API 함수가 단일 파일 → diff/충돌 범위 넓음
// - 캐시 키가 사용처마다 분산 → invalidation 누락 가능
// - response.ts에 모든 도메인 응답 타입 혼재
04 — Entities Layer

Entities: 백엔드 도메인 1:1 매핑

queries.ts + mutations.ts 로 TanStack Query를 캡슐화

백엔드 도메인 하나 = entities 슬라이스 하나. TanStack Query의 캐시 키와 invalidation 정책을 entities/{domain}/api 한곳에서 관리하면, 키 파편화와 불일치를 구조적으로 방지할 수 있습니다.
현재: 캐시 키 분산
// page-a.tsx
useQuery({ queryKey: ['buyer', id], ... })

// page-b.tsx
useQuery({ queryKey: ['buyers', params], ... })
// 'buyer' vs 'buyers' — 불일치

// mutation 후
queryClient.invalidateQueries(['buyer'])
// 'buyers' 리스트는 갱신 안 됨!
✓ FSD: 캐시 키 팩토리 중앙 관리
// entities/buyer/api/queries.ts
export const buyerKeys = {
  all:    ['buyer'] as const,
  lists:  () => [...buyerKeys.all, 'list'],
  list:   (p: BuyerListParams) => [...buyerKeys.lists(), p],
  detail: (id: number) => [...buyerKeys.all, 'detail', id],
  tags:   (kw?: string) => [...buyerKeys.all, 'tags', kw],
}

export const useBuyer = (id: number) =>
  useQuery({ queryKey: buyerKeys.detail(id), ... })

// entities/buyer/api/mutations.ts
export const usePatchBuyerEmail = () =>
  useMutation({
    mutationFn: ...,
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({
        queryKey: buyerKeys.detail(id)
      })
      queryClient.invalidateQueries({
        queryKey: buyerKeys.lists()  
      })
    },
  })
✓ entities/buyer 전체 구조
entities/buyer/
├── api/
│   ├── queries.ts          ← 캐시 키 팩토리 + useQuery hooks
│   ├── mutations.ts        ← useMutation + invalidation
│   └── buyerApi.ts         ← raw HTTP 함수 (내부용, export 안 함)
├── model/
│   ├── constants.ts        ← BUYER_STATUS 등 도메인 상수
│   └── helpers.ts          ← 도메인 특화 변환
├── ui/
│   ├── BuyerAvatar.tsx     ← 순수 표현 컴포넌트
│   └── BuyerStatusBadge.tsx
└── index.ts                ← Public API

// index.ts — 외부에 노출할 것만 명시
export { useBuyer, useBuyerList, buyerKeys } from './api/queries'
export { usePatchBuyerEmail, usePatchBuyerAddress } from './api/mutations'
export { BuyerAvatar, BuyerStatusBadge } from './ui'
// buyerApi.ts는 export 안 함 → 내부 구현
05 — DTO Types

DTO 타입: any 대신 백엔드 명세 활용

shared/api/dto.ts 한 파일에서 전체 DTO 관리

현재: any + 인라인 타입
http.get<any, BuyerListResponse>(...)
http.patch<any, BuyerResponse>(...)
profileMedium: any

// 인라인 타입 → 백엔드 변경 시 수동 동기화
data: {
  addressCountry?: string
  addressZipCode?: string
  ...
}

// types/response.ts에 모든 도메인 혼재
// → 파일 비대화, 변경 시 넓은 diff
✓ shared/api/dto.ts — 전체 DTO 한 파일
// shared/api/dto.ts
// 백엔드 Swagger에 정의된 모든 DTO를 한곳에서 관리
// 수동 작성 또는 자동 생성 도구 활용 가능

// ── Buyer ──
export interface BuyerDto {
  id: number
  email: string
  companyName: string
  contactName: string
  jobTitle: string | null
  address: BuyerAddressDto | null
  businessIndustries: string[]
  tags: BuyerTagDto[]
  profileMedium: MediaDto | null
}

export interface BuyerAddressDto {
  country?: string
  zipCode?: string
  state?: string
  address?: string
  detailAddress?: string
  latitude?: number
  longitude?: number
}

export interface PatchBuyerAddressReq { ... }
export interface BuyerTagDto { ... }
export interface MediaDto { ... }

// ── Product ──
export interface ProductDto { ... }

// ── Order ──
export interface OrderDto { ... }

// 사용:
import type { BuyerDto } from '@/shared/api/dto'
// ✅ any 대신 구체 타입 → 오타 즉시 감지

참고 — DTO 자동 생성은 선택 사항입니다. 백엔드가 Swagger/OpenAPI 명세를 제공한다면, openapi-typescriptorval 같은 도구로 dto.ts를 자동 생성하여 동기화 비용을 더 줄일 수도 있습니다. 당장은 수동으로 작성하더라도, 백엔드 DTO 구조를 한곳에 모아두는 것만으로도 타입 관리가 크게 개선됩니다. 자동 생성 도입은 이후 효율을 높이고 싶을 때 검토하면 충분합니다.

06 — Features vs Widgets

Features와 Widgets: 도메인별 그룹핑

"동사(액션)"이면 feature, "명사(영역)"이면 widget — 도메인 폴더로 한 번 더 그룹핑

Feature = 사용자 액션 (동사)
features/
├── buyer/                    ← 도메인 그룹
│   ├── edit-email/
│   │   ├── ui/
│   │   │   └── EditEmailForm.tsx
│   │   ├── model/
│   │   │   └── useEditEmail.ts ← mutation
│   │   └── index.ts
│   │
│   ├── edit-address/
│   │   ├── ui/EditAddressForm.tsx
│   │   ├── model/useEditAddress.ts
│   │   └── index.ts
│   │
│   ├── filter-list/
│   │   ├── ui/BuyerFilterPanel.tsx
│   │   ├── model/useFilterState.ts
│   │   └── index.ts
│   │
│   └── manage-tags/
│       ├── ui/TagInput.tsx
│       ├── ui/TagList.tsx
│       ├── model/useManageTags.ts
│       └── index.ts
│
├── product/                  ← 도메인 그룹
│   ├── search/
│   └── create/
│
└── order/
    └── create-order/

// ✅ 도메인/액션 2단계 구조
// ✅ 동사로 이름: edit-, filter-, manage-
// ❌ 다른 feature import 금지
Widget = 화면 영역 (명사)
widgets/
├── buyer/                    ← 도메인 그룹
│   ├── buyer-profile/
│   │   ├── ui/BuyerProfile.tsx
│   │   └── index.ts
│   │
│   └── buyer-list-table/
│       ├── ui/BuyerListTable.tsx
│       └── index.ts
│
├── product/
│   └── product-catalog/
│
└── layout/                   ← 범용 레이아웃
    ├── header/
    └── sidebar/

// Widget = features + entities를 조립

// widgets/buyer/buyer-profile/ui/BuyerProfile.tsx
import { BuyerAvatar }
  from '@/entities/buyer'
import { EditEmailForm }
  from '@/features/buyer/edit-email'
import { ManageTags }
  from '@/features/buyer/manage-tags'

export function BuyerProfile({ id }) {
  return (
    <section>
      <BuyerAvatar buyerId={id} />
      <EditEmailForm buyerId={id} />
      <ManageTags buyerId={id} />
    </section>
  )
}

// ✅ 명사로 이름: buyer-profile, header
// ✅ 비즈니스 로직 없이 조립만

판단 기준

  • useMutation, form validation, 복잡한 상태 로직 → feature
  • 데이터 표시(read-only) + 여러 feature를 레이아웃 → widget
  • 도메인 그룹 폴더(buyer/, product/)는 단순 정리용이며, 그 자체가 슬라이스는 아닙니다. 실제 슬라이스는 하위의 edit-email/, buyer-profile/ 등입니다.
07 — Shared Layer

Shared: 비즈니스 로직 없는 공용 인프라

✓ Shared 레이어 구조
shared/
├── api/
│   ├── httpClient.ts          ← Axios 인스턴스, 인터셉터
│   ├── queryClient.ts         ← TanStack QueryClient 설정
│   ├── dto.ts                 ← 전체 DTO 타입 (한 파일)
│   └── index.ts
├── ui/
│   ├── Button/
│   ├── Input/
│   ├── Modal/
│   ├── DataTable/             ← 범용 (도메인 무관)
│   └── index.ts
├── lib/
│   ├── format/
│   ├── validation/
│   └── index.ts
└── config/
    ├── env.ts
    └── routes.ts

// Import은 세그먼트별로:
import { http } from '@/shared/api'
import type { BuyerDto } from '@/shared/api/dto'
import { Button } from '@/shared/ui'
import { formatDate } from '@/shared/lib'
08 — Public API

Public API: 캡슐화의 핵심

슬라이스의 계약(Contract) + 게이트(Gate) — index.ts

✗ 내부 파일 직접 import
import { buyerApi }
  from '@/entities/buyer/api/buyerApi'   

import { BuyerAvatar }
  from '@/entities/buyer/ui/BuyerAvatar' 

// 내부 경로에 의존
// → 리팩토링 시 모든 소비자 수정 필요
✓ Public API 통한 import
import { useBuyer, BuyerAvatar }
  from '@/entities/buyer'                

import { EditEmailForm }
  from '@/features/buyer/edit-email'     

// index.ts만 바라봄
// → 내부 구조 변경해도 외부 import 불변

배럴 파일 주의: export * from(와일드카드)는 트리셰이킹에 영향을 줄 수 있습니다. 레이어/슬라이스 경계에서만 Public API를 두고, re-export 대상을 명시적으로 나열하세요.

09 — Dependency Rules

의존성 방향: FSD의 가장 강력한 제약

✓ 허용
// app/ → 모든 레이어
// widgets → features, entities, shared
// features → entities, shared
// entities → shared

import { EditEmail }
  from '@/features/buyer/edit-email'     
import { useBuyer }
  from '@/entities/buyer'                
import type { BuyerDto }
  from '@/shared/api/dto'                
✗ 금지
// ❌ feature → 다른 feature
import { ... }
  from '@/features/buyer/manage-tags'

// ❌ entity → 다른 entity
import { ... }
  from '@/entities/product'

// ❌ 하위 → 상위 (entity → feature)
import { ... }
  from '@/features/buyer/edit-email'

// ❌ 내부 파일 직접 (Public API 우회)
import { ... }
  from '@/entities/buyer/api/buyerApi'
10 — ESLint + Husky

규칙 자동 강제: 커밋 시점에 차단

Import 규칙 + any 금지를 도구로 강제합니다

핵심: 아키텍처 규칙은 "팀원의 의지"가 아니라 "도구에 의한 자동 강제"로 유지되어야 합니다. 잘못된 import나 any 사용 → 커밋 자체가 불가능하게 설정합니다.

✓ .eslintrc.js — FSD 규칙 + any 금지
module.exports = {
  plugins: ['boundaries', '@typescript-eslint'],
  settings: {
    'boundaries/elements': [
      { type: 'shared',   pattern: 'src/shared/*' },
      { type: 'entities',  pattern: 'src/entities/*' },
      { type: 'features',  pattern: 'src/features/*' },
      { type: 'widgets',   pattern: 'src/widgets/*' },
      { type: 'app',       pattern: 'app/*' },
    ],
  },
  rules: {
    // ── Import 흐름 강제 ──
    'boundaries/element-types': ['error', {
      default: 'disallow',
      rules: [
        { from: 'app',      allow: ['widgets','features','entities','shared'] },
        { from: 'widgets',  allow: ['features','entities','shared'] },
        { from: 'features', allow: ['entities','shared'] },
        { from: 'entities', allow: ['shared'] },
        { from: 'shared',   allow: ['shared'] },
      ],
    }],

    // ── any 완전 금지 ──
    '@typescript-eslint/no-explicit-any': 'error',
    '@typescript-eslint/no-unsafe-argument': 'error',
    '@typescript-eslint/no-unsafe-assignment': 'error',
    '@typescript-eslint/no-unsafe-call': 'error',
    '@typescript-eslint/no-unsafe-member-access': 'error',
    '@typescript-eslint/no-unsafe-return': 'error',

    // ── Public API 우회 방지 ──
    'no-restricted-imports': ['error', {
      patterns: [
        '@/entities/*/*/**',
        '@/features/*/*/*/**',  ← 도메인/슬라이스/내부
        '@/widgets/*/*/*/**',
      ],
    }],
  },
}
✓ Husky + lint-staged: 커밋 시점 강제
// 설치
npm install -D husky lint-staged
npx husky init

// .husky/pre-commit
npx lint-staged

// package.json
{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --max-warnings=0",    ← warning도 0개
      "tsc-files --noEmit"           ← 타입 체크
    ]
  }
}

// 흐름:
// 1. 개발 중 → IDE 빨간 줄 (즉시 피드백)
// 2. git commit → pre-commit hook → ESLint 실행
//    → any or 잘못된 import → 커밋 거부
// 3. CI → 이중 검증
이 커밋은 거부됩니다
// features/buyer/edit-email/model/useEditEmail.ts

import { useManageTags }
  from '@/features/buyer/manage-tags'
// ❌ feature → feature 교차 import

const data: any = response.data
// ❌ no-explicit-any

import { buyerApi }
  from '@/entities/buyer/api/buyerApi'
// ❌ Public API 우회

// → husky pre-commit: exit code 1
// → 커밋 불가
이 커밋은 통과합니다
// features/buyer/edit-email/model/useEditEmail.ts

import { useBuyer }
  from '@/entities/buyer'
// ✓ feature → entity

import type { BuyerDto }
  from '@/shared/api/dto'
// ✓ feature → shared

const data: BuyerDto = response.data
// ✓ 구체 타입

// → eslint passed ✔
// → tsc passed ✔
// → 커밋 성공
11 — AI Context

AI 도구와 FSD의 시너지

🧠 컨텍스트 윈도우 효율

LLM은 컨텍스트가 길어질수록 중간 정보를 놓치는 "Lost in the Middle" 현상이 발생합니다. FSD는 관련 코드만 정확히 추출하여 최소 컨텍스트로 제공할 수 있게 합니다.
기존: ~1,300줄 투입 (88% 노이즈)
libs/api/buyers.ts     200줄 (20개 함수 전부)
types/response.ts      500줄 (모든 도메인)
types/request.ts       400줄 (모든 도메인)
hooks/useBuyerEmail.ts 50줄
components/EmailForm   100줄
실제 필요: ~150줄
✓ FSD: ~100줄 (노이즈 ≈ 0%)
features/buyer/edit-email/
├── ui/EditEmailForm.tsx  40줄
├── model/useEditEmail.ts 30줄
└── index.ts              3줄
shared/api/dto.ts         25줄 (해당 부분만)

📐 .cursorrules와의 결합

✓ .cursorrules — 5줄이면 전체 규칙 전달
# FSD Rules
- features/{domain}/{verb-noun}/ → ui/, model/, index.ts
- entities/{domain}/ → api/(queries+mutations), model/, ui/, index.ts
- DTO types: shared/api/dto.ts (single file, all domains)
- Import only through index.ts. Never import internal files.
- features → entities, shared only. Never feature → feature.

🔍 RAG / 코드 검색 정확도

features/buyer/edit-email/ui/EditEmailForm.tsx — 파일 경로만으로 "buyer 도메인의 이메일 편집 feature UI"를 AI가 즉시 파악. src/components/EmailForm.tsx에서는 어떤 도메인인지 파일을 열어봐야 알 수 있습니다.
~88%
컨텍스트 노이즈 감소
5줄
.cursorrules로 규칙 전달
경로=문서
RAG 검색 시 경로로 식별
패턴 일관
기존 슬라이스 → 새 코드 정확 생성
12 — Note

참고 사항

본 문서의 코드 예시는 FSD 구조를 설명하기 위한 참고용입니다. 실제 프로젝트에 적용할 때는 팀의 컨벤션과 상황에 맞게 조정하면 됩니다. 폴더 이름, 세그먼트 구성, 파일 분리 방식 등은 팀에서 함께 논의하여 우리 프로젝트에 맞는 기준을 정하는 것이 가장 좋습니다.

13 — Conclusion

도입 제안 요약

측면현재FSD 도입 후
동시 작업 충돌공유 폴더 수정 → 충돌 빈발도메인 슬라이스 분리 → 충돌 최소화
AI 토큰 효율~1,300줄 컨텍스트 (88% 노이즈)~100줄 컨텍스트 (관련 코드만)
기능 추가/삭제6~10개 폴더 수정폴더 생성/삭제로 완료
타입 안전성any → 컴파일 타임 검증 무력화DTO 타입 + ESLint → 빌드에서 잡힘
Import 규칙없음 → 순환 참조 가능boundaries → 커밋 시점 차단
캐시 키사용처마다 분산entities/{domain}/api 중앙 관리
코드 탐색한 기능 = 여러 폴더한 슬라이스 = 한 기능의 전부
규칙 유지코드 리뷰에 의존Husky pre-commit (도구 강제)

본 문서에서 사용된 코드와 폴더 구조는 FSD의 개념을 설명하기 위한 예시이며, 정답이 아닙니다. 실제 적용 시에는 팀에서 함께 논의하여 우리 프로젝트에 맞는 기준을 정하는 것이 가장 좋습니다.