Next.js 이론 12강

Feature-Sliced Design (FSD) 아키텍처

Feature-Sliced Design 아키텍처의 개념, 레이어, 슬라이스 구조를 학습합니다.

1. FSD란 무엇인가

Feature-Sliced Design(FSD)은 프론트엔드 프로젝트를 구조화하는 아키텍처 방법론입니다. 코드를 기능(Feature) 단위로 분리하여 유지보수성과 확장성을 높입니다. 러시아 개발자 커뮤니티에서 시작되어 현재 전 세계적으로 채택되고 있습니다.

기존 구조의 문제점

대부분의 프로젝트는 기술 유형별로 폴더를 구성합니다. components, hooks, utils 등으로 나누는 방식입니다. 프로젝트가 작을 때는 문제없지만, 규모가 커지면 심각한 문제가 발생합니다.

// ❌ 일반적인 폴더 구조 (기술 유형별)
src/
├── components/           # 모든 컴포넌트가 섞여있음
│   ├── Button.tsx
│   ├── ProductCard.tsx
│   ├── CartItem.tsx
│   ├── OrderSummary.tsx
│   ├── UserProfile.tsx
│   ├── Header.tsx
│   └── ... (100개 이상의 컴포넌트)
├── hooks/                # 모든 훅이 섞여있음
│   ├── useAuth.ts
│   ├── useCart.ts
│   ├── useProduct.ts
│   └── useOrder.ts
├── utils/                # 모든 유틸이 섞여있음
├── services/             # 모든 API 호출이 섞여있음
└── pages/

// 이 구조의 문제점:
// 1. ProductCard가 어디서 사용되는지 파악하려면 전체 검색 필요
// 2. 장바구니 기능 수정 시 여러 폴더를 돌아다녀야 함
// 3. 팀원 A가 components/를 수정하면 팀원 B와 충돌
// 4. 어떤 코드가 어떤 기능에 속하는지 불명확

실제 발생하는 문제들

  • • "이 컴포넌트 삭제해도 되나요?" - 영향 범위 파악 불가
  • • "장바구니 관련 코드가 어디있죠?" - 5개 폴더에 분산
  • • "이 유틸 함수 누가 쓰고 있어요?" - 전체 검색 필요
  • • "새 기능 추가할 때 어디에 만들어야 하죠?" - 기준 없음

FSD의 핵심 원칙

📦 원칙 1: 기능 중심 분리 (Feature-First)

기술 유형이 아닌 비즈니스 기능 단위로 코드를 구성합니다.

// 기술 유형별 (❌)          // 기능 중심 (✅)
components/                 features/
├── CartItem.tsx           ├── cart/
├── ProductCard.tsx        │   ├── ui/CartItem.tsx
hooks/                     │   └── model/useCart.ts
├── useCart.ts             └── product/
├── useProduct.ts              └── ui/ProductCard.tsx

⬇️ 원칙 2: 단방향 의존성 (Unidirectional Flow)

상위 레이어만 하위 레이어를 import할 수 있습니다.

// 레이어 계층 (위에서 아래로만 import 가능)
app      → pages, widgets, features, entities, shared
pages    → widgets, features, entities, shared
widgets  → features, entities, shared
features → entities, shared
entities → shared
shared   → (외부 라이브러리만)

🔒 원칙 3: Public API (캡슐화)

각 슬라이스는 index.ts를 통해서만 외부에 노출됩니다.

// features/cart/index.ts (Public API)
export { CartWidget } from './ui/CartWidget';
export { useCart } from './model/useCart';
// 내부 구현은 export하지 않음

FSD vs 다른 아키텍처

특성기술 유형별Atomic DesignFSD
분류 기준기술UI 크기비즈니스 기능
의존성 규칙없음크기 기반레이어 기반
확장성낮음중간높음
학습 곡선낮음중간높음

FSD 도입 효과

코드 위치 명확

장바구니는 features/cart에

영향 범위 파악 용이

단방향 의존성으로 예측 가능

팀 협업 개선

기능별 담당자 지정

테스트 용이

기능 단위 독립 테스트

왜 FSD인가?

