Next.js 이론 13강

FSD 실전 적용 - Entities와 Features

FSD의 핵심인 Entities와 Features 레이어를 실제 이커머스 코드로 구현합니다.

1. Entities 레이어 이해

Entities는 비즈니스 도메인의 핵심 개념을 표현합니다. 이커머스에서는 Product, User, Cart, Order 등이 Entity입니다. Entity는 비즈니스 로직 없이 데이터 모델과 기본 표현만 담당합니다.

Entity의 구성 요소

📝 model/types.ts

Entity의 타입 정의 (Product, User 인터페이스)

🎨 ui/

Entity의 기본 UI 표현 (ProductCard, UserAvatar)

🌐 api/

Entity 데이터 조회 API (getProduct, getUser)

🔧 lib/

Entity 관련 유틸리티 (formatProductPrice, isProductAvailable)

Entity 폴더 구조

entities/
├── product/                    # 상품 Entity
│   ├── model/
│   │   ├── types.ts            # Product 타입 정의
│   │   └── api.ts              # 상품 조회 API
│   ├── ui/
│   │   ├── ProductCard.tsx     # 상품 카드 컴포넌트
│   │   ├── ProductImage.tsx    # 상품 이미지
│   │   └── ProductPrice.tsx    # 가격 표시
│   ├── lib/
│   │   └── helpers.ts          # 상품 관련 헬퍼
│   └── index.ts                # Public API
│
├── user/                       # 사용자 Entity
│   ├── model/
│   │   └── types.ts
│   ├── ui/
│   │   ├── UserAvatar.tsx
│   │   └── UserName.tsx
│   └── index.ts
│
├── order/                      # 주문 Entity
│   ├── model/
│   │   └── types.ts
│   ├── ui/
│   │   ├── OrderCard.tsx
│   │   └── OrderStatus.tsx
│   └── index.ts
│
└── category/                   # 카테고리 Entity
    ├── model/
    │   └── types.ts
    ├── ui/
    │   └── CategoryBadge.tsx
    └── index.ts

Entity vs Feature 구분

구분EntityFeature
목적데이터 표현사용자 액션
예시ProductCard (상품 표시)AddToCart (장바구니 담기)
상태읽기 전용상태 변경 가능
비즈니스 로직없음있음

Entity 설계 원칙

  • 순수 데이터: 비즈니스 로직 없이 데이터만 표현
  • 재사용성: 여러 Feature에서 공통으로 사용
  • 독립성: 다른 Entity에 의존하지 않음
  • 단순함: 복잡한 상태 관리 없음

2. Product Entity 구현

이커머스의 핵심 Entity인 Product를 FSD 구조로 구현합니다. 타입 정의, API, UI 컴포넌트를 체계적으로 구성합니다.

타입 정의

// entities/product/model/types.ts
export interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
  originalPrice?: number;  // 할인 전 가격
  images: string[];
  category: {
    id: string;
    name: string;
  };
  stock: number;
  rating: number;
  reviewCount: number;
  createdAt: string;
  updatedAt: string;
}

export interface ProductListItem {
  id: string;
  name: string;
  price: number;
  originalPrice?: number;
  thumbnail: string;
  rating: number;
  reviewCount: number;
}

// 상품 상태
export type ProductStatus = 'available' | 'out_of_stock' | 'discontinued';

// 정렬 옵션
export type ProductSortOption = 
  | 'newest' 
  | 'price_asc' 
  | 'price_desc' 
  | 'rating' 
  | 'popularity';

API 함수

// entities/product/model/api.ts
import { api } from '@/shared/api';
import { Product, ProductListItem, ProductSortOption } from './types';

interface GetProductsParams {
  category?: string;
  sort?: ProductSortOption;
  page?: number;
  limit?: number;
}

interface GetProductsResponse {
  products: ProductListItem[];
  total: number;
  page: number;
  totalPages: number;
}

export async function getProducts(
  params: GetProductsParams = {}
): Promise<GetProductsResponse> {
  const { category, sort = 'newest', page = 1, limit = 20 } = params;
  
  const searchParams = new URLSearchParams({
    sort,
    page: String(page),
    limit: String(limit),
  });
  
  if (category) {
    searchParams.set('category', category);
  }
  
  return api.get(`/products?${searchParams}`);
}

export async function getProduct(id: string): Promise<Product> {
  return api.get(`/products/${id}`);
}

