💬 Feature-Sliced Design(FSD) 은 프론트엔드 애플리케이션을 위한 아키텍처 방법론이다. 단순한 폴더 구조 규칙이 아니라, "이 코드는 어디에 두어야 하는가?"라는 질문에 항상 일관된 답을 줄 수 있는 전략적 도구다.
참고: FSD 공식 문서 · 카카오페이 기술 블로그
프로젝트가 커질수록 이런 문제가 반복된다.
FSD는 의존성의 방향과 코드의 책임 범위를 명시적으로 정의해 이 문제들을 구조적으로 해결한다.
FSD는 코드를 세 가지 축으로 분류한다.

코드를 재사용 범위와 의존성 수준에 따라 수직으로 나눈 계층. 아래로 갈수록 더 범용적이고, 위로 갈수록 더 구체적인 비즈니스 로직을 담는다.
app ← 라우터, 전역 설정, 진입점
pages ← 화면(라우트) 단위 컨테이너
widgets ← 여러 페이지에서 재사용되는 대형 UI 블록
features ← 재사용 가능한 비즈니스 기능 단위
entities ← 핵심 도메인 데이터 모델 (User, Product...)
shared ← 프로젝트 무관한 범용 코드레이어 내에서 비즈니스 도메인 단위로 코드를 수평 분할한 것. user, product, geometry-info 등이 슬라이스가 된다.
⚠️ 같은 레이어의 슬라이스끼리는 서로 참조할 수 없다. 이것이 사이드이펙트를 차단하는 핵심 규칙이다.
슬라이스 내부를 기술적 목적에 따라 분류한 것.
| 세그먼트 | 내용 |
|---|---|
ui | React 컴포넌트, 스타일 |
model | 상태, 스키마, 비즈니스 로직 |
api | API 요청 함수, 타입, 매퍼 |
lib | 슬라이스 전용 유틸리티 |
config | 설정, 상수, feature flag |
💡 세그먼트명은 "코드의 성격"이 아닌 **"코드의 목적"**을 설명해야 한다.
components/❌ →ui/✅ |utils/❌ →lib/✅
app → pages → widgets → features → entities → shared상위 레이어는 하위 레이어만 가져올 수 있다. 반대 방향은 절대 불가하다. 이 규칙 하나가 순환 의존성을 구조적으로 차단한다.
// ✅ pages에서 features를 가져오는 것은 정상
import { GeometryManageDialog } from "@/features/geometry-info";
// ❌ features에서 pages를 가져오는 것은 규칙 위반
import { GeometryInfoPage } from "@/pages/geometry-info";
// ❌ 슬라이스 내부 경로 직접 참조 금지
import { useZoneStore } from "@/features/geometry-info/zones/model/store";
// ✅ index.ts를 통한 Public API 참조
import { useZoneStore } from "@/features/geometry-info";Public API는 슬라이스 간 결합도를 통제하는 핵심 메커니즘이다. 각 슬라이스는 index.ts 하나를 유일한 진입점으로 삼아, 외부에 공개할 것만 명시적으로 노출한다.
features/geometry-info/
├── floor/model/store.ts ← 내부 구현 (직접 임포트 금지)
├── surfaces/model/store.ts ← 내부 구현 (직접 임포트 금지)
└── index.ts ← 슬라이스의 공개 API (유일한 진입점)Public API가 중요한 이유:
index.ts 인터페이스를 유지하는 한 내부 폴더를 얼마든지 재구성해도 외부 코드는 영향을 받지 않는다index.ts에 없으면 내부 구현이다Public API 설계 원칙:
| 원칙 | 설명 |
|---|---|
| 필요한 것만 노출 | 내부 헬퍼, 중간 컴포넌트는 노출하지 않는다 |
| 타입도 명시적으로 | export type으로 명시해야 한다 |
| 경로는 슬라이스까지 | @/features/geometry-info까지만 |
| 크기를 감시하라 | index.ts가 50~100줄을 넘으면 슬라이스 분리 신호 |
전체 애플리케이션에 영향을 미치는 코드. 라우터 설정, 글로벌 Provider, 전역 스타일이 여기에 속한다. 슬라이스 없이 세그먼트만 존재하는 레이어다.
라우트 하나 = 슬라이스 하나가 원칙. 페이지는 features와 widgets를 조합하는 역할이며, 자체 비즈니스 로직을 최소화해야 한다.
여러 페이지에서 재사용되는 자기 완결적 UI 블록. GNB, 사이드바 등이 해당한다.
💡 단 하나의 페이지에서만 쓰인다면 그 페이지 안에 두는 것이 맞다.
사용자가 수행하는 인터랙션 단위. "로그인", "파일 저장", "데이터 필터링"처럼 동사적 성격을 가진다.
⚠️ "모든 것을 feature로 만들 필요는 없다." 한 페이지에서만 쓰이는 기능은 그 페이지 안에 두어도 된다.
프로젝트의 핵심 비즈니스 개념. User, Building, Zone처럼 명사적 성격을 가진다.
비즈니스 도메인에 무관한 범용 코드. UI 키트, API 클라이언트, 공통 훅, 유틸리티 함수가 여기에 속한다.
에너지 시뮬레이션 Electron 앱에 FSD를 적용한 경험이다. 도메인이 복잡하고 화면 수가 많았다.
src/
├── app/ → 라우터(createHashRouter), 전역 타입, 스타일
├── pages/ → 라우트별 컨테이너 (features 조합 역할)
├── features/ → 도메인 피처 모듈
├── widgets/ → GNB, SNB, Stepper (레이아웃 수준 재사용 UI)
└── shared/ → 공통 UI 컴포넌트, 훅, 유틸리티features/geometry/.../
├── model/
│ ├── store.ts → Zustand 스토어 + 세분화된 셀렉터 훅
│ ├── schema.ts → Zod 유효성 검사 스키마
│ ├── types.ts → TypeScript 타입
│ └── columns/ → TanStack Table 컬럼 정의
├── hooks/ → 커스텀 훅 (useEquipmentManage 등)
├── lib/ → 순수 계산 함수 (computeDensity...)
└── ui/ → React 컴포넌트
├── Form.tsx
├── .../
└── .../Zustand를 FSD에 맞게 사용하는 핵심 패턴이다. 스토어 정의와 셀렉터를 같은 파일에서 익스포트하여 과도한 리렌더링을 방지한다.
const useZoneStore = create<ZoneState>((set) => ({
zones: {},
setZonesForFloor: (floorId, zones) =>
set((state) => ({ zones: { ...state.zones, [floorId]: zones } })),
}));
// 세분화된 셀렉터를 named export로 공개
export const useZonesForFloor = (floorId: string) =>
useZoneStore((s) => s.zones[floorId] ?? []);
export const useSetZonesForFloor = () =>
useZoneStore((s) => s.setZonesForFloor);✅ 컴포넌트는 스토어 전체가 아닌 필요한 조각만 구독하므로 불필요한 리렌더링이 없다.
widgets/
├── gnb/ → Global Navigation Bar (모든 페이지에 공통)
├── snb/ → Side Navigation Bar (단계 네비게이션)
├── stepper/ → 단계 진행 표시기
└── tooltip/ → 전역 툴팁이 컴포넌트들은 특정 도메인에 종속되지 않으면서 여러 페이지에서 공유된다는 점에서 widgets 레이어가 정확히 맞는 위치다.
완전한 FSD 스펙 대신 핵심 원칙만 지키는 간소화된 형태로 운용했다. 주된 차이점과 그 결과다.
entities 레이어 부재// 표준 FSD
entities/zone/model/types.ts → 타입 정의
features/geometry/zones/... → Zone 관련 기능
// 실제 프로젝트
features/geometry/model/types.ts → 모든 타입이 features 안에 존재shared에 복사하거나 간접 의존성이 생긴다FSD는 슬라이스를 단일 depth로 유지하도록 권장하지만, 실제로는 features/attributes/source-supply/source-system/chiller/ 같은 4~5 depth가 존재했다.
index.ts 배럴 익스포트가 매우 길어지고 경로가 복잡해진다index.ts Public API의 비대화⚠️
features/geometry-info/index.ts는 100줄이 넘는다. 이는 슬라이스가 너무 많은 것을 공개하고 있다는 신호다.
이 상황은 geometry-info가 단일 슬라이스가 아닌 여러 슬라이스를 묶은 "슬라이스 그룹" 역할을 하고 있음을 보여준다.
entities 레이어를 처음부터 분리하라entities/
├── zone/model/types.ts → Zone, ZoneData 타입
├── surface/model/types.ts
└── floor/model/types.ts
features/
├── geometry-manage/ → 형상 CRUD 기능
├── zone-equipment/ → 존 설비 관리 기능
└── surface-construction/ → 외피 구조 기능중첩 슬라이스 대신 네이밍 컨벤션으로 그룹을 표현한다.
// ❌ 중첩 방식
features/attributes/source-supply/source-system/chiller/
// ✅ Flat + 그룹 폴더 방식
features/
└── supply-systems/ ← 그룹 폴더 (index.ts 없음)
├── chiller/ ← 실제 슬라이스
├── boiler/
└── heat-pump/api 세그먼트를 명시적으로 분리하라// ❌ 현재: store.ts 안에 API 호출이 섞임
const useZoneStore = create((set) => ({
fetchZones: async (floorId) => {
const data = await fetch(`/api/zones?floor=${floorId}`);
set({ zones: data });
}
}));
// ✅ 개선안: api 세그먼트 분리
// features/zone-manage/api/index.ts
export const fetchZones = (floorId: string) =>
apiClient.get<Zone[]>(`/zones?floor=${floorId}`);
// features/zone-manage/model/store.ts
const useZoneStore = create((set) => ({
fetchZones: async (floorId) => {
const data = await fetchZones(floorId); // api 세그먼트 사용
set({ zones: data });
}
}));아키텍처 규칙은 문서로만 남기면 지켜지지 않는다. eslint-plugin-boundaries를 설정하면 CI에서 위반을 자동으로 잡을 수 있다.
// .eslintrc.js
{
"rules": {
"boundaries/element-types": ["error", {
"default": "disallow",
"rules": [
{ "from": "pages", "allow": ["widgets", "features", "shared"] },
{ "from": "features", "allow": ["entities", "shared"] },
{ "from": "widgets", "allow": ["features", "entities", "shared"] }
]
}]
}
}기존 프로젝트에 FSD를 도입할 때는 아래 순서가 유효하다.
단계 1: shared 레이어 정리
단계 2: features/pages 분리
단계 3: index.ts Public API 도입
단계 4: entities 분리 (필요 시)
단계 5: 린트 규칙 자동화✅ FSD를 완벽하게 따르는 것보다, FSD가 왜 그런 규칙을 만들었는지 이해하는 것이 더 중요하다.
이해 위에서 현실에 맞게 조정하는 것과, 이해 없이 임의로 변형하는 것은 전혀 다른 결과를 낳는다.
FSD는 단순한 폴더 구조가 아닌 "코드가 어디에 속할지 확신을 주는 전략적 도구"다. 프로젝트 규모가 커지면서 구조적 복잡성을 느낀다면 충분히 고려할 가치가 있다.