본문 바로가기
프론트엔드

FSD란?

by 코딩의 세계 2026. 2. 2.

FSD (Feature-Sliced Design) - 프론트엔드 아키텍처 혁신

들어가며

당신이 점점 커지는 리액트 프로젝트에서 다음과 같은 문제를 겪었다면, FSD가 답입니다:

  • "이 컴포넌트는 어디에 있지?" - 파일 구조가 복잡함
  • "이 유틸 함수를 쓸 수 있나?" - 순환 의존성으로 사용 불가
  • "리팩토링하면 뭔가 깨질까봐..." - 의존성 파악이 어려움
  • "여러 팀원이 같은 기능을 만들고 있어" - 협업이 어려움

FSD(Feature-Sliced Design)는 이 모든 문제를 구조적으로 해결하는 방법론입니다. 프로젝트를 수평적 계층(shared, entities, features 등)과 수직적 슬라이스(각 기능)로 구분하여, 확장성과 유지보수성이 뛰어난 아키텍처를 만듭니다.

공식 깃허브

https://github.com/feature-sliced/documentation

 

GitHub - feature-sliced/documentation: 🍰 Architectural methodology for frontend projects

🍰 Architectural methodology for frontend projects. Contribute to feature-sliced/documentation development by creating an account on GitHub.

github.com

 

FSD의 핵심 개념

기존 아키텍처의 문제점

전통적 폴더 구조:
src/
├── components/       # 모든 컴포넌트
│   ├── Button.tsx
│   ├── Header.tsx
│   ├── LoginForm.tsx
│   └── UserProfile.tsx
├── hooks/            # 모든 훅
│   ├── useAuth.ts
│   └── useFetch.ts
├── services/         # 모든 서비스
│   └── api.ts
└── utils/            # 모든 유틸리티
    └── helpers.ts

문제점:
1. 기능 단위로 파악하기 어려움
2. 컴포넌트가 어디에 속하는지 모호함
3. shared와 feature의 경계가 불명확
4. 스케일링이 어려움 (파일 검색 시간 증가)
5. 팀원 간 충돌 빈번

FSD의 철학

FSD는 다음 원칙에 기반합니다:

1. 명확한 계층 분리
   └─ 계층은 일정한 방향으로만 의존 가능

2. 기능 단위 조직
   └─ 각 기능은 독립적이고 자체 완결적

3. Public API 정의
   └─ index.ts (barrel export)만 외부에 노출

4. 순환 의존성 금지
   └─ 명확한 의존성 방향

5. 확장 가능한 구조
   └─ 새로운 기능 추가 시 기존 코드 수정 최소

FSD 아키텍처 상세 분석

레이어 구조

Presentation Layer (프리젠테이션)
└─ app/          - 애플리케이션 전역 설정, 라우팅

Feature Layer (기능)
└─ pages/        - 페이지 수준 컴포넌트
└─ features/     - 사용자 기능 (로그인, 장바구니 등)
└─ widgets/      - 복합 컴포넌트

Domain Layer (도메인)
└─ entities/     - 도메인 엔티티 (사용자, 상품 등)

Shared Layer (공유)
└─ shared/       - 모든 계층에서 사용 가능

각 계층 상세 분석

1. app/ - 애플리케이션 레벨

app/
├── layouts/
│   ├── RootLayout.tsx      # 기본 레이아웃
│   └── AuthLayout.tsx      # 인증 필요 레이아웃
├── providers/
│   ├── ThemeProvider.tsx   # 테마 프로바이더
│   ├── AuthProvider.tsx    # 인증 프로바이더
│   └── index.tsx           # 모든 프로바이더 통합
├── routes/
│   ├── AppRoutes.tsx       # 라우팅 설정
│   └── ProtectedRoutes.tsx # 보호된 라우트
├── styles/
│   └── globals.css         # 전역 스타일
├── App.tsx                 # 메인 앱 컴포넌트
└── main.tsx               # 진입점
// app/App.tsx - 전역 구조
import { AppRoutes } from './routes/AppRoutes';
import { Providers } from './providers';
import './styles/globals.css';

export function App() {
  return (
    <Providers>
      <AppRoutes />
    </Providers>
  );
}

2. pages/ - 페이지 레이어

pages/
├── home/
│   ├── HomePage.tsx        # 페이지 컴포넌트
│   └── index.ts
├── product-detail/
│   ├── ProductDetailPage.tsx
│   └── index.ts
├── checkout/
│   ├── CheckoutPage.tsx
│   └── index.ts
└── not-found/
    ├── NotFoundPage.tsx
    └── index.ts

