17장 : 실전 프로젝트단계별 구현 가이드
장바구니 구현
장바구니 상태와 서버 액션을 구성하고 장바구니 화면으로 연결합니다.
핵심 기능 구현: 장바구니 및 주문
다음 다이어그램은 장바구니 버튼 클릭 이후 서버 액션에서 세션, 재고, 수량, 캐시 재검증이 어떤 순서로 처리되는지 보여줍니다.
장바구니와 주문 기능은 사용자 상호작용이 많으므로 클라이언트 컴포넌트와 Server Actions를 혼합하여 구현합니다.
장바구니 관리
- Server Actions: 장바구니에 항목을 추가/삭제/수량 변경하는 서버 액션 정의. 데이터베이스 업데이트 및 캐시 재검증 수행.
- 클라이언트 컴포넌트:
useTransition등을 사용하여 Server Action의 로딩 상태를 처리하고, 장바구니 UI를 업데이트.
'use server';
import connectToDatabase from '@/lib/db';
import CartItem from '@/models/CartItem';
import Book from '@/models/Book';
import mongoose from 'mongoose';
import { revalidatePath } from 'next/cache';
import { getSession } from '@/lib/auth'; // 사용자 세션 가져오는 함수
import { redirect } from 'next/navigation';
type CartActionResult =
| { success: true; message: string }
| { success: false; message: string };
async function getRequiredUserId() {
const session = await getSession();
if (!session?.user?.id) {
redirect('/login?next=/cart');
}
return session.user.id;
}
export async function addToCart(bookId: string, quantity: number = 1): Promise<CartActionResult> {
const userId = await getRequiredUserId();
if (!mongoose.isValidObjectId(bookId)) {
return { success: false, message: '올바르지 않은 도서입니다.' };
}
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
return { success: false, message: '수량은 1부터 99 사이의 정수여야 합니다.' };
}
await connectToDatabase();
const book = await Book.findById(bookId);
if (!book || book.stock < quantity) {
return { success: false, message: '재고가 부족하거나 책을 찾을 수 없습니다.' };
}
let cartItem = await CartItem.findOne({ userId, bookId });
if (cartItem) {
cartItem.quantity += quantity;
if (cartItem.quantity > book.stock) {
return { success: false, message: '장바구니에 담을 수 있는 최대 수량을 초과했습니다.' };
}
await cartItem.save();
} else {
await CartItem.create({ userId, bookId, quantity });
}
// 장바구니 페이지의 데이터를 최신 상태로 재검증
revalidatePath('/cart');
revalidatePath('/books/[id]', 'page'); // 도서 상세 페이지 재고 정보 갱신
return { success: true, message: '장바구니에 추가되었습니다.' };
}
export async function updateCartItemQuantity(itemId: string, newQuantity: number): Promise<CartActionResult> {
const userId = await getRequiredUserId();
if (!mongoose.isValidObjectId(itemId)) {
return { success: false, message: '올바르지 않은 장바구니 항목입니다.' };
}
if (!Number.isInteger(newQuantity) || newQuantity < 1 || newQuantity > 99) {
return { success: false, message: '수량은 1부터 99 사이의 정수여야 합니다.' };
}
await connectToDatabase();
const cartItem = await CartItem.findOne({ _id: itemId, userId });
if (!cartItem) {
return { success: false, message: '장바구니 항목을 찾을 수 없습니다.' };
}
const book = await Book.findById(cartItem.bookId);
if (!book || book.stock < newQuantity) {
return { success: false, message: '재고가 부족하거나 책을 찾을 수 없습니다.' };
}
cartItem.quantity = newQuantity;
await cartItem.save();
revalidatePath('/cart');
return { success: true, message: '수량을 변경했습니다.' };
}
export async function removeCartItem(itemId: string): Promise<CartActionResult> {
const userId = await getRequiredUserId();
if (!mongoose.isValidObjectId(itemId)) {
return { success: false, message: '올바르지 않은 장바구니 항목입니다.' };
}
await connectToDatabase();
const result = await CartItem.deleteOne({ _id: itemId, userId });
if (result.deletedCount === 0) {
return { success: false, message: '삭제할 장바구니 항목을 찾을 수 없습니다.' };
}
revalidatePath('/cart');
return { success: true, message: '장바구니에서 삭제했습니다.' };
}"use client";
import { useState, useTransition } from 'react';
import Button from './ui/Button';
import { addToCart } from '@/actions/cart'; // Server Action 임포트
interface AddToCartButtonProps {
bookId: string;
}
export default function AddToCartButton({ bookId }: AddToCartButtonProps) {
const [isPending, startTransition] = useTransition();
const [feedback, setFeedback] = useState<{
tone: 'success' | 'error';
message: string;
} | null>(null);
const handleAddToCart = () => {
setFeedback(null);
startTransition(async () => {
try {
const result = await addToCart(bookId, 1);
setFeedback({
tone: result.success ? 'success' : 'error',
message: result.message,
});
} catch {
setFeedback({ tone: 'error', message: '잠시 후 다시 시도해 주세요.' });
}
});
};
return (
<div>
<Button onClick={handleAddToCart} disabled={isPending}>
{isPending ? '추가 중...' : '장바구니에 추가'}
</Button>
{feedback && (
<p
aria-live="polite"
className={`mt-2 text-sm ${feedback.tone === 'success' ? 'text-green-700' : 'text-red-700'}`}
>
{feedback.message}
</p>
)}
</div>
);
}장바구니 페이지
장바구니 항목을 표시하고, 수량 변경 및 삭제 기능을 제공합니다.
서버의 Mongoose 문서를 Client Component에 그대로 넘기지 않고 문자열 ID와 원시 값만 가진 DTO로 변환합니다.
export interface CartItemDto {
id: string;
quantity: number;
book: {
id: string;
title: string;
author: string;
price: number;
imageUrl: string;
stock: number;
};
}import connectToDatabase from '@/lib/db';
import CartItemModel from '@/models/CartItem';
import type { IBook } from '@/models/Book';
import type { CartItemDto } from '@/types/cart';
import CartItemCard from '@/components/CartItemCard';
import Link from 'next/link';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function CartPage() {
const session = await getSession();
if (!session?.user?.id) {
redirect('/login?next=/cart');
}
const userId = session.user.id;
await connectToDatabase();
const cartDocuments = await CartItemModel.find({ userId })
.populate<{ bookId: IBook }>('bookId')
.lean();
const cartItems: CartItemDto[] = cartDocuments.map((item) => ({
id: String(item._id),
quantity: item.quantity,
book: {
id: String(item.bookId._id),
title: item.bookId.title,
author: item.bookId.author,
price: item.bookId.price,
imageUrl: item.bookId.imageUrl,
stock: item.bookId.stock,
},
}));
const total = cartItems.reduce((sum, item) => sum + item.book.price * item.quantity, 0);
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8 text-center">장바구니</h1>
{cartItems.length === 0 ? (
<div className="text-center p-8 border rounded-lg bg-white shadow-sm">
<p className="text-lg text-gray-600 mb-4">장바구니가 비어있습니다.</p>
<Link
href="/books"
className="inline-flex rounded-md bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
도서 보러가기
</Link>
</div>
) : (
<div className="bg-white p-6 rounded-lg shadow-lg">
<div className="space-y-6">
{cartItems.map((item) => (
<CartItemCard key={item.id} item={item} />
))}
</div>
<div className="mt-8 pt-6 border-t-2 border-gray-200 flex justify-end items-center">
<span className="text-2xl font-bold text-gray-800 mr-4">총액: ₩{total.toLocaleString()}</span>
<Link
href="/order"
className="inline-flex rounded-md bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
주문하기
</Link>
</div>
</div>
)}
</main>
);
}"use client";
import { type ChangeEvent, useState, useTransition } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { updateCartItemQuantity, removeCartItem } from '@/actions/cart'; // Server Action 임포트
import Button from './ui/Button';
import type { CartItemDto } from '@/types/cart';
interface CartItemCardProps {
item: CartItemDto;
}
export default function CartItemCard({ item }: CartItemCardProps) {
const [quantity, setQuantity] = useState(item.quantity);
const [isPending, startTransition] = useTransition();
const [feedback, setFeedback] = useState<string | null>(null);
const book = item.book;
const handleQuantityChange = (e: ChangeEvent<HTMLSelectElement>) => {
const newQuantity = Number.parseInt(e.target.value, 10);
const previousQuantity = quantity;
setQuantity(newQuantity);
setFeedback(null);
startTransition(async () => {
try {
const result = await updateCartItemQuantity(item.id, newQuantity);
if (!result.success) {
setQuantity(previousQuantity);
setFeedback(result.message);
}
} catch {
setQuantity(previousQuantity);
setFeedback('수량을 바꾸지 못했습니다. 잠시 후 다시 시도해 주세요.');
}
});
};
const handleRemoveItem = () => {
setFeedback(null);
startTransition(async () => {
try {
const result = await removeCartItem(item.id);
if (!result.success) setFeedback(result.message);
} catch {
setFeedback('항목을 삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.');
}
});
};
return (
<div className="flex items-center space-x-4 p-4 border rounded-md bg-gray-50">
<Link href={`/books/${book.id}`}>
<Image
src={book.imageUrl}
alt={book.title}
width={80}
height={100}
className="rounded-md"
/>
</Link>
<div className="grow">
<Link href={`/books/${book.id}`}>
<h3 className="text-lg font-semibold text-gray-800 hover:text-blue-600 transition-colors">
{book.title}
</h3>
</Link>
<p className="text-sm text-gray-600">{book.author}</p>
<p className="text-md font-bold text-blue-600">₩{book.price.toLocaleString()}</p>
</div>
<div className="flex items-center space-x-4">
<label htmlFor={`quantity-${item.id}`} className="sr-only">수량</label>
<select
id={`quantity-${item.id}`}
value={quantity}
onChange={handleQuantityChange}
disabled={isPending}
className="p-2 border rounded-md"
>
{Array.from({ length: book.stock > 10 ? 10 : book.stock }, (_, i) => i + 1).map((q) => (
<option key={q} value={q}>{q}</option>
))}
</select>
<Button onClick={handleRemoveItem} disabled={isPending} variant="danger">
삭제
</Button>
</div>
{feedback && <p aria-live="polite" className="text-sm text-red-700">{feedback}</p>}
</div>
);
}