export async function getRelatedProducts(
  productId: string,
  limit = 4
): Promise<ProductListItem[]> {
  return api.get(`/products/${productId}/related?limit=${limit}`);
}

export async function searchProducts(
  query: string,
  limit = 10
): Promise<ProductListItem[]> {
  return api.get(`/products/search?q=${encodeURIComponent(query)}&limit=${limit}`);
}

UI 컴포넌트

// entities/product/ui/ProductCard.tsx
import Image from 'next/image';
import Link from 'next/link';
import { ProductListItem } from '../model/types';
import { ProductPrice } from './ProductPrice';
import { ProductRating } from './ProductRating';

interface ProductCardProps {
  product: ProductListItem;
}

export function ProductCard({ product }: ProductCardProps) {
  return (
    <Link 
      href={`/products/${product.id}`}
      className="group block"
    >
      <div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
        <Image
          src={product.thumbnail}
          alt={product.name}
          fill
          sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
          className="object-cover transition-transform group-hover:scale-105"
        />
      </div>
      
      <div className="mt-3 space-y-1">
        <h3 className="text-sm font-medium line-clamp-2 group-hover:text-blue-600">
          {product.name}
        </h3>
        
        <ProductPrice 
          price={product.price} 
          originalPrice={product.originalPrice} 
        />
        
        <ProductRating 
          rating={product.rating} 
          reviewCount={product.reviewCount} 
        />
      </div>
    </Link>
  );
}

// entities/product/ui/ProductPrice.tsx
import { formatPrice } from '@/shared/lib';

interface ProductPriceProps {
  price: number;
  originalPrice?: number;
  size?: 'sm' | 'md' | 'lg';
}

export function ProductPrice({ price, originalPrice, size = 'md' }: ProductPriceProps) {
  const hasDiscount = originalPrice && originalPrice > price;
  const discountRate = hasDiscount 
    ? Math.round((1 - price / originalPrice) * 100) 
    : 0;
  
  const sizeClasses = {
    sm: 'text-sm',
    md: 'text-base',
    lg: 'text-xl',
  };
  
  return (
    <div className="flex items-center gap-2">
      {hasDiscount && (
        <span className="text-red-500 font-bold">{discountRate}%</span>
      )}
      <span className={`font-bold ${sizeClasses[size]}`}>
        {formatPrice(price)}
      </span>
      {hasDiscount && (
        <span className="text-gray-400 line-through text-sm">
          {formatPrice(originalPrice)}
        </span>
      )}
    </div>
  );
}

// entities/product/ui/ProductRating.tsx
import { Star } from 'lucide-react';

interface ProductRatingProps {
  rating: number;
  reviewCount: number;
}

export function ProductRating({ rating, reviewCount }: ProductRatingProps) {
  return (
    <div className="flex items-center gap-1 text-sm text-gray-500">
      <Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
      <span>{rating.toFixed(1)}</span>
      <span>({reviewCount.toLocaleString()})</span>
    </div>
  );
}

Public API (index.ts)

// entities/product/index.ts
// 타입
export type { 
  Product, 
  ProductListItem, 
  ProductStatus,
  ProductSortOption 
} from './model/types';

// API
export { 
  getProducts, 
  getProduct, 
  getRelatedProducts,
  searchProducts 
} from './model/api';

// UI 컴포넌트
export { ProductCard } from './ui/ProductCard';
export { ProductPrice } from './ui/ProductPrice';
export { ProductRating } from './ui/ProductRating';
export { ProductImage } from './ui/ProductImage';

Product Entity 특징

  • 읽기 전용: 상품 정보 표시만, 수정 로직 없음
  • 재사용: 목록, 상세, 검색 등 여러 곳에서 사용
  • 독립적: 장바구니, 주문 등 다른 기능에 의존 안 함

3. User Entity 구현

User Entity는 사용자 정보를 표현합니다. 인증 로직(로그인, 로그아웃)은 Feature에서 처리하고, Entity는 사용자 데이터 표현만 담당합니다.

타입 정의

// entities/user/model/types.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  phone?: string;
  createdAt: string;
}

export interface UserProfile extends User {
  addresses: Address[];
  defaultAddressId?: string;
}

export interface Address {
  id: string;
  name: string;           // 배송지명 (집, 회사 등)
  recipient: string;      // 수령인
  phone: string;
  zipCode: string;
  address: string;        // 기본 주소
  addressDetail: string;  // 상세 주소
  isDefault: boolean;
}