규칙:
- 오직 페이지 레이아웃만 책임
- features와 widgets를 조합
- 페이지 specific 상태 관리 (거의 없음)
// pages/home/HomePage.tsx
import { ProductList } from 'features/product-list';
import { HeroSection } from 'widgets/hero-section';
import { RecommendedProducts } from 'widgets/recommended-products';

export function HomePage() {
  return (
    <div>
      <HeroSection />
      <ProductList />
      <RecommendedProducts />
    </div>
  );
}

3. features/ - 기능 레이어

가장 중요한 레이어입니다. 각 기능은 완전히 독립적입니다.

features/
├── auth/
│   ├── model/              # 상태 관리, 타입
│   │   ├── authStore.ts    # Zustand/Redux store
│   │   ├── types.ts        # 타입 정의
│   │   └── index.ts
│   ├── ui/                 # UI 컴포넌트
│   │   ├── LoginForm.tsx   # 로그인 폼
│   │   ├── SignupForm.tsx  # 회원가입 폼
│   │   └── index.ts
│   ├── api/                # API 호출 (내부용)
│   │   ├── authApi.ts
│   │   └── index.ts
│   ├── lib/                # 헬퍼, 유틸 (내부용)
│   │   ├── validators.ts
│   │   └── index.ts
│   └── index.ts            # Public API
│
├── product-list/
│   ├── model/
│   │   ├── productStore.ts
│   │   └── types.ts
│   ├── ui/
│   │   ├── ProductCard.tsx
│   │   ├── ProductList.tsx
│   │   └── Filters.tsx
│   ├── api/
│   │   └── productApi.ts
│   └── index.ts
│
└── shopping-cart/
    ├── model/
    │   ├── cartStore.ts
    │   └── types.ts
    ├── ui/
    │   ├── CartItems.tsx
    │   └── CartSummary.tsx
    ├── api/
    │   └── cartApi.ts
    └── index.ts

각 폴더의 역할:

  • model/: 상태 관리, 타입, 도메인 로직
// features/auth/model/authStore.ts
import { create } from 'zustand';

interface AuthState {
  user: User | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  isLoading: false,
  login: async (email, password) => {
    set({ isLoading: true });
    try {
      const response = await loginApi(email, password);
      set({ user: response.user });
    } finally {
      set({ isLoading: false });
    }
  },
  logout: () => set({ user: null }),
}));
  • ui/: 이 기능의 UI 컴포넌트들
// features/auth/ui/LoginForm.tsx
import { useAuthStore } from '../model/authStore';
import { validateEmail } from '../lib/validators';

export function LoginForm() {
  const { login, isLoading } = useAuthStore();

  return (
    <form onSubmit={/* ... */}>
      {/* 폼 내용 */}
    </form>
  );
}
  • api/: API 호출 (외부 노출 안 함)
// features/auth/api/authApi.ts (내부용)
export async function loginApi(email: string, password: string) {
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    body: JSON.stringify({ email, password }),
  });
  return response.json();
}
  • lib/: 유틸리티, 헬퍼 함수 (내부용)
// features/auth/lib/validators.ts (내부용)
export function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
  • index.ts: Public API (이것만 외부에서 임포트 가능)
// features/auth/index.ts
// 외부에서 접근할 수 있는 것들만 export
export { LoginForm } from './ui/LoginForm';
export { SignupForm } from './ui/SignupForm';
export { useAuthStore } from './model/authStore';
export type { User, AuthState } from './model/types';

// 다른 것들은 export하지 않음!
// api/authApi.ts는 내부용
// lib/validators.ts는 내부용

4. entities/ - 엔티티 레이어

도메인 모델과 엔티티를 정의합니다.

entities/
├── user/
│   ├── model/
│   │   ├── types.ts        # User, UserProfile 등
│   │   ├── constants.ts    # USER_ROLES 등
│   │   └── index.ts
│   ├── ui/
│   │   ├── UserCard.tsx    # 순수 프리젠테이션
│   │   └── index.ts
│   └── index.ts
│
├── product/
│   ├── model/
│   │   ├── types.ts
│   │   └── constants.ts
│   ├── ui/
│   │   └── ProductBadge.tsx
│   └── index.ts
│
└── order/
    ├── model/
    │   ├── types.ts
    │   └── index.ts
    └── index.ts
// entities/user/model/types.ts
export type UserRole = 'admin' | 'user' | 'guest';

export interface User {
  id: string;
  email: string;
  name: string;
  role: UserRole;
  avatar?: string;
  createdAt: Date;
}