프로젝트가 커질수록 "이 코드가 어디에 있어야 하지?"라는 고민이 생깁니다. FSD는 명확한 규칙으로 이 문제를 해결합니다.

2. FSD 레이어 구조

FSD는 7개의 레이어로 구성됩니다. 각 레이어는 명확한 책임을 가지며, 상위 레이어만 하위 레이어를 import할 수 있습니다.

7개 레이어 개요

src/
├── app/        # 1. 앱 초기화, 프로바이더, 전역 스타일
├── pages/      # 2. 라우트별 페이지 컴포넌트 (Next.js에서는 app/ 사용)
├── widgets/    # 3. 독립적인 UI 블록 (Header, Sidebar, ProductList)
├── features/   # 4. 사용자 시나리오, 비즈니스 기능 (AddToCart, Checkout)
├── entities/   # 5. 비즈니스 엔티티 (Product, User, Order)
└── shared/     # 6. 재사용 가능한 유틸, UI 컴포넌트 (Button, formatPrice)

// 의존성 방향: app → pages → widgets → features → entities → shared
// 위에서 아래로만 import 가능!

각 레이어 상세 설명

1. app (애플리케이션)

앱 전체 초기화, 프로바이더 설정, 전역 스타일

// app/providers/index.tsx
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        <AuthProvider>
          {children}
        </AuthProvider>
      </ThemeProvider>
    </QueryClientProvider>
  );
}

2. pages (페이지)

라우트별 페이지, widgets와 features를 조합

// Next.js App Router에서는 app/ 디렉토리가 pages 역할
// app/products/[id]/page.tsx
import { ProductDetails } from '@/widgets/product-details';
import { AddToCart } from '@/features/cart/add-to-cart';
import { ProductReviews } from '@/widgets/product-reviews';

export default function ProductPage({ params }) {
  return (
    <div>
      <ProductDetails productId={params.id} />
      <AddToCart productId={params.id} />
      <ProductReviews productId={params.id} />
    </div>
  );
}

3. widgets (위젯)

독립적인 UI 블록, features와 entities를 조합

// widgets/product-details/ui/ProductDetails.tsx
import { ProductCard } from '@/entities/product';
import { AddToWishlist } from '@/features/wishlist/add-to-wishlist';

export function ProductDetails({ productId }: { productId: string }) {
  return (
    <div className="grid md:grid-cols-2 gap-8">
      <ProductCard productId={productId} />
      <div>
        <ProductInfo productId={productId} />
        <AddToWishlist productId={productId} />
      </div>
    </div>
  );
}

4. features (기능)

사용자 시나리오, 비즈니스 로직 포함

// features/cart/add-to-cart/ui/AddToCartButton.tsx
import { useCartStore } from '../model/store';
import { Product } from '@/entities/product';
import { Button } from '@/shared/ui';

export function AddToCartButton({ product }: { product: Product }) {
  const addItem = useCartStore((state) => state.addItem);
  
  return (
    <Button onClick={() => addItem(product)}>
      장바구니 담기
    </Button>
  );
}

5. entities (엔티티)

비즈니스 엔티티, 데이터 모델과 기본 UI

// entities/product/model/types.ts
export interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  images: string[];
  category: string;
}

// entities/product/ui/ProductCard.tsx
export function ProductCard({ product }: { product: Product }) {
  return (
    <div className="border rounded-lg p-4">
      <img src={product.images[0]} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price.toLocaleString()}원</p>
    </div>
  );
}

6. shared (공유)

재사용 가능한 유틸, UI 컴포넌트, 설정

// shared/ui/button/Button.tsx
export function Button({ children, variant = 'primary', ...props }) {
  return (
    <button className={cn(buttonVariants({ variant }))} {...props}>
      {children}
    </button>
  );
}

// shared/lib/format-price.ts
export function formatPrice(price: number): string {
  return new Intl.NumberFormat('ko-KR').format(price) + '원';
}

// shared/api/base.ts
export const api = {
  get: (url: string) => fetch(url).then(res => res.json()),
  post: (url: string, data: any) => fetch(url, {
    method: 'POST',
    body: JSON.stringify(data)
  }).then(res => res.json())
};

