logoTomalab
홈블로그소개

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

ReactTypescriptWeb

FSD 아키텍처 적용 후기 — 실전에서 배운 것들

Toma
2026년 2월 17일
목차
📌 왜 FSD인가
🧱 핵심 개념: 3차원 구조
Layer (레이어)
Slice (슬라이스)
Segment (세그먼트)
🔁 임포트 규칙과 Public API
단방향 의존성
Public API
🏢 레이어별 역할
App
Pages
Widgets
Features
Entities
Shared
🔨 실제 프로젝트 적용
실제 레이어 구성
세그먼트 적용 패턴
상태 관리 패턴
Widgets 레이어 활용
⚖️ 간소화된 FSD: 선택과 트레이드오프
1. entities 레이어 부재
2. features 내 깊은 중첩
3. index.ts Public API의 비대화
🚀 향후 개선 방향
1. entities 레이어를 처음부터 분리하라
2. 슬라이스를 Flat하게 유지하라
3. api 세그먼트를 명시적으로 분리하라
4. ESLint 규칙으로 임포트 방향을 자동 검증하라
5. 점진적 도입 전략
🎯 정리

목차

📌 왜 FSD인가
🧱 핵심 개념: 3차원 구조
Layer (레이어)
Slice (슬라이스)
Segment (세그먼트)
🔁 임포트 규칙과 Public API
단방향 의존성
Public API
🏢 레이어별 역할
App
Pages
Widgets
Features
Entities
Shared
🔨 실제 프로젝트 적용
실제 레이어 구성
세그먼트 적용 패턴
상태 관리 패턴
Widgets 레이어 활용
⚖️ 간소화된 FSD: 선택과 트레이드오프
1. entities 레이어 부재
2. features 내 깊은 중첩
3. index.ts Public API의 비대화
🚀 향후 개선 방향
1. entities 레이어를 처음부터 분리하라
2. 슬라이스를 Flat하게 유지하라
3. api 세그먼트를 명시적으로 분리하라
4. ESLint 규칙으로 임포트 방향을 자동 검증하라
5. 점진적 도입 전략
🎯 정리

💬 Feature-Sliced Design(FSD) 은 프론트엔드 애플리케이션을 위한 아키텍처 방법론이다. 단순한 폴더 구조 규칙이 아니라, "이 코드는 어디에 두어야 하는가?"라는 질문에 항상 일관된 답을 줄 수 있는 전략적 도구다.

참고: FSD 공식 문서 · 카카오페이 기술 블로그


📌 왜 FSD인가

프로젝트가 커질수록 이런 문제가 반복된다.

  • 새 기능을 어느 폴더에 넣어야 할지 매번 팀원들과 논쟁하게 된다
  • 한 컴포넌트를 수정했는데 전혀 관계없어 보이는 기능이 망가진다
  • 공통 컴포넌트에 특정 도메인 로직이 슬금슬금 섞여든다
  • 코드베이스를 처음 보는 사람이 원하는 코드를 찾는 데 오래 걸린다

FSD는 의존성의 방향과 코드의 책임 범위를 명시적으로 정의해 이 문제들을 구조적으로 해결한다.


🧱 핵심 개념: 3차원 구조

FSD는 코드를 세 가지 축으로 분류한다.

FSD Layers, Slices, Segments 구조도

Layer (레이어)

코드를 재사용 범위와 의존성 수준에 따라 수직으로 나눈 계층. 아래로 갈수록 더 범용적이고, 위로 갈수록 더 구체적인 비즈니스 로직을 담는다.

javascript
app       ← 라우터, 전역 설정, 진입점
pages     ← 화면(라우트) 단위 컨테이너
widgets   ← 여러 페이지에서 재사용되는 대형 UI 블록
features  ← 재사용 가능한 비즈니스 기능 단위
entities  ← 핵심 도메인 데이터 모델 (User, Product...)
shared    ← 프로젝트 무관한 범용 코드

Slice (슬라이스)

레이어 내에서 비즈니스 도메인 단위로 코드를 수평 분할한 것. user, product, geometry-info 등이 슬라이스가 된다.

⚠️ 같은 레이어의 슬라이스끼리는 서로 참조할 수 없다. 이것이 사이드이펙트를 차단하는 핵심 규칙이다.

Segment (세그먼트)

슬라이스 내부를 기술적 목적에 따라 분류한 것.

