Feature-Sliced Design
React 프로젝트가 커지면 파일을 어디에 둘지가 점점 큰 문제가 된다. 처음에는 components/, hooks/, utils/ 같은 기술 역할별 폴더 구조로 시작하는 경우가 많다.
src/
├── components/
│ ├── Button.tsx
│ ├── UserProfile.tsx
│ ├── OrderList.tsx
│ ├── CartItem.tsx
│ └── ... (50개)
├── hooks/
│ ├── useAuth.tsx
│ ├── useCart.tsx
│ └── useOrder.tsx
├── utils/
│ ├── formatDate.ts
│ ├── calculatePrice.ts
│ └── validateEmail.ts
└── pages/
├── Home.tsx
├── Profile.tsx
└── Checkout.tsx
파일이 50개, 100개가 되면 문제가 드러난다. components/ 안에 관련 없는 컴포넌트들이 뒤섞이고, "주문 관련 로직을 수정해야 해"라고 했을 때 components/OrderList.tsx, hooks/useOrder.tsx, utils/calculatePrice.ts, pages/Checkout.tsx를 모두 찾아다녀야 한다. 기능 하나를 이해하려면 프로젝트 전체를 뒤져야 하는 상황이 온다.
Feature-Sliced Design(FSD)은 이 문제를 기능(feature) 단위로 코드를 조직화해서 해결하는 아키텍처 방법론이다. 러시아 프론트엔드 커뮤니티에서 시작해서 2021년쯤 공식 스펙이 정리됐고, 현재 React/Vue/Angular 등 프레임워크에 관계없이 사용할 수 있다.
핵심 개념: 3가지 축
FSD는 코드를 Layer → Slice → Segment 세 가지 축으로 분류한다.
src/
├── app/ ← Layer 1 (최상위)
├── processes/ ← Layer 2
├── pages/ ← Layer 3
├── widgets/ ← Layer 4
├── features/ ← Layer 5
├── entities/ ← Layer 6
└── shared/ ← Layer 7 (최하위)
Layer (레이어)
프로젝트를 수직으로 나누는 계층이다. 총 7개의 표준 레이어가 있고, 상위 레이어는 하위 레이어만 import할 수 있다는 규칙이 핵심이다. 이 단방향 의존성 규칙 덕분에 순환 참조가 구조적으로 불가능하다.
| 레이어 | 역할 | 예시 |
|---|---|---|
app | 앱 초기화, 전역 설정 | 라우터, 프로바이더, 전역 스타일 |
processes | 여러 페이지에 걸친 비즈니스 프로세스 | 결제 흐름, 온보딩 (선택적, 거의 안 씀) |
pages | 라우트별 페이지 | HomePage, ProfilePage |
widgets | 독립적인 UI 블록 | 헤더, 사이드바, 상품 카드 리스트 |
features | 사용자 행동 (비즈니스 가치 단위) | 로그인, 장바구니 추가, 리뷰 작성 |
entities | 비즈니스 엔티티 | User, Product, Order |
shared | 재사용 가능한 공통 코드 | UI 키트, 유틸, API 클라이언트, 타입 |
processes 레이어는 실무에서 거의 사용하지 않는다. 대부분의 프로젝트에서는 6개 레이어로 충분하다.
Slice (슬라이스)
레이어 안에서 도메인별로 나누는 단위다. features/auth, features/cart, entities/user, entities/product처럼 비즈니스 도메인에 따라 분리한다.
src/features/
├── auth/ ← Slice
├── add-to-cart/ ← Slice
└── write-review/ ← Slice
같은 레이어의 슬라이스끼리는 서로 import할 수 없다. features/auth에서 features/cart를 직접 가져다 쓰면 안 된다. 이 규칙이 결합도를 낮추는 핵심이다. 두 feature가 소통해야 하면 상위 레이어(widget이나 page)에서 조합한다.
Segment (세그먼트)
슬라이스 안에서 기술적 역할별로 나누는 단위다. 표준 세그먼트는 다음과 같다:
ui/— React 컴포넌트model/— 비즈니스 로직, 상태 관리 (store, hook)api/— API 호출lib/— 유틸리티 함수config/— 설정값consts/— 상수
src/features/auth/
├── ui/
│ ├── LoginForm.tsx
│ └── LogoutButton.tsx
├── model/
│ ├── useAuth.ts
│ └── types.ts
├── api/
│ └── authApi.ts
└── index.ts ← Public API
Public API: index.ts
각 슬라이스는 반드시 index.ts를 통해 외부에 공개할 것만 내보낸다. 이것이 FSD에서 가장 중요한 규칙 중 하나다.
// features/auth/index.ts
export { LoginForm } from './ui/LoginForm';
export { LogoutButton } from './ui/LogoutButton';
export { useAuth } from './model/useAuth';
export type { User } from './model/types';
외부에서는 반드시 이 public API를 통해서만 접근한다:
// ✅ 올바른 import
import { LoginForm, useAuth } from '@/features/auth';
// ❌ 잘못된 import — 내부 구조에 직접 접근
import { LoginForm } from '@/features/auth/ui/LoginForm';
내부 파일에 직접 접근하면 슬라이스의 내부 구조 변경이 외부에 영향을 미친다. index.ts로 캡슐화하면 내부를 자유롭게 리팩터링할 수 있다. 이 규칙은 eslint로 강제할 수 있다 (뒤에서 설명).
의존성 규칙 상세
FSD의 핵심은 단방향 의존성이다. 이 규칙만 제대로 지키면 프로젝트가 커져도 구조가 무너지지 않는다.
규칙 1: 상위 → 하위만 가능
app → pages → widgets → features → entities → shared
features는 entities와 shared만 import할 수 있다. widgets나 pages를 import하면 안 된다.
// features/add-to-cart/model/useAddToCart.ts
// ✅ entities 레이어 (하위) 접근 가능
import { Product } from '@/entities/product';
// ✅ shared 레이어 (최하위) 접근 가능
import { apiClient } from '@/shared/api';
// ❌ widgets 레이어 (상위) 접근 불가
import { Header } from '@/widgets/header';
// ❌ 같은 레이어의 다른 슬라이스 접근 불가
import { useAuth } from '@/features/auth';
규칙 2: 같은 레이어의 슬라이스 간 교차 참조 금지
entities/user에서 entities/product를 import하면 안 된다. 두 엔티티가 관련되어야 하면 상위 레이어에서 조합한다.
// ❌ entities/order/model/types.ts
import { Product } from '@/entities/product'; // 같은 레이어 교차 참조
// ✅ 대신 widgets/order-card/ui/OrderCard.tsx에서 조합
import { Order } from '@/entities/order';
import { Product } from '@/entities/product';
규칙 3: shared는 비즈니스 로직을 모른다
shared는 가장 아래에 있어서 모든 레이어가 사용하지만, 특정 도메인에 종속되면 안 된다. Button, Input 같은 범용 UI, axios 인스턴스, 날짜 포맷 유틸 등 어떤 프로젝트에서도 쓸 수 있는 것들만 들어간다.
src/shared/
├── ui/
│ ├── Button.tsx
│ ├── Input.tsx
│ └── Modal.tsx
├── api/
│ └── apiClient.ts
├── lib/
│ ├── formatDate.ts
│ └── cn.ts
└── config/
└── routes.ts
실전 폴더 구조 예시
쇼핑몰 프로젝트를 FSD로 구성하면 다음과 같다:
src/
├── app/
│ ├── providers/
│ │ ├── RouterProvider.tsx
│ │ ├── QueryProvider.tsx
│ │ └── ThemeProvider.tsx
│ ├── styles/
│ │ └── global.css
│ └── index.tsx
│
├── pages/
│ ├── home/
│ │ ├── ui/
│ │ │ └── HomePage.tsx
│ │ └── index.ts
│ ├── product-detail/
│ │ ├── ui/
│ │ │ └── ProductDetailPage.tsx
│ │ └── index.ts
│ └── checkout/
│ ├── ui/
│ │ └── CheckoutPage.tsx
│ └── index.ts
│
├── widgets/
│ ├── header/
│ │ ├── ui/
│ │ │ └── Header.tsx
│ │ └── index.ts
│ ├── product-list/
│ │ ├── ui/
│ │ │ └── ProductList.tsx
│ │ └── index.ts
│ └── cart-sidebar/
│ ├── ui/
│ │ └── CartSidebar.tsx
│ └── index.ts
│
├── features/
│ ├── auth/
│ │ ├── ui/
│ │ │ ├── LoginForm.tsx
│ │ │ └── LogoutButton.tsx
│ │ ├── model/
│ │ │ └── useAuth.ts
│ │ ├── api/
│ │ │ └── authApi.ts
│ │ └── index.ts
│ ├── add-to-cart/
│ │ ├── ui/
│ │ │ └── AddToCartButton.tsx
│ │ ├── model/
│ │ │ └── useCart.ts
│ │ ├── api/
│ │ │ └── cartApi.ts
│ │ └── index.ts
│ └── search-products/
│ ├── ui/
│ │ └── SearchBar.tsx
│ ├── model/
│ │ └── useSearch.ts
│ └── index.ts
│
├── entities/
│ ├── user/
│ │ ├── ui/
│ │ │ └── UserAvatar.tsx
│ │ ├── model/
│ │ │ └── types.ts
│ │ └── index.ts
│ ├── product/
│ │ ├── ui/
│ │ │ └── ProductCard.tsx
│ │ ├── model/
│ │ │ └── types.ts
│ │ ├── api/
│ │ │ └── productApi.ts
│ │ └── index.ts
│ └── order/
│ ├── model/
│ │ └── types.ts
│ └── index.ts
│
└── shared/
├── ui/
│ ├── Button.tsx
│ ├── Input.tsx
│ └── Spinner.tsx
├── api/
│ └── apiClient.ts
├── lib/
│ ├── cn.ts
│ └── formatPrice.ts
└── config/
└── routes.ts
이 구조에서 "장바구니 기능을 수정해야 해"라고 하면 features/add-to-cart/만 보면 된다. UI, 로직, API가 한 곳에 모여 있기 때문이다.
레이어 구분이 애매할 때
실무에서 가장 많이 고민되는 부분이다. 기준을 명확히 잡아두면 혼란이 줄어든다.
Entity vs Feature
- Entity: 비즈니스 개념 자체. "사용자", "상품", "주문". 행동이 아니라 존재.
- Feature: 사용자가 수행하는 행동. "로그인하다", "장바구니에 담다", "리뷰를 쓰다".
entities/product/ → 상품이라는 개념 (타입, 카드 UI, 조회 API)
features/add-to-cart/ → 상품을 장바구니에 담는 행위
ProductCard는 상품 정보를 표시하는 UI이므로 entity에 속한다. AddToCartButton은 장바구니에 담는 행위이므로 feature에 속한다.
Feature vs Widget
- Feature: 하나의 사용자 행동. 독립적으로 의미가 있다.
- Widget: 여러 entity나 feature를 조합한 독립적 UI 블록.
features/add-to-cart/ → 장바구니 추가 버튼 하나
widgets/product-list/ → ProductCard(entity) + AddToCartButton(feature) 조합
위젯은 "이 블록만 떼어서 다른 페이지에 놓아도 동작하는가?"로 판단한다. 헤더, 사이드바, 상품 카드 리스트 같은 것들이 위젯이다.
shared에 넣을지 entities에 넣을지
formatDate()→ shared (도메인과 무관한 범용 유틸)formatOrderStatus()→ entities/order (주문 도메인에 종속)Button→ shared (범용 UI)ProductCard→ entities/product (상품 도메인에 종속)
조합 패턴: 상위 레이어에서 하위를 연결
같은 레이어의 슬라이스끼리 직접 통신할 수 없으므로, 상위 레이어에서 "조합"하는 패턴이 자주 사용된다.
// widgets/product-list/ui/ProductListItem.tsx
import { ProductCard } from '@/entities/product'; // entity
import { AddToCartButton } from '@/features/add-to-cart'; // feature
import { LikeButton } from '@/features/like-product'; // feature
export function ProductListItem({ product }: Props) {
return (
<ProductCard product={product}>
<AddToCartButton productId={product.id} />
<LikeButton productId={product.id} />
</ProductCard>
);
}
ProductCard(entity)는 장바구니나 좋아요에 대해 모른다. AddToCartButton(feature)은 상품 카드 UI에 대해 모른다. Widget이 이 둘을 조합해서 완성된 UI를 만든다. 이렇게 하면 각 슬라이스는 자기 역할에만 집중하고 결합도가 낮아진다.
Cross-Import 문제 해결
실무에서는 "entities끼리 참조해야 하는 상황"이 반드시 생긴다. 예를 들어 주문 목록에서 상품 정보를 표시해야 할 때.
해결법 1: 상위 레이어에서 조합
가장 정석적인 방법이다.
// widgets/order-list/ui/OrderItem.tsx
import { Order } from '@/entities/order';
import { ProductCard } from '@/entities/product';
export function OrderItem({ order }: Props) {
return (
<div>
<Order.Status status={order.status} />
{order.products.map(p => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
해결법 2: Render Prop / Slot 패턴
entity가 자기가 모르는 콘텐츠를 표시해야 할 때, slot으로 열어두는 방법이다.
// entities/order/ui/OrderCard.tsx
interface OrderCardProps {
order: Order;
productSlot?: React.ReactNode; // 외부에서 주입
}
export function OrderCard({ order, productSlot }: OrderCardProps) {
return (
<div>
<h3>주문 #{order.id}</h3>
<p>상태: {order.status}</p>
{productSlot}
</div>
);
}
// widgets/order-list/ui/OrderItem.tsx
import { OrderCard } from '@/entities/order';
import { ProductCard } from '@/entities/product';
export function OrderItem({ order }: Props) {
return (
<OrderCard
order={order}
productSlot={order.products.map(p => (
<ProductCard key={p.id} product={p} />
))}
/>
);
}
이 패턴이 FSD에서 cross-import를 피하는 가장 깔끔한 방법이다.
ESLint로 규칙 강제하기
FSD 규칙을 코드 리뷰에만 의존하면 금방 무너진다. @feature-sliced/eslint-config를 사용하면 자동으로 검사할 수 있다.
npm install -D @feature-sliced/eslint-config
// .eslintrc.js
module.exports = {
extends: ['@feature-sliced'],
};
주요 규칙:
- import/no-internal-modules: 슬라이스 내부 파일 직접 접근 차단
- import/order: 레이어 순서에 맞는 import 정렬
- boundaries/element-types: 상위→하위 의존성 규칙 강제
@feature-sliced/eslint-config가 없어도 eslint-plugin-boundaries로 직접 규칙을 설정할 수 있다:
// .eslintrc.js
{
plugins: ['boundaries'],
settings: {
'boundaries/elements': [
{ type: 'app', pattern: 'src/app/*' },
{ type: 'pages', pattern: 'src/pages/*' },
{ type: 'widgets', pattern: 'src/widgets/*' },
{ type: 'features', pattern: 'src/features/*' },
{ type: 'entities', pattern: 'src/entities/*' },
{ type: 'shared', pattern: 'src/shared/*' },
],
'boundaries/ignore': ['src/index.ts'],
},
rules: {
'boundaries/element-types': [2, {
default: 'disallow',
rules: [
{ from: 'app', allow: ['pages', 'widgets', 'features', 'entities', 'shared'] },
{ from: 'pages', allow: ['widgets', 'features', 'entities', 'shared'] },
{ from: 'widgets', allow: ['features', 'entities', 'shared'] },
{ from: 'features', allow: ['entities', 'shared'] },
{ from: 'entities', allow: ['shared'] },
{ from: 'shared', allow: ['shared'] },
],
}],
},
}
기존 프로젝트에 점진적 도입
기존 프로젝트를 하루아침에 FSD로 전환하는 건 현실적이지 않다. 점진적으로 도입하는 전략이 있다.
1단계: shared 레이어 분리
가장 쉽고 리스크가 낮다. 공통 UI 컴포넌트, 유틸, API 클라이언트를 shared/로 옮긴다.
src/
├── shared/ ← 새로 만듦
│ ├── ui/
│ └── api/
├── components/ ← 기존 구조 유지
├── pages/
└── hooks/
2단계: entities 분리
비즈니스 엔티티별로 타입, 기본 UI, API를 묶는다.
3단계: features 분리
사용자 행동 단위로 묶는다. 이 단계에서 가장 많은 리팩터링이 발생한다.
4단계: 나머지 정리
pages, widgets를 정리하고 기존 components/, hooks/ 폴더를 비운다.
각 단계마다 기존 코드와 공존할 수 있다. 한 번에 하지 않아도 된다.
언제 FSD를 쓰면 좋고, 언제 과한가
FSD가 적합한 경우
- 팀 규모가 3명 이상이고 프로젝트가 6개월 이상 유지보수될 때
- 도메인이 복잡하고 기능 간 경계가 명확할 때
- 코드 소유권을 팀/기능 단위로 나누고 싶을 때
FSD가 과한 경우
- 혼자 하는 소규모 프로젝트 (토이 프로젝트, 랜딩 페이지)
- 도메인이 단순해서 entity가 2~3개밖에 없을 때
- 프로토타입이나 MVP 단계에서 빠르게 구조가 바뀔 때
기존 방식과의 비교
기술 역할별 (components/hooks/utils/) | FSD | |
|---|---|---|
| 장점 | 단순, 진입장벽 없음 | 기능별 응집도 높음, 의존성 규칙 명확 |
| 단점 | 규모 커지면 관련 파일 찾기 어려움 | 초기 학습 비용, 구조 결정에 시간 필요 |
| 적합한 규모 | 소~중규모 | 중~대규모 |
| 팀 | 1~2명 | 3명 이상 |
정리
- Layer → Slice → Segment 세 축으로 코드를 조직화하고, 상위 레이어가 하위만 import하는 단방향 의존성 규칙으로 순환 참조를 구조적으로 차단한다
- 각 슬라이스는 index.ts(Public API)로 캡슐화하고, 같은 레이어 간 교차 참조는 상위 레이어에서 조합 패턴이나 Slot 패턴으로 해결한다
- 팀 3명 이상, 6개월 이상 유지보수 프로젝트에 적합하며, shared → entities → features 순으로 점진적 도입이 가능하다
관련 문서
- 공식 문서
- React Context API - 전역 상태 관리
- 도메인 기반 폴더 구조 - FSD의 경량 버전 접근