// 사용자 요약 정보 (리뷰, 댓글 등에서 사용)
export interface UserSummary {
  id: string;
  name: string;
  avatar?: string;
}

API 함수

// entities/user/model/api.ts
import { api } from '@/shared/api';
import { User, UserProfile, Address } from './types';

export async function getCurrentUser(): Promise<User | null> {
  try {
    return await api.get('/users/me');
  } catch {
    return null;
  }
}

export async function getUserProfile(): Promise<UserProfile> {
  return api.get('/users/me/profile');
}

export async function getAddresses(): Promise<Address[]> {
  return api.get('/users/me/addresses');
}

export async function getUser(id: string): Promise<User> {
  return api.get(`/users/${id}`);
}

UI 컴포넌트

// entities/user/ui/UserAvatar.tsx
import Image from 'next/image';

interface UserAvatarProps {
  src?: string;
  name: string;
  size?: 'sm' | 'md' | 'lg';
}

export function UserAvatar({ src, name, size = 'md' }: UserAvatarProps) {
  const sizeClasses = {
    sm: 'h-8 w-8 text-xs',
    md: 'h-10 w-10 text-sm',
    lg: 'h-16 w-16 text-lg',
  };
  
  const initial = name.charAt(0).toUpperCase();
  
  if (src) {
    return (
      <div className={`relative rounded-full overflow-hidden ${sizeClasses[size]}`}>
        <Image
          src={src}
          alt={name}
          fill
          className="object-cover"
        />
      </div>
    );
  }
  
  return (
    <div className={`
      flex items-center justify-center rounded-full 
      bg-blue-100 text-blue-600 font-medium
      ${sizeClasses[size]}
    `}>
      {initial}
    </div>
  );
}

// entities/user/ui/UserName.tsx
interface UserNameProps {
  name: string;
  email?: string;
}

export function UserName({ name, email }: UserNameProps) {
  return (
    <div>
      <p className="font-medium">{name}</p>
      {email && (
        <p className="text-sm text-muted-foreground">{email}</p>
      )}
    </div>
  );
}

// entities/user/ui/AddressCard.tsx
import { Address } from '../model/types';

interface AddressCardProps {
  address: Address;
  isSelected?: boolean;
}

export function AddressCard({ address, isSelected }: AddressCardProps) {
  return (
    <div className={`
      p-4 border rounded-lg
      ${isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-200'}
    `}>
      <div className="flex items-center gap-2 mb-2">
        <span className="font-medium">{address.name}</span>
        {address.isDefault && (
          <span className="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded">
            기본
          </span>
        )}
      </div>
      <p className="text-sm">{address.recipient}</p>
      <p className="text-sm text-muted-foreground">{address.phone}</p>
      <p className="text-sm mt-1">
        [{address.zipCode}] {address.address} {address.addressDetail}
      </p>
    </div>
  );
}

Public API

// entities/user/index.ts
// 타입
export type { User, UserProfile, UserSummary, Address } from './model/types';

// API
export { getCurrentUser, getUserProfile, getAddresses, getUser } from './model/api';

// UI
export { UserAvatar } from './ui/UserAvatar';
export { UserName } from './ui/UserName';
export { AddressCard } from './ui/AddressCard';

User Entity vs Auth Feature

  • User Entity: 사용자 정보 표시 (아바타, 이름, 주소)
  • Auth Feature: 로그인, 로그아웃, 회원가입 로직
  • • Entity는 "누구인가", Feature는 "무엇을 하는가"

4. Features 레이어 이해

Features는 사용자 시나리오와 비즈니스 로직을 담당합니다. "장바구니에 담기", "로그인하기", "결제하기" 등 사용자 액션을 구현합니다.

Feature의 특징

🎯 사용자 액션 중심

"~하기"로 표현되는 기능 (장바구니 담기, 로그인하기)

🧠 비즈니스 로직 포함

상태 변경, 유효성 검사, API 호출 등

📦 독립적 기능 단위

다른 Feature에 의존하지 않음 (Cross-Import 금지)

Feature 폴더 구조