레이어별 책임 요약

레이어책임예시import 가능
app앱 초기화Providers, 전역 스타일모든 레이어
pages페이지 조합ProductPagewidgets 이하
widgetsUI 블록Header, ProductListfeatures 이하
features비즈니스 기능AddToCart, Loginentities 이하
entities비즈니스 엔티티Product, Usershared만
shared공유 코드Button, formatPrice외부 라이브러리

Next.js App Router와 FSD

// Next.js App Router + FSD 구조
project/
├── app/                    # Next.js App Router (pages 역할)
│   ├── layout.tsx          # 루트 레이아웃 (app 레이어)
│   ├── providers.tsx       # 프로바이더 (app 레이어)
│   ├── page.tsx            # 홈페이지
│   └── products/
│       └── [id]/
│           └── page.tsx    # 상품 상세 페이지
├── src/
│   ├── widgets/            # 위젯 레이어
│   ├── features/           # 기능 레이어
│   ├── entities/           # 엔티티 레이어
│   └── shared/             # 공유 레이어
└── ...

// Next.js의 app/ 디렉토리가 FSD의 app + pages 역할을 함
// 나머지 레이어는 src/ 아래에 구성

레이어 선택 기준

  • shared: 비즈니스 로직 없이 어디서든 재사용
  • entities: 비즈니스 데이터 모델과 기본 표현
  • features: 사용자 액션, 비즈니스 로직 포함
  • widgets: 여러 features/entities 조합

3. 슬라이스와 세그먼트

FSD에서 레이어 내부는 슬라이스(Slice)로 나뉘고, 각 슬라이스는 세그먼트(Segment)로 구성됩니다. 이 구조를 이해하면 코드를 어디에 배치해야 할지 명확해집니다.

슬라이스 (Slice)

슬라이스는 레이어 내에서 도메인별로 코드를 분리하는 단위입니다. 예를 들어 features 레이어에는 cart, auth, checkout 등의 슬라이스가 있습니다.

// 레이어 내 슬라이스 구조
features/                   # 레이어
├── cart/                   # 슬라이스: 장바구니 기능
│   ├── add-to-cart/        # 하위 슬라이스
│   ├── remove-from-cart/
│   └── update-quantity/
├── auth/                   # 슬라이스: 인증 기능
│   ├── login/
│   ├── logout/
│   └── register/
├── checkout/               # 슬라이스: 결제 기능
│   ├── payment/
│   └── shipping/
└── wishlist/               # 슬라이스: 위시리스트 기능

entities/                   # 레이어
├── product/                # 슬라이스: 상품 엔티티
├── user/                   # 슬라이스: 사용자 엔티티
├── order/                  # 슬라이스: 주문 엔티티
└── category/               # 슬라이스: 카테고리 엔티티

세그먼트 (Segment)

세그먼트는 슬라이스 내에서 코드의 목적별로 분리하는 단위입니다. 표준 세그먼트로 ui, model, api, lib, config가 있습니다.

// 슬라이스 내 세그먼트 구조
features/cart/add-to-cart/
├── ui/                     # UI 컴포넌트
│   ├── AddToCartButton.tsx
│   └── QuantitySelector.tsx
├── model/                  # 비즈니스 로직, 상태
│   ├── store.ts            # Zustand 스토어
│   ├── types.ts            # 타입 정의
│   └── hooks.ts            # 커스텀 훅
├── api/                    # API 호출
│   └── addToCart.ts
├── lib/                    # 유틸리티 함수
│   └── calculateTotal.ts
└── index.ts                # Public API

표준 세그먼트 상세

📦 ui - UI 컴포넌트

React 컴포넌트, 스타일, 애니메이션

// features/cart/add-to-cart/ui/AddToCartButton.tsx
'use client';

import { useCartStore } from '../model/store';
import { Button } from '@/shared/ui';

interface Props {
  productId: string;
  productName: string;
  price: number;
}