export interface UserProfile extends User {
  bio?: string;
  location?: string;
  phone?: string;
}

// entities/user/ui/UserCard.tsx - 순수 프리젠테이션
interface UserCardProps {
  user: User;
  onClick?: () => void;
}

export function UserCard({ user, onClick }: UserCardProps) {
  return (
    <div onClick={onClick}>
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

5. widgets/ - 위젯 레이어

여러 features와 entities를 조합한 복합 컴포넌트입니다.

widgets/
├── header/
│   ├── Header.tsx          # 헤더 (여러 feature 조합)
│   └── index.ts
├── sidebar/
│   ├── Sidebar.tsx
│   └── index.ts
├── hero-section/
│   ├── HeroSection.tsx
│   └── index.ts
├── recommended-products/
│   ├── RecommendedProducts.tsx
│   └── index.ts
└── user-menu/
    ├── UserMenu.tsx        # 유저 메뉴 드롭다운
    └── index.ts
// widgets/header/Header.tsx
import { useAuthStore } from 'features/auth';
import { useCartStore } from 'features/shopping-cart';
import { UserMenu } from 'widgets/user-menu';
import { CartButton } from 'features/shopping-cart/ui/CartButton';

export function Header() {
  const user = useAuthStore(state => state.user);
  const cartCount = useCartStore(state => state.items.length);

  return (
    <header>
      <Logo />
      <CartButton count={cartCount} />
      {user && <UserMenu user={user} />}
    </header>
  );
}

6. shared/ - 공유 레이어

모든 계층에서 사용할 수 있는 재사용 가능한 코드입니다.

shared/
├── ui/                     # 공통 UI 컴포넌트
│   ├── Button.tsx
│   ├── Input.tsx
│   ├── Modal.tsx
│   ├── Spinner.tsx
│   └── index.ts
│
├── lib/                    # 유틸리티 함수
│   ├── api.ts             # API 클라이언트
│   ├── hooks.ts           # 공통 훅 (useDebounce 등)
│   ├── utils.ts           # 일반 유틸리티
│   └── index.ts
│
├── types/                  # 공통 타입
│   ├── api.ts             # API 응답 타입
│   ├── common.ts          # 공통 타입
│   └── index.ts
│
├── config/                # 공통 설정
│   ├── constants.ts       # 상수
│   ├── env.ts             # 환경 변수
│   └── index.ts
│
├── assets/                # 정적 자산
│   ├── images/
│   ├── icons/
│   └── fonts/
│
└── styles/               # 공통 스타일
    ├── variables.css
    ├── reset.css
    └── globals.css
// shared/ui/Button.tsx - 공통 컴포넌트
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
}

export function Button({
  variant = 'primary',
  size = 'md',
  loading = false,
  children,
  ...props
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={loading || props.disabled}
      {...props}
    >
      {loading && <Spinner size={size} />}
      {children}
    </button>
  );
}

FSD 규칙과 제약

1. 의존성 방향 규칙

상위 계층이 하위 계층에 의존할 수 있습니다:

app/
  ↑
pages/
  ↑
features/  ⟷  widgets/  ← features와 widgets는 양방향 가능
  ↑
entities/
  ↑
shared/

❌ 금지된 의존성:
- shared ← entities (shared는 최하위)
- entities ← features (위에서 아래로만)
- pages ← app (아래에서 위로)

2. Public API 규칙

각 슬라이스의 index.ts만이 public API입니다.

// ✅ 올바른 임포트
import { LoginForm, useAuthStore } from 'features/auth';
import { UserCard } from 'entities/user';
import { Button } from 'shared/ui';

// ❌ 잘못된 임포트
import { validateEmail } from 'features/auth/lib/validators';  // NO!
import { loginApi } from 'features/auth/api/authApi';         // NO!
import { authStore } from 'features/auth/model/authStore';    // NO!

3. Slice 간 통신

슬라이스 간 통신은 명시적이어야 합니다.

// ✅ 올바른 방법 1: Props를 통한 통신
function Component() {
  const user = useAuthStore(state => state.user);
  return <UserCard user={user} />;
}

// ✅ 올바른 방법 2: 이벤트/콜백
function Component() {
  const handleUserSelected = (user: User) => {
    // 필요한 작업
  };
  return <UserList onUserSelect={handleUserSelected} />;
}

// ❌ 잘못된 방법: 직접 다른 feature의 store 접근
import { useProductStore } from 'features/product-list/model'; // NO!

4. Circular Dependency 방지

// ❌ 문제: 순환 의존성
// features/auth/index.ts
export { LoginForm } from './ui/LoginForm';

// features/auth/ui/LoginForm.tsx
import { useProductStore } from 'features/product-list';  // ← 다른 feature

// features/product-list/index.ts
export { useProductStore } from './model/productStore';

// features/product-list 어딘가
import { LoginForm } from 'features/auth';  // ← 다시 auth 참조!

// ✅ 해결: Shared 사용 또는 구조 재검토
// shared/hooks/useNotification.ts
export function useNotification() {
  // 다른 features와 독립적인 로직
}

프로덕션 레벨 구현

1. 완벽한 프로젝트 구조

// tsconfig.json - 절대 경로 설정
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "app/*": ["src/app/*"],
      "pages/*": ["src/pages/*"],
      "features/*": ["src/features/*"],
      "entities/*": ["src/entities/*"],
      "widgets/*": ["src/widgets/*"],
      "shared/*": ["src/shared/*"]
    }
  }
}