세그먼트내용
uiReact 컴포넌트, 스타일
model상태, 스키마, 비즈니스 로직
apiAPI 요청 함수, 타입, 매퍼
lib슬라이스 전용 유틸리티
config설정, 상수, feature flag

💡 세그먼트명은 "코드의 성격"이 아닌 **"코드의 목적"**을 설명해야 한다. components/ ❌ → ui/ ✅ | utils/ ❌ → lib/ ✅


🔁 임포트 규칙과 Public API

단방향 의존성

javascript
app → pages → widgets → features → entities → shared

상위 레이어는 하위 레이어만 가져올 수 있다. 반대 방향은 절대 불가하다. 이 규칙 하나가 순환 의존성을 구조적으로 차단한다.

typescript
// ✅ 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

Public API는 슬라이스 간 결합도를 통제하는 핵심 메커니즘이다. 각 슬라이스는 index.ts 하나를 유일한 진입점으로 삼아, 외부에 공개할 것만 명시적으로 노출한다.

javascript
features/geometry-info/
├── floor/model/store.ts       ← 내부 구현 (직접 임포트 금지)
├── surfaces/model/store.ts    ← 내부 구현 (직접 임포트 금지)
└── index.ts                   ← 슬라이스의 공개 API (유일한 진입점)

Public API가 중요한 이유:

  1. 내부 구현을 자유롭게 바꿀 수 있다 — index.ts 인터페이스를 유지하는 한 내부 폴더를 얼마든지 재구성해도 외부 코드는 영향을 받지 않는다
  2. 무엇이 공개된 것인지 명확해진다 — index.ts에 없으면 내부 구현이다
  3. 의존성 방향이 강제된다 — ESLint로 내부 경로 참조를 금지하면 위반이 자동 차단된다

Public API 설계 원칙:

원칙설명
필요한 것만 노출내부 헬퍼, 중간 컴포넌트는 노출하지 않는다
타입도 명시적으로export type으로 명시해야 한다
경로는 슬라이스까지@/features/geometry-info까지만
크기를 감시하라index.ts가 50~100줄을 넘으면 슬라이스 분리 신호

🏢 레이어별 역할

App

전체 애플리케이션에 영향을 미치는 코드. 라우터 설정, 글로벌 Provider, 전역 스타일이 여기에 속한다. 슬라이스 없이 세그먼트만 존재하는 레이어다.

Pages

라우트 하나 = 슬라이스 하나가 원칙. 페이지는 features와 widgets를 조합하는 역할이며, 자체 비즈니스 로직을 최소화해야 한다.

Widgets

여러 페이지에서 재사용되는 자기 완결적 UI 블록. GNB, 사이드바 등이 해당한다.

💡 단 하나의 페이지에서만 쓰인다면 그 페이지 안에 두는 것이 맞다.

Features

사용자가 수행하는 인터랙션 단위. "로그인", "파일 저장", "데이터 필터링"처럼 동사적 성격을 가진다.

⚠️ "모든 것을 feature로 만들 필요는 없다." 한 페이지에서만 쓰이는 기능은 그 페이지 안에 두어도 된다.

Entities

프로젝트의 핵심 비즈니스 개념. User, Building, Zone처럼 명사적 성격을 가진다.

Shared

비즈니스 도메인에 무관한 범용 코드. UI 키트, API 클라이언트, 공통 훅, 유틸리티 함수가 여기에 속한다.


🔨 실제 프로젝트 적용

에너지 시뮬레이션 Electron 앱에 FSD를 적용한 경험이다. 도메인이 복잡하고 화면 수가 많았다.

  • 랜딩 → 기본 정보 → 형상 정보(층/공간/외피) → 속성(구조체/창호/조명/설비/환기/신재생) → 분석/결과

실제 레이어 구성

javascript
src/
├── app/      → 라우터(createHashRouter), 전역 타입, 스타일
├── pages/    → 라우트별 컨테이너 (features 조합 역할)
├── features/ → 도메인 피처 모듈
├── widgets/  → GNB, SNB, Stepper (레이아웃 수준 재사용 UI)
└── shared/   → 공통 UI 컴포넌트, 훅, 유틸리티

세그먼트 적용 패턴

javascript
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에 맞게 사용하는 핵심 패턴이다. 스토어 정의와 셀렉터를 같은 파일에서 익스포트하여 과도한 리렌더링을 방지한다.