export function AddToCartButton({ productId, productName, price }: Props) {
  const { addItem, isLoading } = useCartStore();
  
  const handleClick = () => {
    addItem({ id: productId, name: productName, price, quantity: 1 });
  };
  
  return (
    <Button 
      onClick={handleClick} 
      disabled={isLoading}
      className="w-full"
    >
      {isLoading ? '추가 중...' : '장바구니 담기'}
    </Button>
  );
}

🧠 model - 비즈니스 로직

상태 관리, 타입, 훅, 비즈니스 규칙

// features/cart/add-to-cart/model/store.ts
import { create } from 'zustand';
import { CartItem } from './types';

interface CartState {
  items: CartItem[];
  isLoading: boolean;
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  getTotalPrice: () => number;
}

export const useCartStore = create<CartState>((set, get) => ({
  items: [],
  isLoading: false,
  
  addItem: (item) => {
    set((state) => {
      const existing = state.items.find(i => i.id === item.id);
      if (existing) {
        return {
          items: state.items.map(i => 
            i.id === item.id 
              ? { ...i, quantity: i.quantity + 1 }
              : i
          )
        };
      }
      return { items: [...state.items, item] };
    });
  },
  
  removeItem: (id) => {
    set((state) => ({
      items: state.items.filter(i => i.id !== id)
    }));
  },
  
  getTotalPrice: () => {
    return get().items.reduce(
      (total, item) => total + item.price * item.quantity, 
      0
    );
  }
}));

🌐 api - API 호출

서버 통신, 데이터 페칭

// features/cart/add-to-cart/api/addToCart.ts
import { api } from '@/shared/api';
import { CartItem } from '../model/types';

export async function addToCartApi(item: CartItem): Promise<void> {
  return api.post('/api/cart/items', item);
}

export async function syncCartApi(items: CartItem[]): Promise<void> {
  return api.put('/api/cart', { items });
}

🔧 lib - 유틸리티

헬퍼 함수, 계산 로직

// features/cart/add-to-cart/lib/calculateTotal.ts
import { CartItem } from '../model/types';

export function calculateSubtotal(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

export function calculateDiscount(subtotal: number, coupon?: string): number {
  if (!coupon) return 0;
  // 쿠폰 로직
  return subtotal * 0.1; // 10% 할인 예시
}

export function calculateTotal(items: CartItem[], coupon?: string): number {
  const subtotal = calculateSubtotal(items);
  const discount = calculateDiscount(subtotal, coupon);
  return subtotal - discount;
}

Public API (index.ts)

각 슬라이스는 index.ts를 통해 외부에 노출할 것만 export합니다. 이를 통해 내부 구현을 캡슐화하고 명확한 인터페이스를 제공합니다.

// features/cart/add-to-cart/index.ts
// Public API - 외부에서 사용할 수 있는 것만 export

// UI 컴포넌트
export { AddToCartButton } from './ui/AddToCartButton';
export { QuantitySelector } from './ui/QuantitySelector';

// 상태 및 훅
export { useCartStore } from './model/store';

// 타입
export type { CartItem, CartState } from './model/types';

// 유틸리티 (필요한 경우만)
export { calculateTotal } from './lib/calculateTotal';

// ❌ 내부 구현은 export하지 않음
// export { cartReducer } from './model/reducer';
// export { CART_ACTIONS } from './model/constants';

올바른 import 방식

// ✅ Public API를 통한 import
import { AddToCartButton, useCartStore } from '@/features/cart/add-to-cart';

// ❌ 내부 파일 직접 import (금지)
import { AddToCartButton } from '@/features/cart/add-to-cart/ui/AddToCartButton';
import { useCartStore } from '@/features/cart/add-to-cart/model/store';

세그먼트 선택 가이드

  • ui: 화면에 보이는 것 → 컴포넌트
  • model: 데이터와 로직 → 상태, 타입, 훅
  • api: 서버 통신 → fetch, axios 호출
  • lib: 순수 함수 → 계산, 변환, 검증

4. Shared 레이어 구성

Shared 레이어는 프로젝트 전체에서 재사용되는 코드를 담습니다. 비즈니스 로직이 없는 순수한 유틸리티, UI 컴포넌트, 설정 등이 위치합니다.

Shared 레이어 구조

shared/
├── ui/                     # 재사용 UI 컴포넌트
│   ├── button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx
│   │   └── index.ts
│   ├── input/
│   ├── modal/
│   ├── card/
│   └── index.ts            # 모든 UI 컴포넌트 export
├── lib/                    # 유틸리티 함수
│   ├── format-price.ts
│   ├── format-date.ts
│   ├── cn.ts               # className 유틸
│   └── index.ts
├── api/                    # API 기본 설정
│   ├── base.ts             # fetch wrapper
│   ├── interceptors.ts
│   └── index.ts
├── config/                 # 설정 값
│   ├── constants.ts
│   ├── routes.ts
│   └── index.ts
├── hooks/                  # 범용 훅
│   ├── use-debounce.ts
│   ├── use-local-storage.ts
│   └── index.ts
└── types/                  # 공통 타입
    ├── api.ts
    └── index.ts

UI 컴포넌트 예시

// shared/ui/button/Button.tsx
import { forwardRef } from 'react';
import { cn } from '@/shared/lib';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
    const baseStyles = 'inline-flex items-center justify-center rounded-md font-medium transition-colors';
    
    const variants = {
      primary: 'bg-blue-600 text-white hover:bg-blue-700',
      secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
      outline: 'border border-gray-300 hover:bg-gray-100',
      ghost: 'hover:bg-gray-100',
    };
    
    const sizes = {
      sm: 'h-8 px-3 text-sm',
      md: 'h-10 px-4',
      lg: 'h-12 px-6 text-lg',
    };
    
    return (
      <button
        ref={ref}
        className={cn(baseStyles, variants[variant], sizes[size], className)}
        disabled={disabled || isLoading}
        {...props}
      >
        {isLoading ? (
          <span className="mr-2 animate-spin">⏳</span>
        ) : null}
        {children}
      </button>
    );
  }
);