// vite.config.ts
export default {
  resolve: {
    alias: {
      'app': '/src/app',
      'pages': '/src/pages',
      'features': '/src/features',
      'entities': '/src/entities',
      'widgets': '/src/widgets',
      'shared': '/src/shared'
    }
  }
}

2. ESLint 규칙으로 강제

// eslint.config.js - FSD 의존성 강제
import { FSDPlugin } from 'eslint-plugin-fsd';

export default [
  {
    plugins: { fsd: FSDPlugin },
    rules: {
      // 각 레이어는 자신의 인덱스만 export 가능
      'fsd/public-api-integrity': 'error',
      
      // 위에서 아래로만 의존 가능
      'fsd/layers-interaction': 'error',
      
      // 순환 의존성 금지
      'import/no-cycle': 'error',
      
      // 절대 경로 필수
      'import/no-relative-packages': 'error'
    }
  }
];

3. 슬라이스 생성 스크립트

#!/bin/bash
# scripts/create-slice.sh
# 새로운 슬라이스를 자동으로 생성

SLICE_NAME=$1
LAYER=${2:-features}

mkdir -p src/$LAYER/$SLICE_NAME/{model,ui}

# model/index.ts
cat > src/$LAYER/$SLICE_NAME/model/index.ts << 'EOF'
export {};
EOF

# ui/index.ts
cat > src/$LAYER/$SLICE_NAME/ui/index.ts << 'EOF'
export {};
EOF

# slice index.ts
cat > src/$LAYER/$SLICE_NAME/index.ts << 'EOF'
// Public API
EOF

echo "✅ Slice created: src/$LAYER/$SLICE_NAME"

4. 복잡한 기능 구현 예시

// 완전한 쇼핑 기능 예시

// features/shopping-cart/model/cartStore.ts
import { create } from 'zustand';
import { cartApi } from '../api/cartApi';

interface CartItem {
  productId: string;
  quantity: number;
  price: number;
}

interface CartState {
  items: CartItem[];
  isLoading: boolean;
  total: number;
  
  // Actions
  addItem: (productId: string, quantity: number) => Promise<void>;
  removeItem: (productId: string) => Promise<void>;
  updateQuantity: (productId: string, quantity: number) => Promise<void>;
  checkout: () => Promise<void>;
  clearCart: () => void;
}

export const useCartStore = create<CartState>((set, get) => ({
  items: [],
  isLoading: false,
  total: 0,

  addItem: async (productId, quantity) => {
    set({ isLoading: true });
    try {
      const response = await cartApi.addItem(productId, quantity);
      const items = response.items;
      set({
        items,
        total: response.total
      });
    } finally {
      set({ isLoading: false });
    }
  },

  removeItem: async (productId) => {
    set({ isLoading: true });
    try {
      const response = await cartApi.removeItem(productId);
      set({
        items: response.items,
        total: response.total
      });
    } finally {
      set({ isLoading: false });
    }
  },

  updateQuantity: async (productId, quantity) => {
    set({ isLoading: true });
    try {
      const response = await cartApi.updateQuantity(productId, quantity);
      set({
        items: response.items,
        total: response.total
      });
    } finally {
      set({ isLoading: false });
    }
  },

  checkout: async () => {
    set({ isLoading: true });
    try {
      await cartApi.checkout();
      set({ items: [], total: 0 });
    } finally {
      set({ isLoading: false });
    }
  },

  clearCart: () => set({ items: [], total: 0 })
}));

// features/shopping-cart/ui/CartItems.tsx
import { useCartStore } from '../model/cartStore';
import { ProductCard } from 'entities/product/ui/ProductCard';
import { Button } from 'shared/ui/Button';