features/
├── auth/                       # 인증 관련 기능
│   ├── login/                  # 로그인
│   │   ├── ui/
│   │   │   └── LoginForm.tsx
│   │   ├── model/
│   │   │   ├── store.ts
│   │   │   └── types.ts
│   │   ├── api/
│   │   │   └── login.ts
│   │   └── index.ts
│   ├── logout/                 # 로그아웃
│   └── register/               # 회원가입
│
├── cart/                       # 장바구니 관련 기능
│   ├── add-to-cart/            # 장바구니 담기
│   ├── remove-from-cart/       # 장바구니 삭제
│   ├── update-quantity/        # 수량 변경
│   └── cart-summary/           # 장바구니 요약
│
├── checkout/                   # 결제 관련 기능
│   ├── shipping/               # 배송 정보
│   └── payment/                # 결제 처리
│
├── search/                     # 검색 기능
│   └── product-search/
│
└── wishlist/                   # 위시리스트 기능
    └── toggle-wishlist/

Feature 세그먼트 구성

features/cart/add-to-cart/
├── ui/                         # UI 컴포넌트
│   ├── AddToCartButton.tsx     # 장바구니 담기 버튼
│   └── QuantitySelector.tsx    # 수량 선택기
│
├── model/                      # 비즈니스 로직
│   ├── store.ts                # 상태 관리 (Zustand)
│   ├── types.ts                # 타입 정의
│   └── hooks.ts                # 커스텀 훅
│
├── api/                        # API 호출
│   └── addToCart.ts            # 장바구니 추가 API
│
├── lib/                        # 유틸리티
│   └── validation.ts           # 유효성 검사
│
└── index.ts                    # Public API

이커머스 주요 Features

Feature사용자 액션주요 로직
auth/login로그인하기인증, 토큰 저장
cart/add-to-cart장바구니 담기재고 확인, 수량 관리
checkout/payment결제하기결제 처리, 주문 생성
search/product-search상품 검색검색어 처리, 필터링
wishlist/toggle찜하기/취소위시리스트 관리

Feature 설계 원칙

  • 단일 책임: 하나의 Feature는 하나의 기능만
  • 독립성: 다른 Feature에 의존하지 않음
  • 완결성: UI, 로직, API가 모두 포함
  • 테스트 용이: 독립적으로 테스트 가능

5. Cart Feature 구현

장바구니 기능을 FSD Feature로 구현합니다. 상태 관리, UI 컴포넌트, API 호출을 체계적으로 구성합니다.

타입 정의

// features/cart/add-to-cart/model/types.ts
export interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
  maxQuantity: number;  // 재고 수량
}

export interface CartState {
  items: CartItem[];
  isLoading: boolean;
  error: string | null;
}

export interface AddToCartPayload {
  productId: string;
  name: string;
  price: number;
  image: string;
  quantity?: number;
  maxQuantity: number;
}

상태 관리 (Zustand)

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

interface CartStore {
  items: CartItem[];
  isLoading: boolean;
  
  // Actions
  addItem: (payload: AddToCartPayload) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
  
  // Computed
  getTotalItems: () => number;
  getTotalPrice: () => number;
  getItemQuantity: (productId: string) => number;
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      isLoading: false,
      
      addItem: (payload) => {
        set((state) => {
          const existingItem = state.items.find(
            item => item.productId === payload.productId
          );
          
          if (existingItem) {
            // 이미 있으면 수량 증가
            const newQuantity = Math.min(
              existingItem.quantity + (payload.quantity || 1),
              payload.maxQuantity
            );
            
            return {
              items: state.items.map(item =>
                item.productId === payload.productId
                  ? { ...item, quantity: newQuantity }
                  : item
              ),
            };
          }
          
          // 새 상품 추가
          return {
            items: [...state.items, {
              productId: payload.productId,
              name: payload.name,
              price: payload.price,
              image: payload.image,
              quantity: payload.quantity || 1,
              maxQuantity: payload.maxQuantity,
            }],
          };
        });
      },
      
      removeItem: (productId) => {
        set((state) => ({
          items: state.items.filter(item => item.productId !== productId),
        }));
      },
      
      updateQuantity: (productId, quantity) => {
        set((state) => ({
          items: state.items.map(item =>
            item.productId === productId
              ? { ...item, quantity: Math.min(quantity, item.maxQuantity) }
              : item
          ),
        }));
      },
      
      clearCart: () => set({ items: [] }),
      
      getTotalItems: () => {
        return get().items.reduce((sum, item) => sum + item.quantity, 0);
      },
      
      getTotalPrice: () => {
        return get().items.reduce(
          (sum, item) => sum + item.price * item.quantity, 
          0
        );
      },
      
