junyeokk
Blog
React·2026. 02. 15

Feature-Sliced Design

React 프로젝트가 커지면 파일을 어디에 둘지가 점점 큰 문제가 된다. 처음에는 components/, hooks/, utils/ 같은 기술 역할별 폴더 구조로 시작하는 경우가 많다.

text
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 세 가지 축으로 분류한다.

text
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처럼 비즈니스 도메인에 따라 분리한다.

text
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/ — 상수
text
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에서 가장 중요한 규칙 중 하나다.

typescript
// 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를 통해서만 접근한다:

typescript
// ✅ 올바른 import
import { LoginForm, useAuth } from '@/features/auth';

// ❌ 잘못된 import — 내부 구조에 직접 접근
import { LoginForm } from '@/features/auth/ui/LoginForm';

내부 파일에 직접 접근하면 슬라이스의 내부 구조 변경이 외부에 영향을 미친다. index.ts로 캡슐화하면 내부를 자유롭게 리팩터링할 수 있다. 이 규칙은 eslint로 강제할 수 있다 (뒤에서 설명).


의존성 규칙 상세

FSD의 핵심은 단방향 의존성이다. 이 규칙만 제대로 지키면 프로젝트가 커져도 구조가 무너지지 않는다.

규칙 1: 상위 → 하위만 가능

text
app → pages → widgets → features → entities → shared

featuresentitiesshared만 import할 수 있다. widgetspages를 import하면 안 된다.

typescript
// 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하면 안 된다. 두 엔티티가 관련되어야 하면 상위 레이어에서 조합한다.

typescript
// ❌ 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 인스턴스, 날짜 포맷 유틸 등 어떤 프로젝트에서도 쓸 수 있는 것들만 들어간다.

text
src/shared/
├── ui/
│   ├── Button.tsx
│   ├── Input.tsx
│   └── Modal.tsx
├── api/
│   └── apiClient.ts
├── lib/
│   ├── formatDate.ts
│   └── cn.ts
└── config/
    └── routes.ts

실전 폴더 구조 예시

쇼핑몰 프로젝트를 FSD로 구성하면 다음과 같다:

text
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: 사용자가 수행하는 행동. "로그인하다", "장바구니에 담다", "리뷰를 쓰다".
text
entities/product/    → 상품이라는 개념 (타입, 카드 UI, 조회 API)
features/add-to-cart/ → 상품을 장바구니에 담는 행위

ProductCard는 상품 정보를 표시하는 UI이므로 entity에 속한다. AddToCartButton은 장바구니에 담는 행위이므로 feature에 속한다.

Feature vs Widget

  • Feature: 하나의 사용자 행동. 독립적으로 의미가 있다.
  • Widget: 여러 entity나 feature를 조합한 독립적 UI 블록.
text
features/add-to-cart/   → 장바구니 추가 버튼 하나
widgets/product-list/   → ProductCard(entity) + AddToCartButton(feature) 조합

위젯은 "이 블록만 떼어서 다른 페이지에 놓아도 동작하는가?"로 판단한다. 헤더, 사이드바, 상품 카드 리스트 같은 것들이 위젯이다.

shared에 넣을지 entities에 넣을지

  • formatDate() → shared (도메인과 무관한 범용 유틸)
  • formatOrderStatus() → entities/order (주문 도메인에 종속)
  • Button → shared (범용 UI)
  • ProductCard → entities/product (상품 도메인에 종속)

조합 패턴: 상위 레이어에서 하위를 연결

같은 레이어의 슬라이스끼리 직접 통신할 수 없으므로, 상위 레이어에서 "조합"하는 패턴이 자주 사용된다.

tsx
// 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: 상위 레이어에서 조합

가장 정석적인 방법이다.

tsx
// 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으로 열어두는 방법이다.

tsx
// 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>
  );
}
tsx
// 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를 사용하면 자동으로 검사할 수 있다.

bash
npm install -D @feature-sliced/eslint-config
javascript
// .eslintrc.js
module.exports = {
  extends: ['@feature-sliced'],
};

주요 규칙:

  • import/no-internal-modules: 슬라이스 내부 파일 직접 접근 차단
  • import/order: 레이어 순서에 맞는 import 정렬
  • boundaries/element-types: 상위→하위 의존성 규칙 강제

@feature-sliced/eslint-config가 없어도 eslint-plugin-boundaries로 직접 규칙을 설정할 수 있다:

javascript
// .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/로 옮긴다.

text
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 순으로 점진적 도입이 가능하다

관련 문서