export function CartItems() {
  const items = useCartStore(state => state.items);
  const removeItem = useCartStore(state => state.removeItem);
  const updateQuantity = useCartStore(state => state.updateQuantity);

  return (
    <div className="cart-items">
      {items.map(item => (
        <CartItem
          key={item.productId}
          item={item}
          onRemove={() => removeItem(item.productId)}
          onQuantityChange={(qty) => updateQuantity(item.productId, qty)}
        />
      ))}
    </div>
  );
}

// features/shopping-cart/ui/CartSummary.tsx
import { useCartStore } from '../model/cartStore';

export function CartSummary() {
  const total = useCartStore(state => state.total);
  const checkout = useCartStore(state => state.checkout);
  const isLoading = useCartStore(state => state.isLoading);

  return (
    <div className="cart-summary">
      <p>Total: ${total.toFixed(2)}</p>
      <Button
        onClick={() => checkout()}
        loading={isLoading}
        variant="primary"
      >
        Checkout
      </Button>
    </div>
  );
}

// features/shopping-cart/index.ts - Public API
export { CartItems } from './ui/CartItems';
export { CartSummary } from './ui/CartSummary';
export { useCartStore } from './model/cartStore';
export type { CartItem, CartState } from './model/cartStore';

팀 협업 가이드

# FSD 아키텍처 협업 규칙

## 개발자 체크리스트

### 기능 추가 시
- [ ] 어떤 레이어에 속하는지 명확히 했는가?
- [ ] Public API (index.ts)를 정의했는가?
- [ ] 다른 슬라이스와 의존성이 없는가?
- [ ] 순환 의존성이 없는가?
- [ ] 사내 ESLint 규칙을 모두 통과했는가?

### 코드 리뷰 체크리스트
- [ ] Public API만 노출되었는가?
- [ ] 의존성 방향이 올바른가?
- [ ] 올바른 레이어에 있는가?
- [ ] 파일 구조가 일관적인가?

## 파일 이동 금지 규칙

다음은 절대 이동하면 안 됩니다:
- features 간 파일 이동
- 상위 레이어로의 파일 이동

이 경우 새로운 슬라이스/엔티티를 생성하세요.

## 문제 해결

**Q: 이 기능이 feature인가 widget인가?**
A: Feature는 기능이고, Widget은 여러 feature의 조합입니다.

**Q: 다른 feature를 사용해야 하는데?**
A: 공통 로직이면 shared로, 공통 컴포넌트면 widget으로 추상화하세요.

**Q: 어디에 이 타입을 정의해야 하나?**
A: 특정 feature 전용이면 feature/model/types.ts에, 여러 곳에서 사용되면 entities에, 기본 타입이면 shared/types에.

FSD의 장점과 한계

장점

명확한 구조: 새로운 개발자도 파일 위치를 쉽게 찾을 수 있음 ✅ 독립성: 기능을 독립적으로 개발하고 테스트할 수 있음 ✅ 확장성: 프로젝트 크기와 관계없이 구조 유지 ✅ 협업 효율: 팀원 간 충돌 감소 ✅ 유지보수: 기능 삭제 시 관련 파일만 제거 ✅ 테스트: 슬라이스 단위 테스트 용이

한계

⚠️ 초기 복잡성: 작은 프로젝트에는 과할 수 있음 ⚠️ 학습곡선: 팀원들이 아키텍처 이해 필요 ⚠️ 도구 지원: IDE에서 완벽한 지원 아직 부족 ⚠️ 변경 비용: 초기 설계 실수 시 수정 어려움

결론

FSD는 중소 규모 이상의 모던 프론트엔드 프로젝트에 매우 적합한 아키텍처 패턴입니다.

특히 다음 경우 강력히 권장됩니다:

  • 팀원이 3명 이상
  • 프로젝트 규모가 중간 이상
  • 장기적으로 유지보수해야 함
  • 기능이 계속 추가될 예정
  • 여러 팀이 협업해야 함

FSD를 올바르게 적용하면:

  • 코드 리뷰 시간 50% 감소
  • 버그 발생률 30% 감소
  • 개발 속도 40% 향상
  • 기술 부채 관리 용이

지금 바로 FSD를 도입하고 프론트엔드 개발을 혁신해보세요!

'프론트엔드' 카테고리의 다른 글

MSW란?  (0) 2026.02.04
Deno란?  (0) 2026.02.03
Axios로 API 통신 구현하기  (0) 2026.01.11
프론트엔드에서의 프록시  (0) 2026.01.04
vs code에서 상태 표시줄 복구 방법  (0) 2025.10.23