      getItemQuantity: (productId) => {
        const item = get().items.find(i => i.productId === productId);
        return item?.quantity || 0;
      },
    }),
    {
      name: 'cart-storage',
    }
  )
);

UI 컴포넌트

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

import { useState } from 'react';
import { ShoppingCart, Check } from 'lucide-react';
import { Button } from '@/shared/ui';
import { useCartStore } from '../model/store';
import { Product } from '@/entities/product';

interface AddToCartButtonProps {
  product: Product;
  quantity?: number;
}

export function AddToCartButton({ product, quantity = 1 }: AddToCartButtonProps) {
  const [isAdded, setIsAdded] = useState(false);
  const addItem = useCartStore((state) => state.addItem);
  const itemQuantity = useCartStore((state) => state.getItemQuantity(product.id));
  
  const isOutOfStock = product.stock === 0;
  const isMaxReached = itemQuantity >= product.stock;
  
  const handleClick = () => {
    if (isOutOfStock || isMaxReached) return;
    
    addItem({
      productId: product.id,
      name: product.name,
      price: product.price,
      image: product.images[0],
      quantity,
      maxQuantity: product.stock,
    });
    
    setIsAdded(true);
    setTimeout(() => setIsAdded(false), 2000);
  };
  
  if (isOutOfStock) {
    return (
      <Button disabled className="w-full">
        품절
      </Button>
    );
  }
  
  return (
    <Button 
      onClick={handleClick}
      disabled={isMaxReached}
      className="w-full"
    >
      {isAdded ? (
        <>
          <Check className="h-4 w-4 mr-2" />
          담았습니다
        </>
      ) : (
        <>
          <ShoppingCart className="h-4 w-4 mr-2" />
          {isMaxReached ? '최대 수량 도달' : '장바구니 담기'}
        </>
      )}
    </Button>
  );
}

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

import { ShoppingCart } from 'lucide-react';
import Link from 'next/link';
import { useCartStore } from '../model/store';

export function CartBadge() {
  const totalItems = useCartStore((state) => state.getTotalItems());
  
  return (
    <Link href="/cart" className="relative">
      <ShoppingCart className="h-6 w-6" />
      {totalItems > 0 && (
        <span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
          {totalItems > 99 ? '99+' : totalItems}
        </span>
      )}
    </Link>
  );
}

Public API

// features/cart/add-to-cart/index.ts
// 타입
export type { CartItem, AddToCartPayload } from './model/types';

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

// UI
export { AddToCartButton } from './ui/AddToCartButton';
export { CartBadge } from './ui/CartBadge';
export { QuantitySelector } from './ui/QuantitySelector';

Cart Feature 특징

  • 상태 관리: Zustand + persist로 로컬 저장
  • 재고 검증: maxQuantity로 재고 초과 방지
  • UX 피드백: 담기 완료 애니메이션
  • 독립성: Product Entity만 의존

6. Auth Feature 구현

인증 기능을 FSD Feature로 구현합니다. 로그인, 로그아웃, 회원가입을 독립적인 슬라이스로 분리합니다.

타입 정의

// features/auth/login/model/types.ts
export interface LoginCredentials {
  email: string;
  password: string;
}

export interface LoginResponse {
  accessToken: string;
  refreshToken: string;
  user: {
    id: string;
    email: string;
    name: string;
  };
}

export interface AuthState {
  isAuthenticated: boolean;
  user: LoginResponse['user'] | null;
  isLoading: boolean;
  error: string | null;
}

상태 관리

// features/auth/login/model/store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { loginApi, logoutApi, refreshTokenApi } from '../api/auth';
import { LoginCredentials, AuthState } from './types';

interface AuthStore extends AuthState {
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => Promise<void>;
  refreshToken: () => Promise<void>;
  clearError: () => void;
}

