코드 위치를 두고 팀이 싸우지 않게 된 날 — Feature-Sliced Design으로 프론트엔드 폴더 구조 정착시키기
"이 컴포넌트, 어디다 넣어야 해요?" 프론트엔드 팀이라면 한 번쯤 이 질문으로 슬랙이 달아올랐을 겁니다. PR 리뷰 때마다 "이건 components보다 utils가 맞지 않나요?"라는 코멘트가 붙고, 프로젝트가 커질수록 components 폴더가 수백 개의 파일로 불어나는 경험 — 저도 정확히 그랬습니다. 서비스가 어느 정도 궤도에 오르면 반드시 찾아오는 문제인데, 그걸 구조로 해결해준 게 바로 Feature-Sliced Design이었습니다.
FSD(Feature-Sliced Design)는 기술 역할(hook인지, util인지)이 아니라 비즈니스 기능(feature) 단위로 코드를 조직하는 프론트엔드 아키텍처 방법론입니다. 처음 들으면 당연한 말 같지만, 막상 팀에 적용해보면 많은 것이 달라집니다. 저희 팀의 경우 도입 후 PR 리뷰에서 "어디에 넣어야 해?"류의 코멘트가 눈에 띄게 줄었고, 새로 합류한 팀원이 코드베이스를 파악하는 시간도 체감상 절반 이하로 줄었습니다.
이 글은 React/Next.js를 주로 쓰는 프론트엔드 개발자를 주요 대상으로 합니다. FSD의 구조와 작동 원리, 실제 프로젝트에 어떻게 녹여낼 수 있는지, 그리고 솔직히 저희 팀이 어떤 부분에서 삽질했는지까지 풀어보겠습니다.
핵심 개념
30초 요약: FSD는 코드를 "기술 역할"이 아닌 "비즈니스 기능" 단위로 나누고, 레이어 간 단방향 의존성 규칙으로 순환 참조를 구조적으로 차단합니다. 각 기능 단위(슬라이스)는
index.ts하나로만 외부에 노출됩니다.
세 계층으로 이루어진 구조 — Layer, Slice, Segment
FSD는 세 가지 개념이 겹겹이 쌓인 구조입니다. 처음 문서를 봤을 때 저도 한참 헷갈렸는데, 마트 진열대에 비유하면 이해가 빨랐습니다. **레이어는 층(냉동식품 코너, 음료 코너 같은 것)이고, 슬라이스는 그 층의 칸(음료 중에서도 탄산, 주스), 세그먼트는 칸 안의 정리 방식(큰 것부터, 브랜드별)**입니다.
레이어(Layer) 는 6~7개가 고정되어 있으며, 위에서 아래로만 참조할 수 있습니다(단방향 의존성).
| 레이어 | 역할 | 예시 |
|---|---|---|
app |
라우팅, 전역 Provider, 전역 스타일 | providers/, router/, styles/ |
pages |
페이지 단위 컴포넌트 | DashboardPage, ProfilePage |
widgets |
독립적인 UI 블록 | Header, Sidebar, DataTable |
features |
비즈니스 가치를 제공하는 인터랙션 단위 | auth/, report-download/ |
entities |
비즈니스 도메인 모델 | user/, product/, order/ |
shared |
프로젝트 무관 재사용 코드 | ui/Button, api/httpClient |
pages는 widgets를 참조할 수 있고, widgets는 features를 참조할 수 있습니다. 반대 방향, 즉 shared가 features를 참조하거나 entities가 features를 참조하는 건 규칙 위반입니다. 이 단 하나의 규칙이 순환 의존성을 구조적으로 차단합니다.
슬라이스(Slice) 는 각 레이어 안을 도메인별로 나눈 것입니다. features/auth, features/payment-history처럼 이름은 프로젝트 목적에 맞게 자유롭게 정할 수 있습니다.
세그먼트(Segment) 는 슬라이스 내부를 기술 역할별로 나눕니다.
features/
└── auth/
├── ui/ # 컴포넌트
├── model/ # 상태, 비즈니스 로직
├── api/ # API 호출
├── lib/ # 유틸리티
└── index.ts # Public APIfeatures vs widgets — 실무에서 가장 헷갈리는 경계
FSD를 처음 도입하면 꼭 이 질문이 나옵니다. "헤더가 widgets면, 헤더 안의 검색 기능은 features인가요?" 저희 팀도 이 경계 때문에 미팅에서 20분을 허비한 적이 있습니다.
구분 기준은 **"이것이 독립적인 UI 블록인가, 아니면 사용자가 수행하는 인터랙션인가"**입니다.
widgets: 여러 기능을 조합해 만든 독립적인 UI 블록. 그 자체로 화면 한 구역을 차지하며, 직접 비즈니스 로직을 소유하지 않습니다.Header,Sidebar,DataTable이 여기에 해당합니다.features: 사용자가 수행하는 하나의 인터랙션 단위. 비즈니스 가치를 직접 제공합니다. "로그인하기", "리포트 다운로드하기", "필터 적용하기"가 여기에 해당합니다.
헤더의 검색창 UI 껍데기는 widgets/header에 있어도 되지만, 검색을 실제로 수행하는 로직과 UI는 features/search로 분리해서 widgets/header가 가져다 쓰는 구조가 자연스럽습니다.
Public API 패턴 — 캡슐화의 핵심
Public API 패턴: 각 슬라이스는
index.ts하나를 통해서만 외부에 자신을 노출합니다. 외부에서 슬라이스 내부 파일을 직접 import하는 것은 금지입니다.
처음엔 번거롭게 느껴질 수 있습니다. 하지만 이 패턴 덕분에 슬라이스 내부를 마음껏 리팩터링해도 외부 코드에 영향을 주지 않습니다. index.ts가 변하지 않는 한, 외부는 아무것도 신경 쓸 필요가 없으니까요.
// features/auth/index.ts — 이것만이 외부에 공개됩니다
export { LoginForm } from './ui/LoginForm';
export { useAuth } from './model/useAuth';
export type { AuthUser } from './model/types';
// 아래 파일들은 외부에서 직접 접근 불가 — 내부 구현 상세
// features/auth/api/authApi.ts (비공개)
// features/auth/model/authStore.ts (비공개)// ✅ 올바른 import
import { LoginForm, useAuth } from '@/features/auth';
// ❌ 잘못된 import — 내부 구현에 직접 접근
import { LoginForm } from '@/features/auth/ui/LoginForm';entities 레이어도 마찬가지입니다. entities/user/나 entities/order/ 안에는 타입, UI 컴포넌트뿐 아니라 해당 도메인의 API 호출 로직(api/ 세그먼트)도 함께 들어갈 수 있습니다. 외부에서는 항상 index.ts를 통해서만 접근합니다.
실전 적용
예시 1: 전형적인 SaaS 대시보드 구조 잡기
실무에서 가장 많이 마주치는 형태가 대시보드 프로젝트입니다. FSD를 처음 적용할 때 이 구조로 시작해보시면 감이 빠르게 잡힐 겁니다.
src/
├── app/
│ ├── providers/ # Redux Provider, QueryClientProvider
│ ├── router/ # 라우팅 설정
│ └── styles/ # 전역 CSS
│
├── pages/
│ ├── dashboard/
│ │ ├── ui/DashboardPage.tsx
│ │ └── index.ts
│ └── profile/
│ ├── ui/ProfilePage.tsx
│ └── index.ts
│
├── widgets/
│ ├── sidebar/
│ │ ├── ui/Sidebar.tsx
│ │ └── index.ts
│ └── data-table/
│ ├── ui/DataTable.tsx
│ └── index.ts
│
├── features/
│ ├── auth/
│ │ ├── ui/LoginForm.tsx
│ │ ├── model/useAuth.ts
│ │ ├── api/authApi.ts
│ │ └── index.ts
│ ├── report-download/
│ │ ├── ui/DownloadButton.tsx
│ │ ├── model/useReportDownload.ts
│ │ ├── lib/generatePdf.ts
│ │ └── index.ts
│ └── filter-apply/
│ ├── ui/FilterPanel.tsx
│ ├── model/filterStore.ts
│ └── index.ts
│
├── entities/
│ ├── user/
│ │ ├── model/types.ts
│ │ ├── ui/UserAvatar.tsx
│ │ └── index.ts
│ └── order/
│ ├── api/orderApi.ts # 도메인 API 호출도 entities에
│ ├── model/types.ts
│ ├── ui/OrderStatusBadge.tsx
│ └── index.ts
│
└── shared/
├── ui/
│ ├── Button/
│ ├── Modal/
│ └── Input/
├── api/
│ └── httpClient.ts
└── lib/
└── formatDate.ts핵심은 report-download 기능을 보시면 이해가 됩니다. 다운로드 버튼 UI, PDF 생성 로직, 관련 훅이 features/report-download 하나에 모두 들어 있습니다. 이 기능을 제거해야 할 때 폴더 하나만 지우면 됩니다. 팀원 중 누구도 "혹시 이 함수 다른 데서 쓰이나요?"를 물을 필요가 없어집니다. 저희 팀에서 처음으로 이 구조로 기능을 삭제했을 때 "이게 이렇게 깔끔하게 지워지네"라는 반응이 바로 나왔습니다.
예시 2: Next.js App Router와 함께 쓰기
Next.js App Router로 넘어온 분들이 FSD를 처음 적용할 때 가장 많이 물어보는 게 "app 폴더 이름이 겹치는데 어떻게 하나요?"입니다. 공식 권장 방식은 FSD 레이어를 언더스코어 prefix로 구분하는 것입니다.
my-next-app/
├── app/ # Next.js App Router (라우팅 전용)
│ ├── (dashboard)/
│ │ └── page.tsx # _pages/dashboard에서 가져와 렌더링
│ └── layout.tsx
│
└── src/
├── _app/ # FSD app 레이어 (Provider, 전역 설정)
├── _pages/ # FSD pages 레이어
│ └── dashboard/
│ ├── ui/DashboardPage.tsx
│ └── index.ts
├── widgets/
├── features/
├── entities/
└── shared/_app, _pages prefix를 사용할 때는 tsconfig.json의 paths 설정도 함께 업데이트해야 합니다. @/_pages/dashboard처럼 alias가 올바르게 잡혀야 Next.js 라우트 파일에서 깔끔하게 import할 수 있습니다. 상세 설정은 FSD 공식 Next.js 가이드를 참고하시면 좋습니다.
// app/(dashboard)/page.tsx — Next.js 라우트 파일은 얇게 유지
import { DashboardPage } from '@/_pages/dashboard';
export default function Page() {
return <DashboardPage />;
}// _pages/dashboard/ui/DashboardPage.tsx
import { OrderList } from '@/widgets/order-list';
import { FilterPanel } from '@/features/filter-apply';
import { getOrders } from '@/entities/order'; // Public API를 통해 접근
export async function DashboardPage() {
const orders = await getOrders(); // Server Component에서 fetch
return (
<div>
<FilterPanel />
<OrderList orders={orders} />
</div>
);
}저희 팀은 Server Component를 entities와 shared의 데이터 페칭 로직에, Client Component를 features의 인터랙션 단위에 배치하는 방식으로 정착했습니다. 이 구분이 처음에는 어색했는데, 막상 해보니 "이 컴포넌트가 Client여야 하나?" 자체를 덜 고민하게 됐습니다. 레이어가 그 결정을 자연스럽게 유도해주는 느낌이었습니다.
예시 3: Steiger로 아키텍처 규칙 자동 검사하기
규칙을 문서로만 관리하면 팀원마다 해석이 달라집니다. FSD 공식 린터 steiger를 설정하면 CI 단계에서 레이어 방향 위반을 자동으로 잡아줍니다.
# 설치
pnpm add -D steiger @feature-sliced/steiger-plugin// steiger.config.ts
import { defineConfig } from 'steiger';
import fsd from '@feature-sliced/steiger-plugin';
export default defineConfig([
...fsd.configs.recommended,
]);// package.json
{
"scripts": {
"lint:arch": "steiger ./src"
}
}shared에서 features를 import하거나, 슬라이스가 같은 레이어의 다른 슬라이스를 직접 참조하면 실제로 이런 에러가 출력됩니다.
$ pnpm lint:arch
✖ Violation: no-cross-imports (features/auth → features/payment)
src/features/auth/model/useAuth.ts
Slices on the same layer cannot import from each other.
Consider moving shared logic to @/shared or @/entities.
1 violation found.이 한 줄이 "팀 컨벤션 문서를 아무도 안 읽어도 CI가 걸러준다"는 걸 실감하게 해줍니다. 저희 팀에서 입사 첫 주 팀원이 실수로 features/auth에서 features/payment를 import했을 때, 문서 안내 없이 CI 에러 메시지만으로 바로 고쳐왔습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 순환 의존성 제거 | 단방향 레이어 규칙이 순환 참조를 구조적으로 차단 |
| 팀 병렬 작업 | 슬라이스 단위 격리로 여러 팀이 충돌 없이 동시 개발 가능 |
| 온보딩 속도 | 예측 가능한 구조로 신규 합류자가 코드 위치를 빠르게 파악 |
| 기능 삭제 용이 | 기능 수정·제거 범위가 해당 슬라이스로 명확히 한정 |
| AI 협업 궁합 | 명확한 "선반" 구조가 AI 생성 코드와 사람 코드의 위치 충돌 방지 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 초기 학습 곡선 | 레이어·슬라이스·세그먼트 개념을 팀 전체가 내재화해야 함 | 팀 워크숍 + Steiger로 즉각 피드백 |
| 소규모 프로젝트 오버헤드 | 기능이 10개 미만인 단순 앱에는 디렉토리 깊이만 늘어남 | 20개 이상 슬라이스가 예상될 때 도입 |
| 레거시 마이그레이션 | 기존 코드베이스를 FSD로 이관하는 데 상당한 공수 필요 | 신규 기능부터 FSD로 작성하며 점진적 확대 |
| Next.js 네이밍 충돌 | app/ 디렉토리가 FSD app 레이어와 겹침 |
_app, _pages prefix 컨벤션으로 해결 |
| 커뮤니티 규모 | MVC, Flux 대비 레퍼런스·튜토리얼 수가 적음 | 공식 문서 + feature-sliced/awesome 활용 |
용어 보충 — 단방향 의존성(Unidirectional Dependency): 상위 레이어는 하위 레이어를 참조할 수 있지만, 하위 레이어가 상위 레이어를 참조하는 것은 금지된 규칙입니다. React의 단방향 데이터 흐름과 같은 철학을 폴더 구조에 적용한 개념이라고 보시면 됩니다.
실무에서 가장 흔한 실수
-
shared에 비즈니스 로직을 넣는 경우 —shared는 프로젝트/비즈니스와 무관한 순수 유틸리티와 UI Kit만 있어야 합니다. 특정 도메인 냄새가 나는 코드가 보이기 시작하면entities나features로 올려야 합니다. -
같은 레이어의 슬라이스끼리 직접 참조하는 경우 —
features/auth가features/payment를 import하는 건 규칙 위반입니다. 저희 팀은 처음에 이 부분에서 "그러면 공통 로직을 어디다 빼지?"로 팀 회의를 한 번 했는데, 결론은 단순합니다.shared또는entities로 내리면 됩니다. -
index.ts없이 내부 파일을 직접 import하는 경우 — 처음엔 귀찮아서 생략하기 쉽지만, 이 패턴이 무너지면 FSD의 캡슐화 이점이 사라집니다. Steiger나 ESLint 플러그인을 초기에 세팅해두면 이 실수를 자동으로 막아줄 수 있습니다.
특히 레거시 마이그레이션을 시도할 때는 "어떤 기능을 features에 넣어야 하지?"를 결정하는 것 자체가 생각보다 오래 걸립니다. 저희 팀도 처음 마이그레이션을 시작했을 때, 기존의 UserProfileCard 컴포넌트를 widgets에 넣을지 entities/user에 넣을지를 두고 3시간짜리 토론을 한 적이 있습니다. 결국 "이 컴포넌트가 여러 도메인을 조합하는가(→ widgets), 아니면 user 도메인 하나만 표현하는가(→ entities)"라는 기준으로 정리됐는데, 이 기준을 팀이 공유하는 데 그만큼의 시간이 필요했습니다. 처음에 이 과정을 겪는 건 정상이라고 보시면 됩니다.
마치며
FSD는 "코드를 어디에 넣을까"라는 팀 내 반복 논쟁을 구조로 해결해주는 방법론입니다. 처음 적용할 때 학습 비용이 조금 들지만, 프로젝트가 20개 이상의 기능 슬라이스로 커지는 순간부터 그 비용은 빠르게 회수됩니다. 카카오페이 기술 블로그에서도 동일한 방식으로 도입 후기를 공유했을 만큼, 국내에서도 충분히 검증된 방법론입니다.
저는 처음에 신규 기능 딱 하나, 그것도 로그인 폼 하나만 FSD 구조로 작성해봤습니다. 전체를 바꾸려 하면 부담이 너무 컸으니까요. 그게 다였는데 그 작은 시작이 팀 전체로 퍼지는 데 3주가 걸렸습니다. "어, 이게 되네"라는 반응이 자연스럽게 퍼졌고, 누가 강요하지 않아도 다음 기능부터는 모두 같은 방식으로 작성하기 시작했습니다.
지금 바로 시작해볼 수 있는 3단계:
-
공식 문서로 구조 훑어보기 — feature-sliced.design에서 레이어 다이어그램과 예제를 먼저 살펴보시면 좋습니다. 개념 파악에 30분이면 충분합니다.
-
신규 기능 하나를 FSD로 작성해보기 — 기존 프로젝트 전체를 한 번에 바꾸려 하면 부담이 큽니다. 다음에 추가할 기능 하나를
features/[기능명]/ui,model,api,index.ts구조로 먼저 작성해보시면 감이 잡힙니다. -
Steiger 설정으로 규칙 자동화하기 —
pnpm add -D steiger @feature-sliced/steiger-plugin후steiger.config.ts를 세팅하면, 팀원이 규칙을 잊어도 CI가 자동으로 잡아줍니다. 린터 설정까지 마쳤다면 FSD 도입 준비는 끝난 겁니다.
참고 자료
- [한] Feature-Sliced Design 공식 문서 — Overview
- [한] Feature-Sliced Design — Usage with Next.js
- [한] FSD 아키텍처 적용기 — 카카오페이 기술 블로그
- [한] (번역) 기능 분할 설계 — 최고의 프런트엔드 아키텍처 | emewjin.log
- [EN] Steiger — 공식 FSD 아키텍처 린터 (GitHub)
- [EN] feature-sliced/awesome — 큐레이션 리소스 목록 (GitHub)
- [EN] Mastering Feature-Sliced Design: Lessons from Real Projects — DEV Community
- [EN] Understanding Feature-Sliced Design: Benefits and Real Code Examples — HackerNoon
- [EN] The Drawbacks of Feature-Sliced Design — Medium
- [EN] Clean Architecture vs. Feature-Sliced Design in Next.js — Medium