Button.displayName = 'Button';

유틸리티 함수

// shared/lib/format-price.ts
export function formatPrice(price: number): string {
  return new Intl.NumberFormat('ko-KR', {
    style: 'currency',
    currency: 'KRW',
  }).format(price);
}

// shared/lib/format-date.ts
export function formatDate(date: Date | string): string {
  const d = typeof date === 'string' ? new Date(date) : date;
  return new Intl.DateTimeFormat('ko-KR', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(d);
}

export function formatRelativeTime(date: Date | string): string {
  const d = typeof date === 'string' ? new Date(date) : date;
  const now = new Date();
  const diff = now.getTime() - d.getTime();
  
  const minutes = Math.floor(diff / 60000);
  const hours = Math.floor(diff / 3600000);
  const days = Math.floor(diff / 86400000);
  
  if (minutes < 1) return '방금 전';
  if (minutes < 60) return `${minutes}분 전`;
  if (hours < 24) return `${hours}시간 전`;
  if (days < 7) return `${days}일 전`;
  return formatDate(d);
}

// shared/lib/cn.ts (className 유틸)
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

API 기본 설정

// shared/api/base.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';

interface RequestConfig extends RequestInit {
  params?: Record<string, string>;
}

async function request<T>(endpoint: string, config: RequestConfig = {}): Promise<T> {
  const { params, ...init } = config;
  
  let url = `${BASE_URL}${endpoint}`;
  if (params) {
    const searchParams = new URLSearchParams(params);
    url += `?${searchParams}`;
  }
  
  const response = await fetch(url, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      ...init.headers,
    },
  });
  
  if (!response.ok) {
    throw new Error(`API Error: ${response.status}`);
  }
  
  return response.json();
}

export const api = {
  get: <T>(endpoint: string, params?: Record<string, string>) => 
    request<T>(endpoint, { method: 'GET', params }),
    
  post: <T>(endpoint: string, data: unknown) =>
    request<T>(endpoint, { method: 'POST', body: JSON.stringify(data) }),
    
  put: <T>(endpoint: string, data: unknown) =>
    request<T>(endpoint, { method: 'PUT', body: JSON.stringify(data) }),
    
  delete: <T>(endpoint: string) =>
    request<T>(endpoint, { method: 'DELETE' }),
};

