장바구니 구현
장바구니 상태와 서버 액션을 구성하고 장바구니 화면으로 연결합니다.
핵심 기능 구현: 장바구니 및 주문
다음 다이어그램은 장바구니 버튼 클릭 이후 서버 액션에서 세션, 재고, 수량, 캐시 재검증이 어떤 순서로 처리되는지 보여줍니다.
장바구니와 주문은 클라이언트 UI가 아니라 서버 검증 흐름이 핵심이다
Server Action은 auth() 소유권을 확인하고, 예상 가능한 실패는 직렬화 가능한 결과로 반환하며, 성공 뒤 캐시를 갱신한다.
- 1click
AddToCartButton이 bookId와 quantity를 서버 액션에 넘긴다.
- 2auth()
세션이 없으면 /login으로 보내고 문자열 userId를 서버에서 꺼낸다.
- 3validate
잘못된 ID·수량·재고 부족은 { success: false, message }로 반환한다.
- 4upsert
CartItem을 만들거나 수량을 증가시킨다.
- 5revalidatePath
cart와 books 경로에 필요한 호출을 명시해 화면을 최신화한다.
| 동작 | 서버에서 막아야 할 것 | 화면에서 보여줄 것 |
|---|---|---|
| 장바구니 추가 | 없는 책, 재고 초과, 미로그인 | CartActionResult의 성공·실패 메시지 |
| 장바구니 조회 | Mongoose 문서와 ObjectId의 Client 직접 전달 | lean() 결과를 문자열 ID·원시 값의 CartItemDto로 변환 |
| 수량 변경 | 0 이하 수량, 다른 사용자 항목 수정 | pending 상태와 실패 결과 복구 |
| 주문 생성 | 빈 장바구니, 가격/재고 불일치 | 예상 거절은 결과, 성공은 redirect |
장바구니와 주문 기능은 사용자 상호작용이 많으므로 클라이언트 컴포넌트와 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>
);
}