Feature-Sliced Design 아키텍처의 개념, 레이어, 슬라이스 구조를 학습합니다.
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. 어떤 코드가 어떤 기능에 속하는지 불명확
실제 발생하는 문제들
📦 원칙 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하지 않음| 특성 | 기술 유형별 | Atomic Design | FSD |
|---|---|---|---|
| 분류 기준 | 기술 | UI 크기 | 비즈니스 기능 |
| 의존성 규칙 | 없음 | 크기 기반 | 레이어 기반 |
| 확장성 | 낮음 | 중간 | 높음 |
| 학습 곡선 | 낮음 | 중간 | 높음 |
코드 위치 명확
장바구니는 features/cart에
영향 범위 파악 용이
단방향 의존성으로 예측 가능
팀 협업 개선
기능별 담당자 지정
테스트 용이
기능 단위 독립 테스트
왜 FSD인가?
프로젝트가 커질수록 "이 코드가 어디에 있어야 하지?"라는 고민이 생깁니다. FSD는 명확한 규칙으로 이 문제를 해결합니다.
FSD는 7개의 레이어로 구성됩니다. 각 레이어는 명확한 책임을 가지며, 상위 레이어만 하위 레이어를 import할 수 있습니다.
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 가능!
앱 전체 초기화, 프로바이더 설정, 전역 스타일
// app/providers/index.tsx
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}라우트별 페이지, 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>
);
}독립적인 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>
);
}사용자 시나리오, 비즈니스 로직 포함
// 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>
);
}비즈니스 엔티티, 데이터 모델과 기본 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>
);
}재사용 가능한 유틸, 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 | 페이지 조합 | ProductPage | widgets 이하 |
| widgets | UI 블록 | Header, ProductList | features 이하 |
| features | 비즈니스 기능 | AddToCart, Login | entities 이하 |
| entities | 비즈니스 엔티티 | Product, User | shared만 |
| shared | 공유 코드 | Button, formatPrice | 외부 라이브러리 |
// 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/ 아래에 구성
레이어 선택 기준
FSD에서 레이어 내부는 슬라이스(Slice)로 나뉘고, 각 슬라이스는 세그먼트(Segment)로 구성됩니다. 이 구조를 이해하면 코드를 어디에 배치해야 할지 명확해집니다.
슬라이스는 레이어 내에서 도메인별로 코드를 분리하는 단위입니다. 예를 들어 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/ # 슬라이스: 카테고리 엔티티
세그먼트는 슬라이스 내에서 코드의 목적별로 분리하는 단위입니다. 표준 세그먼트로 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;
}각 슬라이스는 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';세그먼트 선택 가이드
Shared 레이어는 프로젝트 전체에서 재사용되는 코드를 담습니다. 비즈니스 로직이 없는 순수한 유틸리티, UI 컴포넌트, 설정 등이 위치합니다.
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// 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));
}// 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/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에 넣을지 판단 기준
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도 필요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>
);
}여러 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';상위 컴포넌트에서 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={() => {/* ... */}}
/>
);
}직접 호출 대신 이벤트나 콜백을 사용합니다.
// 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()}
/>
);
}// .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',
},
],
},
],
},
};의존성 규칙 요약
실제 이커머스 프로젝트에 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.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 도입 팁