본문으로 건너뛰기

안동민 개발노트

본문 시작
17장 : 실전 프로젝트단계별 구현 가이드

장바구니 구현

장바구니 상태와 서버 액션을 구성하고 장바구니 화면으로 연결합니다.

핵심 기능 구현: 장바구니 및 주문

다음 다이어그램은 장바구니 버튼 클릭 이후 서버 액션에서 세션, 재고, 수량, 캐시 재검증이 어떤 순서로 처리되는지 보여줍니다.

장바구니와 주문 기능은 사용자 상호작용이 많으므로 클라이언트 컴포넌트와 Server Actions를 혼합하여 구현합니다.

장바구니 관리

  • Server Actions: 장바구니에 항목을 추가/삭제/수량 변경하는 서버 액션 정의. 데이터베이스 업데이트 및 캐시 재검증 수행.
  • 클라이언트 컴포넌트: useTransition 등을 사용하여 Server Action의 로딩 상태를 처리하고, 장바구니 UI를 업데이트.
actions/cart.ts (Server Actions)
'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: '장바구니에서 삭제했습니다.' };
}
components/AddToCartButton.tsx (클라이언트 컴포넌트)
"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로 변환합니다.

types/cart.ts
export interface CartItemDto {
  id: string;
  quantity: number;
  book: {
    id: string;
    title: string;
    author: string;
    price: number;
    imageUrl: string;
    stock: number;
  };
}
app/cart/page.tsx (서버 컴포넌트)
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>
  );
}
components/CartItemCard.tsx (클라이언트 컴포넌트)
"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>
  );
}