범용 훅

// shared/hooks/use-debounce.ts
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
  
  return debouncedValue;
}

// shared/hooks/use-local-storage.ts
import { useState, useEffect } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(initialValue);
  
  useEffect(() => {
    try {
      const item = window.localStorage.getItem(key);
      if (item) {
        setStoredValue(JSON.parse(item));
      }
    } catch (error) {
      console.error(error);
    }
  }, [key]);
  
  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue] as const;
}

Shared 레이어 Export

// shared/ui/index.ts
export { Button } from './button';
export { Input } from './input';
export { Modal } from './modal';
export { Card, CardHeader, CardContent, CardFooter } from './card';

// shared/lib/index.ts
export { cn } from './cn';
export { formatPrice } from './format-price';
export { formatDate, formatRelativeTime } from './format-date';

// shared/hooks/index.ts
export { useDebounce } from './use-debounce';
export { useLocalStorage } from './use-local-storage';

// shared/api/index.ts
export { api } from './base';

Shared에 넣을지 판단 기준

  • 비즈니스 로직 없음: 특정 도메인에 종속되지 않음
  • 범용성: 여러 곳에서 재사용 가능
  • 독립성: 다른 프로젝트에도 복사해서 사용 가능

5. 의존성 규칙과 Cross-Import

FSD의 핵심은 단방향 의존성입니다. 이 규칙을 지키면 코드의 영향 범위를 예측할 수 있고, 순환 참조 문제를 방지할 수 있습니다.

의존성 규칙

// 레이어 간 의존성 (위에서 아래로만 가능)
// Mermaid 다이어그램:
// flowchart TB
//     app --> pages --> widgets --> features --> entities --> shared

// ✅ 허용되는 import
features/cart → entities/product    (상위 → 하위)
features/cart → shared/ui           (상위 → 하위)
widgets/header → features/auth      (상위 → 하위)

// ❌ 금지되는 import
entities/product → features/cart    (하위 → 상위)
shared/ui → entities/product        (하위 → 상위)
features/auth → features/cart       (같은 레이어 간)

FSD 레이어 의존성 흐름:

%%{init: {'theme': 'neutral'}}%%
flowchart TB
    A[app] --> B[pages]
    B --> C[widgets]
    C --> D[features]
    D --> E[entities]
    E --> F[shared]

같은 레이어 간 의존성 문제

같은 레이어의 슬라이스 간에는 직접 import가 금지됩니다. 이를 Cross-Import 문제라고 합니다.

Cross-Import 문제 예시

// ❌ 금지: features 간 직접 import
// features/checkout/ui/CheckoutForm.tsx
import { useCartStore } from '@/features/cart';  // 같은 레이어!

// 왜 금지인가?
// 1. 순환 참조 위험: cart → checkout → cart
// 2. 결합도 증가: checkout이 cart에 강하게 의존
// 3. 테스트 어려움: checkout 테스트 시 cart도 필요

Cross-Import 해결 방법

방법 1: 상위 레이어에서 조합

widgets나 pages에서 여러 features를 조합합니다.

// ✅ widgets/checkout-widget/ui/CheckoutWidget.tsx
import { CartSummary } from '@/features/cart';
import { CheckoutForm } from '@/features/checkout';
import { PaymentMethod } from '@/features/payment';

export function CheckoutWidget() {
  return (
    <div className="grid md:grid-cols-2 gap-8">
      <div>
        <CartSummary />  {/* cart feature */}
      </div>
      <div>
        <CheckoutForm /> {/* checkout feature */}
        <PaymentMethod /> {/* payment feature */}
      </div>
    </div>
  );
}

방법 2: 공통 로직을 entities로 이동

여러 features가 공유하는 데이터는 entities에 배치합니다.

// entities/cart/model/types.ts
export interface CartItem {
  productId: string;
  quantity: number;
  price: number;
}

// features/cart와 features/checkout 모두 사용 가능
// features/cart/model/store.ts
import { CartItem } from '@/entities/cart';

// features/checkout/model/useCheckout.ts
import { CartItem } from '@/entities/cart';

방법 3: Props로 데이터 전달