typescript
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 레이어 활용

javascript
widgets/
├── gnb/       → Global Navigation Bar (모든 페이지에 공통)
├── snb/       → Side Navigation Bar (단계 네비게이션)
├── stepper/   → 단계 진행 표시기
└── tooltip/   → 전역 툴팁

이 컴포넌트들은 특정 도메인에 종속되지 않으면서 여러 페이지에서 공유된다는 점에서 widgets 레이어가 정확히 맞는 위치다.


⚖️ 간소화된 FSD: 선택과 트레이드오프

완전한 FSD 스펙 대신 핵심 원칙만 지키는 간소화된 형태로 운용했다. 주된 차이점과 그 결과다.

1. entities 레이어 부재

javascript
// 표준 FSD
entities/zone/model/types.ts     → 타입 정의
features/geometry/zones/...      → Zone 관련 기능

// 실제 프로젝트
features/geometry/model/types.ts → 모든 타입이 features 안에 존재
  • 장점: 레이어 수가 줄어 구조가 단순하고, 초기 설계 비용이 낮다
  • 단점: 도메인 모델과 기능 로직의 경계가 흐려진다. 여러 features에서 같은 타입을 공유할 때 shared에 복사하거나 간접 의존성이 생긴다

2. features 내 깊은 중첩

FSD는 슬라이스를 단일 depth로 유지하도록 권장하지만, 실제로는 features/attributes/source-supply/source-system/chiller/ 같은 4~5 depth가 존재했다.

  • 장점: 도메인 계층 구조가 폴더에 그대로 반영되어 직관적이다
  • 단점: index.ts 배럴 익스포트가 매우 길어지고 경로가 복잡해진다

3. index.ts Public API의 비대화

⚠️ features/geometry-info/index.ts는 100줄이 넘는다. 이는 슬라이스가 너무 많은 것을 공개하고 있다는 신호다.

이 상황은 geometry-info가 단일 슬라이스가 아닌 여러 슬라이스를 묶은 "슬라이스 그룹" 역할을 하고 있음을 보여준다.


🚀 향후 개선 방향

1. entities 레이어를 처음부터 분리하라

javascript
entities/
├── zone/model/types.ts      → Zone, ZoneData 타입
├── surface/model/types.ts
└── floor/model/types.ts

features/
├── geometry-manage/         → 형상 CRUD 기능
├── zone-equipment/          → 존 설비 관리 기능
└── surface-construction/    → 외피 구조 기능

2. 슬라이스를 Flat하게 유지하라

중첩 슬라이스 대신 네이밍 컨벤션으로 그룹을 표현한다.

javascript
// ❌ 중첩 방식
features/attributes/source-supply/source-system/chiller/

// ✅ Flat + 그룹 폴더 방식
features/
└── supply-systems/        ← 그룹 폴더 (index.ts 없음)
    ├── chiller/           ← 실제 슬라이스
    ├── boiler/
    └── heat-pump/

3. api 세그먼트를 명시적으로 분리하라

typescript
// ❌ 현재: 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 });
  }
}));

4. ESLint 규칙으로 임포트 방향을 자동 검증하라

아키텍처 규칙은 문서로만 남기면 지켜지지 않는다. eslint-plugin-boundaries를 설정하면 CI에서 위반을 자동으로 잡을 수 있다.

javascript
// .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"] }
      ]
    }]
  }
}

5. 점진적 도입 전략

기존 프로젝트에 FSD를 도입할 때는 아래 순서가 유효하다.

javascript
단계 1: shared 레이어 정리
단계 2: features/pages 분리
단계 3: index.ts Public API 도입
단계 4: entities 분리 (필요 시)
단계 5: 린트 규칙 자동화

🎯 정리

✅ FSD를 완벽하게 따르는 것보다, FSD가 왜 그런 규칙을 만들었는지 이해하는 것이 더 중요하다.

이해 위에서 현실에 맞게 조정하는 것과, 이해 없이 임의로 변형하는 것은 전혀 다른 결과를 낳는다.

FSD는 단순한 폴더 구조가 아닌 "코드가 어디에 속할지 확신을 주는 전략적 도구"다. 프로젝트 규모가 커지면서 구조적 복잡성을 느낀다면 충분히 고려할 가치가 있다.