장바구니 구현
장바구니 상태와 서버 액션을 구성하고 장바구니 화면으로 연결합니다.
핵심 기능 구현: 장바구니 및 주문
장바구니와 주문 기능은 사용자 상호작용이 많으므로 클라이언트 컴포넌트와 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) {
const nextQuantity = cartItem.quantity + quantity;
if (nextQuantity > 99 || nextQuantity > book.stock) {
return { success: false, message: '장바구니에 담을 수 있는 최대 수량을 초과했습니다.' };
}
cartItem.quantity = nextQuantity;
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: '장바구니에서 삭제했습니다.' };
}장바구니 액션별 조회와 변경 범위
| 동작 | 사용자 범위를 포함한 조회 | 성공한 변경 |
|---|---|---|
| 추가 | userId + bookId로 기존 항목 조회 | 기존 수량에 더하거나 새 항목 생성 |
| 수량 변경 | itemId + userId로 항목 조회 | 새 수량으로 대체 |
| 삭제 | itemId + userId로 항목 선택 | 해당 항목 한 건 삭제 |
- 동작: 추가
- 사용자 범위를 포함한 조회: userId + bookId로 기존 항목 조회
- 성공한 변경: 기존 수량에 더하거나 새 항목 생성
- 동작: 수량 변경
- 사용자 범위를 포함한 조회: itemId + userId로 항목 조회
- 성공한 변경: 새 수량으로 대체
- 동작: 삭제
- 사용자 범위를 포함한 조회: itemId + userId로 항목 선택
- 성공한 변경: 해당 항목 한 건 삭제
장바구니 변경은 재고를 예약하거나 차감하지 않습니다. 추가·수량 변경의 조회 후 저장은 한 번의 원자 연산이 아니므로 동시 요청의 수량 합산까지 보장하지 않습니다.
userId + bookId 고유 인덱스는 중복 행을 막지만 조회·수량 변경을 직렬화하지 않습니다. 동시 신규 추가의 중복 키 오류나 저장 오류는 현재 액션의 예외 경로이며 UI가 일반 실패 메시지를 표시합니다.
"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로 변환합니다. lean()만으로 ObjectId가 문자열로 바뀌지는 않습니다.
아래 페이지는 참조한 Book이 존재한다는 실습 전제를 사용합니다. ref가 삭제를 막는 외래 키는 아니며, 책이 사라지면 populate()가 null을 반환할 수 있습니다. 시드를 다시 넣거나 도서를 삭제할 때는 참조 무결성을 유지하거나 누락 항목을 처리하는 정책이 필요합니다.
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 Book, { 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 }>({ path: 'bookId', model: Book })
.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>
);
}수량 선택 UI는 최대 10까지만 보여주지만 서버와 스키마는 최대 99를 허용합니다. 반복 추가로 10을 넘기거나 다른 화면에서 수량이 바뀌면 선택지·로컬 상태가 맞지 않을 수 있는 예제 범위이므로, 실제 UI에서는 현재 수량을 포함한 선택 범위와 서버 값 동기화를 함께 설계합니다.