장바구니 구현
장바구니 상태와 서버 액션을 구성하고 장바구니 화면으로 연결합니다.
앞 글에서 도서 목록과 상세 페이지를 만들었습니다. 이 글에서는 로그인한 사용자가 자기 장바구니에 도서를 담고, 수량을 바꾸고, 지우는 기능을 서버 액션과 클라이언트 컴포넌트로 구현합니다. 장바구니는 사용자 상호작용이 많으므로, 데이터 변경은 서버 액션이 맡고 로딩 상태와 화면 갱신은 클라이언트 컴포넌트가 맡도록 섞어서 구현합니다.
핵심 기능 구현: 장바구니 및 주문
장바구니 관리
서버 액션에는 장바구니 항목을 추가하고, 지우고, 수량을 바꾸는 함수를 정의해 데이터베이스를 갱신하고 캐시를 재검증합니다. 클라이언트 컴포넌트는 useTransition 등으로 서버 액션의 로딩 상태를 처리하고 장바구니 UI를 갱신합니다. 모든 액션은 먼저 서버 세션에서 사용자 ID를 얻고, 입력 형식과 범위를 검사한 뒤 데이터베이스를 바꿉니다.
'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: '장바구니에서 삭제했습니다.' };
}Next · Cart action scopes
장바구니 액션의 조회·변경 범위
서버가 확인한 세션의 userId를 항목 조회·삭제 조건에 포함합니다. 추가·수량 변경은 도서 재고를 검사하지만 장바구니에 담는 시점에는 재고를 예약하거나 차감하지 않습니다.
| 동작 | 사용자 범위를 포함한 조회 | 성공한 변경 |
|---|---|---|
| 추가 | userId + bookId로 기존 항목 조회 | 기존 수량에 더하거나 새 항목 생성 |
| 수량 변경 | itemId + userId로 항목 조회 | 새 수량으로 대체 |
| 삭제 | itemId + userId로 항목 선택 | 해당 항목 한 건 삭제 |
- 동작: 추가
- 사용자 범위를 포함한 조회
- userId + bookId로 기존 항목 조회
- 성공한 변경
- 기존 수량에 더하거나 새 항목 생성
- 동작: 수량 변경
- 사용자 범위를 포함한 조회
- itemId + userId로 항목 조회
- 성공한 변경
- 새 수량으로 대체
- 동작: 삭제
- 사용자 범위를 포함한 조회
- itemId + userId로 항목 선택
- 성공한 변경
- 해당 항목 한 건 삭제
장바구니 변경은 재고를 예약하거나 차감하지 않습니다. 추가·수량 변경의 조회 후 저장은 한 번의 원자 연산이 아니므로 동시 요청의 수량 합산까지 보장하지 않습니다.
위 표는 장바구니 액션별로 무엇을 조회하고 무엇을 바꾸는지 정리합니다. 세 액션 모두 서버가 확인한 세션의 userId를 조회나 삭제 조건에 넣습니다. 추가는 userId와 bookId로 기존 항목을 찾아 기존 수량에 더하거나 새 항목을 만들고, 수량 변경은 itemId와 userId로 항목을 찾아 새 수량으로 바꾸며, 삭제는 itemId와 userId로 고른 항목 한 건을 지웁니다. 표의 설명처럼 추가와 수량 변경은 도서의 재고를 검사하지만, 장바구니에 담는 시점에 재고를 예약하거나 줄이지는 않습니다. 또 조회한 뒤 저장하는 과정이 하나의 원자적인 연산이 아니므로, 동시에 들어온 요청의 수량이 올바르게 합쳐진다고 보장하지 않습니다.
userId와 bookId의 고유 인덱스는 중복 행을 막지만, 조회와 수량 변경을 차례로 처리하게 만들지는 않습니다. 동시에 새로 추가하다 생기는 중복 키 오류나 저장 오류는 지금 액션의 예외 경로로 이어지며, UI가 일반적인 실패 메시지를 보여 줍니다.
상세 페이지의 장바구니 버튼은 클라이언트 컴포넌트로 만들어, 서버 액션의 결과 메시지를 보여 줍니다.
"use client";
import { useState, useTransition } from 'react';
import { unstable_rethrow } from 'next/navigation';
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 (error) {
unstable_rethrow(error);
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>
);
}버튼은 startTransition 안에서 서버 액션을 기다리고, 그동안 isPending이 true가 되어 버튼이 비활성화되고 글자가 추가 중...으로 바뀝니다. 서버 액션이 돌려준 success에 따라 초록색이나 빨간색으로 메시지를 보여 주고, 액션 호출 자체가 예외로 끝나면 catch에서 일반 안내 문구를 보여 줍니다. 재고 부족처럼 예상한 실패는 예외가 아니라 success: false로 돌아오므로, 두 경로의 메시지가 다르게 보입니다. 로그인하지 않은 사용자를 /login으로 보내는 redirect()도 클라이언트에서는 거절된 Promise로 돌아오므로, catch의 첫 줄에서 unstable_rethrow(error)로 Next.js의 내부 오류를 다시 던져 이 이동을 일반 실패로 처리하지 않습니다.
장바구니 페이지
장바구니 페이지는 항목을 보여 주고, 수량 변경과 삭제 기능을 제공합니다. 서버의 Mongoose 문서를 클라이언트 컴포넌트에 그대로 넘기지 않고, 문자열 ID와 원시 값만 가진 DTO로 바꿉니다. lean()만으로 ObjectId가 문자열로 바뀌지는 않습니다.
이 페이지는 참조한 Book이 있다는 실습 전제를 씁니다. ref는 삭제를 막는 외래 키가 아니므로, 책이 사라지면 populate()가 null을 돌려줄 수 있습니다. 시드를 다시 넣거나 도서를 지울 때는 참조 무결성을 유지하거나, 빠진 항목을 처리하는 정책이 필요합니다. 먼저 클라이언트에 넘길 DTO 타입을 정의합니다.
export interface CartItemDto {
id: string;
quantity: number;
book: {
id: string;
title: string;
author: string;
price: number;
imageUrl: string;
stock: number;
};
}DTO의 ID는 모두 string이고, 나머지 값도 숫자와 문자열뿐입니다. 서버 컴포넌트가 클라이언트 컴포넌트에 넘기는 props는 직렬화할 수 있어야 하므로, ObjectId나 Mongoose 문서처럼 메서드를 가진 객체 대신 이 형태로 바꿔 넘깁니다. 서버 컴포넌트인 장바구니 페이지는 세션의 사용자 ID로만 항목을 조회하고, DTO로 바꾸어 합계를 계산합니다. populate는 각 항목의 bookId 자리를 실제 Book 문서로 채우므로, 아래 코드에서는 item.bookId.title처럼 도서 필드를 바로 읽습니다. 합계는 도서의 현재 가격과 수량을 곱해 더한 값이므로, 도서 가격이 바뀌면 장바구니 합계도 따라 바뀝니다.
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>
);
}로그인하지 않은 요청은 조회하기 전에 /login?next=/cart로 보내므로, 로그인을 마치면 다시 장바구니로 돌아옵니다. 각 항목은 클라이언트 컴포넌트인 CartItemCard가 보여 주며, 수량을 바꾸면 먼저 화면의 값을 바꾼 뒤 서버 액션이 실패하면 이전 값으로 되돌립니다. 아래 코드의 previousQuantity가 되돌릴 값을 기억해 두는 변수이며, 결과가 success: false일 때와 로그인 화면으로 보내는 리다이렉트가 아닌 예외가 났을 때 모두 이 값으로 돌아갑니다. 삭제는 화면에서 미리 지우지 않고, 서버 액션의 revalidatePath('/cart')로 페이지가 새 데이터로 다시 그려질 때 항목이 사라집니다.
"use client";
import { type ChangeEvent, useState, useTransition } from 'react';
import { unstable_rethrow } from 'next/navigation';
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 (error) {
unstable_rethrow(error);
setQuantity(previousQuantity);
setFeedback('수량을 바꾸지 못했습니다. 잠시 후 다시 시도해 주세요.');
}
});
};
const handleRemoveItem = () => {
setFeedback(null);
startTransition(async () => {
try {
const result = await removeCartItem(item.id);
if (!result.success) setFeedback(result.message);
} catch (error) {
unstable_rethrow(error);
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-base 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에서는 현재 수량을 포함한 선택 범위와 서버 값의 동기화를 함께 설계합니다.
정리
- 장바구니 서버 액션은 서버 세션에서 사용자 ID를 얻고, 도서 ID와 수량의 형식, 범위, 재고를 검사한 뒤 변경하고
/cart를 재검증합니다. - 추가는 기존 수량에 더하고, 수량 변경은 새 값으로 바꾸며, 삭제는
itemId와userId가 모두 맞는 항목만 지웁니다. - 장바구니에 담는 것은 재고를 예약하거나 줄이지 않으며, 조회 뒤 저장은 원자적인 연산이 아니므로 동시 요청의 합산을 보장하지 않습니다.
- 서버 컴포넌트는 Mongoose 문서를 문자열 ID와 원시 값의 DTO로 바꾸어 클라이언트 컴포넌트에 넘깁니다.
- 클라이언트 컴포넌트는
useTransition으로 대기 상태를 보여 주고, 실패하면 화면의 값을 이전으로 되돌립니다.
연습 문제
1. 추가와 대체를 계산하기
유효한 사용자 A의 장바구니에 어떤 책이 수량 3으로 담겨 있고, 그 도서의 재고는 5입니다. 다른 요청이 없는 상태에서 addToCart에 2를 넣고, 성공한 뒤 updateCartItemQuantity에 2를 넣으면 각 단계의 장바구니 수량과 Book 재고는 얼마일까요? 처음 조건에서 추가 수량이 3이었다면 저장과 재검증까지 갈까요?
해설 보기
추가는 3 + 2 = 5가 되고, 수량 변경은 더하기가 아니라 새 값 2로 바꿉니다. 두 액션 모두 Book의 재고를 줄이지 않으므로 재고는 계속 5입니다. 처음에 3을 추가하면 합계 6이 재고 5를 넘으므로 거절되어, 저장과 경로 재검증까지 가지 않습니다.
계정 B가 A의 항목 ID를 보내더라도, 조회와 삭제 조건에는 서버 세션의 B ID가 함께 쓰입니다. ID가 유효하다는 이유만으로 A의 항목을 고치지 않습니다. 여기의 계산은 다른 요청이 없는 순차 실행 조건이며, 고유 인덱스만으로 동시 수량 변경이 차례로 처리된다고 볼 수는 없습니다.
2. 실패한 수량 변경과 화면
CartItemCard에서 수량을 2에서 4로 바꿨는데, 그 사이 다른 사용자의 주문으로 재고가 3으로 줄어 서버 액션이 실패를 돌려줬습니다. 화면의 선택값과 메시지는 어떻게 될까요?
해설 보기
선택값은 먼저 4로 바뀌었다가, result.success가 false이므로 setQuantity(previousQuantity)로 다시 2가 됩니다. 그리고 서버가 돌려준 "재고가 부족하거나 책을 찾을 수 없습니다." 메시지가 항목 아래에 보입니다. 화면의 값을 먼저 바꾸고 실패하면 되돌리는 방식은 반응이 빠르지만, 실패를 반드시 처리해야 화면과 서버의 값이 어긋나지 않습니다.
3. 지워진 도서가 담긴 장바구니
관리자가 어떤 도서를 데이터베이스에서 지웠는데, 그 도서가 사용자 A의 장바구니에 남아 있습니다. A가 /cart를 열면 이 페이지의 코드에서 어떤 문제가 생길까요? 어떻게 처리하면 좋을까요?
해설 보기
populate()가 지워진 도서를 찾지 못해 item.bookId가 null이 되고, DTO를 만들 때 item.bookId._id를 읽다가 오류가 납니다. DTO로 바꾸기 전에 bookId가 null인 항목을 걸러 내고, 사용자에게 "더 이상 판매하지 않는 도서가 장바구니에서 빠졌습니다." 같은 안내를 보여 주거나 그 항목을 정리하는 정책을 둡니다. 근본적으로는 도서를 지우는 대신 판매 중지 상태로 표시해 참조가 끊어지지 않게 할 수도 있습니다.
useTransition과 서버 액션 호출은 React 문서의 useTransition에서, Mongoose의 populate는 populate 문서에서 확인할 수 있습니다.
다음 글 주문 및 검색에서는 트랜잭션으로 장바구니를 주문으로 바꾸고, 도서 목록에 검색 기능을 더합니다.