상위 컴포넌트에서 props로 필요한 데이터를 전달합니다.

// features/checkout/ui/CheckoutForm.tsx
interface CheckoutFormProps {
  items: CartItem[];      // props로 받음
  totalPrice: number;     // props로 받음
  onComplete: () => void;
}

export function CheckoutForm({ items, totalPrice, onComplete }: CheckoutFormProps) {
  // cart store를 직접 import하지 않음
  return (
    <form>
      <p>총 {items.length}개 상품</p>
      <p>결제 금액: {totalPrice}원</p>
      {/* ... */}
    </form>
  );
}

// widgets/checkout-widget/ui/CheckoutWidget.tsx
import { useCartStore } from '@/features/cart';
import { CheckoutForm } from '@/features/checkout';

export function CheckoutWidget() {
  const { items, getTotalPrice } = useCartStore();
  
  return (
    <CheckoutForm 
      items={items}
      totalPrice={getTotalPrice()}
      onComplete={() => {/* ... */}}
    />
  );
}

방법 4: 이벤트/콜백 패턴

직접 호출 대신 이벤트나 콜백을 사용합니다.

// features/checkout/ui/CheckoutComplete.tsx
interface Props {
  onCheckoutComplete: () => void;  // 콜백으로 받음
}

export function CheckoutComplete({ onCheckoutComplete }: Props) {
  const handleSubmit = async () => {
    await processPayment();
    onCheckoutComplete();  // 상위에서 cart 비우기 처리
  };
  
  return <button onClick={handleSubmit}>결제 완료</button>;
}

// widgets/checkout-widget/ui/CheckoutWidget.tsx
import { useCartStore } from '@/features/cart';
import { CheckoutComplete } from '@/features/checkout';

export function CheckoutWidget() {
  const clearCart = useCartStore((state) => state.clearCart);
  
  return (
    <CheckoutComplete 
      onCheckoutComplete={() => clearCart()}
    />
  );
}

ESLint로 의존성 검사

// .eslintrc.js
module.exports = {
  rules: {
    'import/no-restricted-paths': [
      'error',
      {
        zones: [
          // entities는 features를 import할 수 없음
          {
            target: './src/entities',
            from: './src/features',
            message: 'entities cannot import from features',
          },
          // shared는 다른 레이어를 import할 수 없음
          {
            target: './src/shared',
            from: './src/entities',
            message: 'shared cannot import from entities',
          },
          {
            target: './src/shared',
            from: './src/features',
            message: 'shared cannot import from features',
          },
        ],
      },
    ],
  },
};

의존성 규칙 요약

  • 위→아래만: 상위 레이어만 하위 레이어 import
  • 같은 레이어 금지: features 간 직접 import 금지
  • 해결책: 상위에서 조합, entities로 이동, props 전달
  • 자동화: ESLint로 규칙 강제

6. 이커머스 FSD 프로젝트 구조

실제 이커머스 프로젝트에 FSD를 적용한 전체 구조를 살펴봅니다. Next.js App Router와 FSD를 조합한 실전 예제입니다.

전체 프로젝트 구조

