FSD의 핵심인 Entities와 Features 레이어를 실제 이커머스 코드로 구현합니다.
Entities는 비즈니스 도메인의 핵심 개념을 표현합니다. 이커머스에서는 Product, User, Cart, Order 등이 Entity입니다. Entity는 비즈니스 로직 없이 데이터 모델과 기본 표현만 담당합니다.
📝 model/types.ts
Entity의 타입 정의 (Product, User 인터페이스)
🎨 ui/
Entity의 기본 UI 표현 (ProductCard, UserAvatar)
🌐 api/
Entity 데이터 조회 API (getProduct, getUser)
🔧 lib/
Entity 관련 유틸리티 (formatProductPrice, isProductAvailable)
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 | Feature |
|---|---|---|
| 목적 | 데이터 표현 | 사용자 액션 |
| 예시 | ProductCard (상품 표시) | AddToCart (장바구니 담기) |
| 상태 | 읽기 전용 | 상태 변경 가능 |
| 비즈니스 로직 | 없음 | 있음 |
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';// 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}`);
}// 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>
);
}// 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 특징
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;
}// 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}`);
}// 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>
);
}// 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
Features는 사용자 시나리오와 비즈니스 로직을 담당합니다. "장바구니에 담기", "로그인하기", "결제하기" 등 사용자 액션을 구현합니다.
🎯 사용자 액션 중심
"~하기"로 표현되는 기능 (장바구니 담기, 로그인하기)
🧠 비즈니스 로직 포함
상태 변경, 유효성 검사, API 호출 등
📦 독립적 기능 단위
다른 Feature에 의존하지 않음 (Cross-Import 금지)
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/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
| Feature | 사용자 액션 | 주요 로직 |
|---|---|---|
| auth/login | 로그인하기 | 인증, 토큰 저장 |
| cart/add-to-cart | 장바구니 담기 | 재고 확인, 수량 관리 |
| checkout/payment | 결제하기 | 결제 처리, 주문 생성 |
| search/product-search | 상품 검색 | 검색어 처리, 필터링 |
| wishlist/toggle | 찜하기/취소 | 위시리스트 관리 |
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;
}// 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',
}
)
);// 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>
);
}// 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 특징
인증 기능을 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,
}),
}
)
);// 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>
);
}// 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 특징
Widgets 레이어에서 Entities와 Features를 조합하여 완성된 UI 블록을 만듭니다. 페이지에서 바로 사용할 수 있는 독립적인 컴포넌트입니다.
Entities + Features 조합
ProductCard(Entity) + AddToCart(Feature) = ProductWidget
독립적인 UI 블록
Header, Sidebar, ProductList 등 재사용 가능한 블록
페이지 구성 단위
페이지는 Widgets를 배치하여 구성
// 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';// 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';// 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>
);
}| 레이어 | 역할 | 예시 |
|---|---|---|
| app/ | 페이지 라우팅 | page.tsx, layout.tsx |
| widgets/ | UI 블록 조합 | Header, ProductDetails |
| features/ | 사용자 액션 | AddToCart, Login |
| entities/ | 데이터 표현 | Product, User |
| shared/ | 공통 코드 | Button, formatPrice |
FSD 실전 적용 팁