도메인 기반 코드 분리로 Git 충돌 감소, AI 토큰 효율 극대화, 타입 안전성 강화까지
FSD가 가져다주는 가장 분명하고 실질적인 메리트들
기존: 여러 사람이 components/, hooks/, api/ 같은 공유 폴더를 동시에 수정 → merge conflict 빈발. FSD: 각자 다른 도메인 슬라이스(features/buyer/, features/order/)에서 작업 → 파일이 겹칠 일이 구조적으로 없음.
"바이어 이메일 수정해줘" — 기존: 관련 없는 20개 API 함수, 80개 타입이 포함된 파일 전체를 컨텍스트에 넣어야 함(~1,300줄). FSD: 해당 슬라이스만 넣으면 됨(~100줄). 같은 토큰으로 10배 더 정확한 결과.
기존: 기능 하나를 삭제하려면 api/, hooks/, components/, types/, store/ 등 6~10개 폴더를 수동 정리. FSD: rm -rf features/edit-buyer-email 한 줄 + import 제거로 완전히 정리 완료.
기존: "바이어 태그 관련 코드가 api/에도, hooks/에도, components/에도…". FSD: features/buyer/manage-tags/ — 여기가 전부. 새 팀원도 폴더 구조만 보면 프로젝트를 파악할 수 있음.
"feature A가 feature B를 import하고, B가 다시 A를 import하는" 순환 참조 — FSD에서는 같은 레이어의 다른 슬라이스 import가 규칙으로 금지되며, ESLint로 커밋 시점에 자동 차단됩니다.
새 도메인(Supplier, Order 등)을 추가할 때, 기존 entities/buyer나 features/buyer/는 한 줄도 수정할 필요가 없음. 새 슬라이스를 만들기만 하면 됩니다. 기존 코드 비대화 문제 해결.
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 라우팅
│ ├── 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/
타입 시스템 관점에서의 분석
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에 모든 도메인 응답 타입 혼재
queries.ts + mutations.ts 로 TanStack Query를 캡슐화
// page-a.tsx
useQuery({ queryKey: ['buyer', id], ... })
// page-b.tsx
useQuery({ queryKey: ['buyers', params], ... })
// 'buyer' vs 'buyers' — 불일치
// mutation 후
queryClient.invalidateQueries(['buyer'])
// 'buyers' 리스트는 갱신 안 됨!
// 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/
├── 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 안 함 → 내부 구현
shared/api/dto.ts 한 파일에서 전체 DTO 관리
http.get<any, BuyerListResponse>(...)
http.patch<any, BuyerResponse>(...)
profileMedium: any
// 인라인 타입 → 백엔드 변경 시 수동 동기화
data: {
addressCountry?: string
addressZipCode?: string
...
}
// types/response.ts에 모든 도메인 혼재
// → 파일 비대화, 변경 시 넓은 diff
// 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-typescript나 orval 같은 도구로 dto.ts를 자동 생성하여 동기화 비용을 더 줄일 수도 있습니다. 당장은 수동으로 작성하더라도, 백엔드 DTO 구조를 한곳에 모아두는 것만으로도 타입 관리가 크게 개선됩니다. 자동 생성 도입은 이후 효율을 높이고 싶을 때 검토하면 충분합니다.
"동사(액션)"이면 feature, "명사(영역)"이면 widget — 도메인 폴더로 한 번 더 그룹핑
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 금지
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, 복잡한 상태 로직 → featurebuyer/, product/)는 단순 정리용이며, 그 자체가 슬라이스는 아닙니다. 실제 슬라이스는 하위의 edit-email/, buyer-profile/ 등입니다.슬라이스의 계약(Contract) + 게이트(Gate) — index.ts
import { buyerApi }
from '@/entities/buyer/api/buyerApi' ❌
import { BuyerAvatar }
from '@/entities/buyer/ui/BuyerAvatar' ❌
// 내부 경로에 의존
// → 리팩토링 시 모든 소비자 수정 필요
import { useBuyer, BuyerAvatar }
from '@/entities/buyer' ✓
import { EditEmailForm }
from '@/features/buyer/edit-email' ✓
// index.ts만 바라봄
// → 내부 구조 변경해도 외부 import 불변
배럴 파일 주의: export * from(와일드카드)는 트리셰이킹에 영향을 줄 수 있습니다. 레이어/슬라이스 경계에서만 Public API를 두고, re-export 대상을 명시적으로 나열하세요.
// 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'
Import 규칙 + any 금지를 도구로 강제합니다
핵심: 아키텍처 규칙은 "팀원의 의지"가 아니라 "도구에 의한 자동 강제"로 유지되어야 합니다. 잘못된 import나 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/*/*/*/**',
],
}],
},
}
// 설치
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 ✔
// → 커밋 성공
libs/api/buyers.ts 200줄 (20개 함수 전부)
types/response.ts 500줄 (모든 도메인)
types/request.ts 400줄 (모든 도메인)
hooks/useBuyerEmail.ts 50줄
components/EmailForm 100줄
실제 필요: ~150줄
features/buyer/edit-email/
├── ui/EditEmailForm.tsx 40줄
├── model/useEditEmail.ts 30줄
└── index.ts 3줄
shared/api/dto.ts 25줄 (해당 부분만)
# 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.
features/buyer/edit-email/ui/EditEmailForm.tsx — 파일 경로만으로 "buyer 도메인의 이메일 편집 feature UI"를 AI가 즉시 파악. src/components/EmailForm.tsx에서는 어떤 도메인인지 파일을 열어봐야 알 수 있습니다.
본 문서의 코드 예시는 FSD 구조를 설명하기 위한 참고용입니다. 실제 프로젝트에 적용할 때는 팀의 컨벤션과 상황에 맞게 조정하면 됩니다. 폴더 이름, 세그먼트 구성, 파일 분리 방식 등은 팀에서 함께 논의하여 우리 프로젝트에 맞는 기준을 정하는 것이 가장 좋습니다.
| 측면 | 현재 | FSD 도입 후 |
|---|---|---|
| 동시 작업 충돌 | 공유 폴더 수정 → 충돌 빈발 | 도메인 슬라이스 분리 → 충돌 최소화 |
| AI 토큰 효율 | ~1,300줄 컨텍스트 (88% 노이즈) | ~100줄 컨텍스트 (관련 코드만) |
| 기능 추가/삭제 | 6~10개 폴더 수정 | 폴더 생성/삭제로 완료 |
| 타입 안전성 | any → 컴파일 타임 검증 무력화 | DTO 타입 + ESLint → 빌드에서 잡힘 |
| Import 규칙 | 없음 → 순환 참조 가능 | boundaries → 커밋 시점 차단 |
| 캐시 키 | 사용처마다 분산 | entities/{domain}/api 중앙 관리 |
| 코드 탐색 | 한 기능 = 여러 폴더 | 한 슬라이스 = 한 기능의 전부 |
| 규칙 유지 | 코드 리뷰에 의존 | Husky pre-commit (도구 강제) |
본 문서에서 사용된 코드와 폴더 구조는 FSD의 개념을 설명하기 위한 예시이며, 정답이 아닙니다. 실제 적용 시에는 팀에서 함께 논의하여 우리 프로젝트에 맞는 기준을 정하는 것이 가장 좋습니다.