my-shop/
├── app/                          # Next.js App Router
│   ├── layout.tsx                # 루트 레이아웃
│   ├── providers.tsx             # 전역 프로바이더
│   ├── page.tsx                  # 홈페이지
│   ├── products/
│   │   ├── page.tsx              # 상품 목록
│   │   └── [id]/
│   │       └── page.tsx          # 상품 상세
│   ├── cart/
│   │   └── page.tsx              # 장바구니
│   ├── checkout/
│   │   └── page.tsx              # 결제
│   └── orders/
│       └── page.tsx              # 주문 내역
│
├── src/
│   ├── widgets/                  # 독립적 UI 블록
│   │   ├── header/
│   │   │   ├── ui/Header.tsx
│   │   │   └── index.ts
│   │   ├── footer/
│   │   ├── product-list/
│   │   ├── product-details/
│   │   ├── cart-sidebar/
│   │   └── checkout-form/
│   │
│   ├── features/                 # 비즈니스 기능
│   │   ├── auth/
│   │   │   ├── login/
│   │   │   ├── logout/
│   │   │   └── register/
│   │   ├── cart/
│   │   │   ├── add-to-cart/
│   │   │   ├── remove-from-cart/
│   │   │   └── update-quantity/
│   │   ├── checkout/
│   │   │   ├── shipping/
│   │   │   └── payment/
│   │   ├── search/
│   │   │   └── product-search/
│   │   └── wishlist/
│   │       └── toggle-wishlist/
│   │
│   ├── entities/                 # 비즈니스 엔티티
│   │   ├── product/
│   │   │   ├── ui/
│   │   │   │   ├── ProductCard.tsx
│   │   │   │   └── ProductImage.tsx
│   │   │   ├── model/
│   │   │   │   ├── types.ts
│   │   │   │   └── api.ts
│   │   │   └── index.ts
│   │   ├── user/
│   │   ├── order/
│   │   ├── category/
│   │   └── review/
│   │
│   └── shared/                   # 공유 코드
│       ├── ui/
│       │   ├── button/
│       │   ├── input/
│       │   ├── modal/
│       │   └── skeleton/
│       ├── lib/
│       │   ├── cn.ts
│       │   ├── format-price.ts
│       │   └── format-date.ts
│       ├── api/
│       │   └── base.ts
│       ├── hooks/
│       │   ├── use-debounce.ts
│       │   └── use-media-query.ts
│       └── config/
│           └── constants.ts
│
├── public/
├── package.json
└── tsconfig.json

페이지 구성 예시

// app/products/[id]/page.tsx
import { ProductDetails } from '@/widgets/product-details';
import { ProductReviews } from '@/widgets/product-reviews';
import { RelatedProducts } from '@/widgets/related-products';
import { AddToCart } from '@/features/cart/add-to-cart';
import { ToggleWishlist } from '@/features/wishlist/toggle-wishlist';
import { getProduct } from '@/entities/product';

interface Props {
  params: { id: string };
}

export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);
  
  return (
    <div className="container mx-auto px-4 py-8">
      {/* Widget: 상품 상세 정보 */}
      <ProductDetails product={product} />
      
      {/* Features: 사용자 액션 */}
      <div className="flex gap-4 mt-6">
        <AddToCart product={product} />
        <ToggleWishlist productId={product.id} />
      </div>
      
      {/* Widget: 상품 리뷰 */}
      <ProductReviews productId={product.id} />
      
      {/* Widget: 관련 상품 */}
      <RelatedProducts categoryId={product.categoryId} />
    </div>
  );
}

// app/cart/page.tsx
import { CartList } from '@/widgets/cart-list';
import { CartSummary } from '@/widgets/cart-summary';
import { ContinueShopping } from '@/features/cart/continue-shopping';

export default function CartPage() {
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold mb-6">장바구니</h1>
      
      <div className="grid md:grid-cols-3 gap-8">
        <div className="md:col-span-2">
          <CartList />
        </div>
        <div>
          <CartSummary />
          <ContinueShopping />
        </div>
      </div>
    </div>
  );
}

tsconfig paths 설정

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@/widgets/*": ["./src/widgets/*"],
      "@/features/*": ["./src/features/*"],
      "@/entities/*": ["./src/entities/*"],
      "@/shared/*": ["./src/shared/*"]
    }
  }
}

레이어별 역할 정리

app/ (Next.js)

라우팅, 레이아웃, 페이지 조합. widgets와 features를 import하여 페이지 구성

widgets/

Header, ProductList, CartSummary 등 독립적인 UI 블록

features/

AddToCart, Login, Search 등 사용자 시나리오와 비즈니스 로직

entities/

Product, User, Order 등 비즈니스 데이터 모델과 기본 UI

shared/

Button, formatPrice 등 비즈니스 로직 없는 재사용 코드

FSD 도입 팁

  • 점진적 도입: 새 기능부터 FSD 적용, 기존 코드는 천천히 마이그레이션
  • shared 먼저: 공통 UI와 유틸부터 정리
  • entities 정의: 비즈니스 도메인 모델 명확히
  • ESLint 설정: 의존성 규칙 자동 검사