export const useAuthStore = create<AuthStore>()(
  persist(
    (set, get) => ({
      isAuthenticated: false,
      user: null,
      isLoading: false,
      error: null,
      
      login: async (credentials) => {
        set({ isLoading: true, error: null });
        
        try {
          const response = await loginApi(credentials);
          
          // 토큰 저장
          localStorage.setItem('accessToken', response.accessToken);
          localStorage.setItem('refreshToken', response.refreshToken);
          
          set({
            isAuthenticated: true,
            user: response.user,
            isLoading: false,
          });
        } catch (error) {
          set({
            isLoading: false,
            error: error instanceof Error ? error.message : '로그인 실패',
          });
          throw error;
        }
      },
      
      logout: async () => {
        try {
          await logoutApi();
        } finally {
          localStorage.removeItem('accessToken');
          localStorage.removeItem('refreshToken');
          set({
            isAuthenticated: false,
            user: null,
          });
        }
      },
      
      refreshToken: async () => {
        const refreshToken = localStorage.getItem('refreshToken');
        if (!refreshToken) {
          get().logout();
          return;
        }
        
        try {
          const response = await refreshTokenApi(refreshToken);
          localStorage.setItem('accessToken', response.accessToken);
        } catch {
          get().logout();
        }
      },
      
      clearError: () => set({ error: null }),
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({
        isAuthenticated: state.isAuthenticated,
        user: state.user,
      }),
    }
  )
);

UI 컴포넌트

// features/auth/login/ui/LoginForm.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Input } from '@/shared/ui';
import { useAuthStore } from '../model/store';

const loginSchema = z.object({
  email: z.string().email('올바른 이메일을 입력하세요'),
  password: z.string().min(8, '비밀번호는 8자 이상이어야 합니다'),
});

type LoginFormData = z.infer<typeof loginSchema>;

export function LoginForm() {
  const router = useRouter();
  const { login, isLoading, error, clearError } = useAuthStore();
  
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
  });
  
  const onSubmit = async (data: LoginFormData) => {
    try {
      await login(data);
      router.push('/');
    } catch {
      // 에러는 store에서 처리
    }
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      {error && (
        <div className="p-3 bg-red-50 text-red-600 rounded-lg text-sm">
          {error}
        </div>
      )}
      
      <div>
        <Input
          type="email"
          placeholder="이메일"
          {...register('email')}
          onChange={() => clearError()}
        />
        {errors.email && (
          <p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
        )}
      </div>
      
      <div>
        <Input
          type="password"
          placeholder="비밀번호"
          {...register('password')}
        />
        {errors.password && (
          <p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
        )}
      </div>
      
      <Button type="submit" className="w-full" disabled={isLoading}>
        {isLoading ? '로그인 중...' : '로그인'}
      </Button>
    </form>
  );
}

// features/auth/logout/ui/LogoutButton.tsx
'use client';

import { useRouter } from 'next/navigation';
import { LogOut } from 'lucide-react';
import { Button } from '@/shared/ui';
import { useAuthStore } from '../../login/model/store';

export function LogoutButton() {
  const router = useRouter();
  const logout = useAuthStore((state) => state.logout);
  
  const handleLogout = async () => {
    await logout();
    router.push('/');
  };
  
  return (
    <Button variant="ghost" onClick={handleLogout}>
      <LogOut className="h-4 w-4 mr-2" />
      로그아웃
    </Button>
  );
}

Public API

// features/auth/login/index.ts
export type { LoginCredentials, AuthState } from './model/types';
export { useAuthStore } from './model/store';
export { LoginForm } from './ui/LoginForm';

// features/auth/logout/index.ts
export { LogoutButton } from './ui/LogoutButton';

// features/auth/register/index.ts
export { RegisterForm } from './ui/RegisterForm';

Auth Feature 특징

  • 토큰 관리: localStorage에 안전하게 저장
  • 자동 갱신: refreshToken으로 세션 유지
  • 에러 처리: 사용자 친화적 에러 메시지
  • 폼 검증: Zod + React Hook Form

7. Widgets로 조합하기

Widgets 레이어에서 Entities와 Features를 조합하여 완성된 UI 블록을 만듭니다. 페이지에서 바로 사용할 수 있는 독립적인 컴포넌트입니다.

Widget의 역할

Entities + Features 조합

ProductCard(Entity) + AddToCart(Feature) = ProductWidget

독립적인 UI 블록

Header, Sidebar, ProductList 등 재사용 가능한 블록

페이지 구성 단위

페이지는 Widgets를 배치하여 구성

Header Widget

// widgets/header/ui/Header.tsx
import Link from 'next/link';
import { CartBadge } from '@/features/cart/add-to-cart';
import { useAuthStore } from '@/features/auth/login';
import { LogoutButton } from '@/features/auth/logout';
import { UserAvatar } from '@/entities/user';
import { ProductSearch } from '@/features/search/product-search';

export function Header() {
  const { isAuthenticated, user } = useAuthStore();
  
  return (
    <header className="border-b">
      <div className="container mx-auto px-4 h-16 flex items-center justify-between">
        {/* 로고 */}
        <Link href="/" className="text-xl font-bold">
          MyShop
        </Link>
        
        {/* 검색 */}
        <div className="flex-1 max-w-xl mx-8">
          <ProductSearch />
        </div>
        
        {/* 우측 메뉴 */}
        <div className="flex items-center gap-4">
          <CartBadge />
          
          {isAuthenticated ? (
            <div className="flex items-center gap-3">
              <Link href="/mypage" className="flex items-center gap-2">
                <UserAvatar src={user?.avatar} name={user?.name || ''} size="sm" />
                <span className="text-sm">{user?.name}</span>
              </Link>
              <LogoutButton />
            </div>
          ) : (
            <Link href="/login" className="text-sm hover:text-blue-600">
              로그인
            </Link>
          )}
        </div>
      </div>
    </header>
  );
}

// widgets/header/index.ts
export { Header } from './ui/Header';

ProductDetails Widget

// widgets/product-details/ui/ProductDetails.tsx
import { Product, ProductPrice, ProductRating } from '@/entities/product';
import { AddToCartButton } from '@/features/cart/add-to-cart';
import { ToggleWishlistButton } from '@/features/wishlist/toggle-wishlist';
import { ProductImageGallery } from './ProductImageGallery';

interface ProductDetailsProps {
  product: Product;
}

export function ProductDetails({ product }: ProductDetailsProps) {
  return (
    <div className="grid md:grid-cols-2 gap-8">
      {/* 이미지 갤러리 */}
      <ProductImageGallery images={product.images} name={product.name} />
      
      {/* 상품 정보 */}
      <div className="space-y-6">
        <div>
          <p className="text-sm text-muted-foreground">{product.category.name}</p>
          <h1 className="text-2xl font-bold mt-1">{product.name}</h1>
        </div>
        
        <ProductRating rating={product.rating} reviewCount={product.reviewCount} />
        
        <ProductPrice 
          price={product.price} 
          originalPrice={product.originalPrice}
          size="lg"
        />
        
        <p className="text-muted-foreground">{product.description}</p>
        
        {/* 재고 상태 */}
        <div className="text-sm">
          {product.stock > 0 ? (
            <span className="text-green-600">재고 {product.stock}개</span>
          ) : (
            <span className="text-red-600">품절</span>
          )}
        </div>
        
        {/* 액션 버튼 */}
        <div className="flex gap-3">
          <div className="flex-1">
            <AddToCartButton product={product} />
          </div>
          <ToggleWishlistButton productId={product.id} />
        </div>
      </div>
    </div>
  );
}

// widgets/product-details/index.ts
export { ProductDetails } from './ui/ProductDetails';

페이지에서 Widget 사용

// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { getProduct } from '@/entities/product';
import { ProductDetails } from '@/widgets/product-details';
import { ProductReviews } from '@/widgets/product-reviews';
import { RelatedProducts } from '@/widgets/related-products';

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

export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);
  
  if (!product) {
    notFound();
  }
  
  return (
    <div className="container mx-auto px-4 py-8">
      {/* Widget: 상품 상세 */}
      <ProductDetails product={product} />
      
      {/* Widget: 상품 리뷰 */}
      <div className="mt-12">
        <Suspense fallback={<div>리뷰 로딩 중...</div>}>
          <ProductReviews productId={product.id} />
        </Suspense>
      </div>
      
      {/* Widget: 관련 상품 */}
      <div className="mt-12">
        <Suspense fallback={<div>관련 상품 로딩 중...</div>}>
          <RelatedProducts categoryId={product.category.id} />
        </Suspense>
      </div>
    </div>
  );
}

FSD 레이어 요약

레이어역할예시
app/페이지 라우팅page.tsx, layout.tsx
widgets/UI 블록 조합Header, ProductDetails
features/사용자 액션AddToCart, Login
entities/데이터 표현Product, User
shared/공통 코드Button, formatPrice

FSD 실전 적용 팁

  • 점진적 도입: 새 기능부터 FSD 적용
  • shared 먼저: 공통 UI/유틸부터 정리
  • entities 정의: 비즈니스 도메인 명확히
  • ESLint 설정: 의존성 규칙 자동 검사