본문으로 건너뛰기

안동민 개발노트

본문 시작
17장 : 실전 프로젝트단계별 구현 가이드주문·검색·배포

주문 및 검색

주문 처리와 도서 검색 및 페이지네이션을 구현합니다.

주문 페이지 및 주문 처리

장바구니 내용을 기반으로 주문을 생성하고 데이터베이스에 저장하는 Server Action을 구현합니다.

actions/order.ts (Server Actions)
'use server';

import connectToDatabase from '@/lib/db';
import Order from '@/models/Order';
import CartItem from '@/models/CartItem';
import Book from '@/models/Book';
import mongoose from 'mongoose';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';

type OrderActionResult = { success: false; message: string };

class OrderRejectedError extends Error {}

async function getRequiredUserId() {
  const session = await getSession();
  if (!session?.user?.id) {
    redirect('/login?next=/order');
  }
  return session.user.id;
}

export async function placeOrder(): Promise<OrderActionResult> {
  const userId = await getRequiredUserId();

  await connectToDatabase();

  let totalPrice = 0;
  const orderItems: Array<{
    bookId: mongoose.Types.ObjectId;
    quantity: number;
    priceAtPurchase: number;
  }> = [];
  const session = await mongoose.startSession();
  session.startTransaction();

  try {
    // 주문 대상도 같은 트랜잭션 안에서 읽어 일관된 스냅샷을 사용합니다.
    const cartItems = await CartItem.find({ userId }).session(session);

    if (cartItems.length === 0) {
      throw new OrderRejectedError('장바구니가 비어있어 주문할 수 없습니다.');
    }

    for (const item of cartItems) {
      const book = await Book.findById(item.bookId).session(session);

      if (!book || book.stock < item.quantity) {
        throw new OrderRejectedError(`책 "${book?.title || '알 수 없음'}"의 재고가 부족합니다.`);
      }

      // 재고 감소
      book.stock -= item.quantity;
      await book.save({ session });

      totalPrice += book.price * item.quantity;
      orderItems.push({
        bookId: book._id,
        quantity: item.quantity,
        priceAtPurchase: book.price,
      });
    }

    // 주문 생성
    await Order.create([{ userId, items: orderItems, totalPrice, status: 'completed' }], { session });

    // 장바구니 비우기
    await CartItem.deleteMany({ userId }).session(session);

    await session.commitTransaction();

  } catch (error) {
    await session.abortTransaction();
    if (error instanceof OrderRejectedError) {
      return { success: false, message: error.message };
    }
    console.error('주문 처리 중 오류 발생:', error);
    throw new Error('주문을 처리하지 못했습니다.');
  } finally {
    await session.endSession();
  }

  revalidatePath('/cart'); // 장바구니 페이지 캐시 갱신
  revalidatePath('/order-success'); // 주문 성공 페이지 캐시 갱신
  // 필요한 경우 도서 상세 페이지도 재고 갱신을 위해 revalidatePath('/books/[id]', 'page');
  redirect('/order-success'); // redirect는 예외를 던지므로 try/catch 밖에서 호출합니다.
}

성공 경로는 값을 반환하지 않고 redirect()로 끝납니다.

따라서 OrderActionResult에는 화면에서 복구할 수 있는 실패만 두고, 예상하지 못한 오류는 예외로 전달하여 클라이언트의 catch에서 처리합니다.

components/PlaceOrderButton.tsx
'use client';

import { useState, useTransition } from 'react';
import { placeOrder } from '@/actions/order';
import Button from '@/components/ui/Button';

export default function PlaceOrderButton() {
  const [isPending, startTransition] = useTransition();
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const handleOrder = () => {
    setErrorMessage(null);
    startTransition(async () => {
      try {
        const result = await placeOrder();
        setErrorMessage(result.message);
      } catch {
        setErrorMessage('주문을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.');
      }
    });
  };

  return (
    <div>
      <Button type="button" variant="primary" disabled={isPending} onClick={handleOrder}>
        {isPending ? '주문 처리 중...' : '주문 완료하기'}
      </Button>
      {errorMessage && <p aria-live="polite" className="mt-2 text-sm text-red-700">{errorMessage}</p>}
    </div>
  );
}
app/order/page.tsx (주문 확인 페이지 - 서버 컴포넌트)
import connectToDatabase from '@/lib/db';
import CartItemModel from '@/models/CartItem';
import type { IBook } from '@/models/Book';
import PlaceOrderButton from '@/components/PlaceOrderButton';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';

export default async function OrderPage() {
  const session = await getSession();
  if (!session?.user?.id) {
    redirect('/login?next=/order');
  }
  const userId = session.user.id;

  await connectToDatabase();

  const cartItems = await CartItemModel.find({ userId })
    .populate<{ bookId: IBook }>('bookId')
    .lean();

  const total = cartItems.reduce((sum, item) => sum + (item.bookId as IBook).price * item.quantity, 0);

  if (cartItems.length === 0) {
    return (
      <main className="container mx-auto px-4 py-8 text-center">
        <h1 className="text-3xl font-bold mb-4">주문할 상품이 없습니다.</h1>
        <p className="text-lg text-gray-600">장바구니에 상품을 추가해주세요.</p>
        <Link
          href="/books"
          className="mt-6 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>
      </main>
    );
  }

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8 text-center">주문 확인</h1>
      <div className="bg-white p-6 rounded-lg shadow-lg">
        <h2 className="text-2xl font-semibold mb-4">주문 상품</h2>
        <ul className="divide-y divide-gray-200">
          {cartItems.map((item) => (
            <li key={item._id.toString()} className="py-4 flex justify-between items-center">
              <div className="flex items-center space-x-4">
                <Image src={(item.bookId as IBook).imageUrl} alt={(item.bookId as IBook).title} width={60} height={80} className="rounded-md" />
                <div>
                  <h3 className="font-medium text-gray-900">{(item.bookId as IBook).title}</h3>
                  <p className="text-sm text-gray-600">수량: {item.quantity}</p>
                </div>
              </div>
              <span className="font-bold text-gray-900">{((item.bookId as IBook).price * item.quantity).toLocaleString()}</span>
            </li>
          ))}
        </ul>
        <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>
          <PlaceOrderButton />
        </div>
      </div>
    </main>
  );
}
app/order-success/page.tsx (주문 성공 페이지)
import Link from 'next/link';

export default function OrderSuccessPage() {
  return (
    <main className="container mx-auto px-4 py-16 text-center">
      <h1 className="text-4xl font-bold text-green-600 mb-6">🎉 주문이 성공적으로 완료되었습니다! 🎉</h1>
      <p className="text-lg text-gray-700 mb-8">주문해주셔서 감사합니다. 빠른 시일 내에 배송될 예정입니다.</p>
      <div className="flex justify-center space-x-4">
        <Link
          href="/"
          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>
        <Link
          href="/books"
          className="inline-flex rounded-md bg-gray-200 px-4 py-2 font-semibold text-gray-800 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
        >
          다른 책 둘러보기
        </Link>
      </div>
    </main>
  );
}

검색 기능 구현

아래 다이어그램은 검색어가 클라이언트 상태에 머물지 않고 URL, 서버 쿼리, 페이지네이션에 함께 반영되어야 하는 이유를 보여줍니다.

도서 목록 페이지에 검색 기능을 추가하여 특정 도서를 찾을 수 있도록 합니다.

app/books/page.tsx (기존 코드에 검색 기능 추가)
import BookCard from '@/components/BookCard';
import SearchInput from '@/components/SearchInput';
import connectToDatabase from '@/lib/db';
import Book, { type IBook } from '@/models/Book';

interface BooksPageProps {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default async function BooksPage({ searchParams }: BooksPageProps) {
  const resolvedSearchParams = await searchParams;
  const pageValue = Array.isArray(resolvedSearchParams.page)
    ? resolvedSearchParams.page[0]
    : resolvedSearchParams.page;
  const page = Math.max(1, Number.parseInt(pageValue ?? '1', 10) || 1);
  const limit = 12;
  const skip = (page - 1) * limit;
  const queryValue = Array.isArray(resolvedSearchParams.query)
    ? resolvedSearchParams.query[0]
    : resolvedSearchParams.query;
  const query = (queryValue ?? '').trim().slice(0, 80);
  const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

  await connectToDatabase();

  const searchCondition = escapedQuery
    ? {
        $or: [
          { title: { $regex: escapedQuery, $options: 'i' } },
          { author: { $regex: escapedQuery, $options: 'i' } },
        ],
      }
    : {};

  const totalBooks = await Book.countDocuments(searchCondition);
  const books: IBook[] = await Book.find(searchCondition)
    .skip(skip)
    .limit(limit)
    .lean();

  const totalPages = Math.ceil(totalBooks / limit);

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8 text-center">모든 도서</h1>
      <div className="mb-8 max-w-md mx-auto">
        <SearchInput initialQuery={query} />
      </div>
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
        {books.map((book) => (
          <BookCard key={book._id.toString()} book={book} />
        ))}
      </div>
      {/* 페이지네이션 컴포넌트 */}
      <div className="flex justify-center mt-8 space-x-2">
        {/* 검색어는 URL에 안전하게 인코딩하여 다음 페이지에서도 유지합니다. */}
        {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
          <a
            key={p}
            href={`/books?page=${p}${query ? `&query=${encodeURIComponent(query)}` : ''}`}
            className={`px-4 py-2 border rounded-md ${
              p === page ? 'bg-blue-600 text-white' : 'bg-white text-blue-600 hover:bg-blue-100'
            }`}
          >
            {p}
          </a>
        ))}
      </div>
    </main>
  );
}
components/SearchInput.tsx (클라이언트 컴포넌트)
"use client";

import { type ChangeEvent, useEffect, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';

interface SearchInputProps {
  initialQuery?: string;
}

export default function SearchInput({ initialQuery = '' }: SearchInputProps) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [inputValue, setInputValue] = useState(initialQuery);

  const updateQuery = useDebouncedCallback(
    (value: string, currentParams: string) => {
      const params = new URLSearchParams(currentParams);
      const normalizedValue = value.trim().slice(0, 80);

      if (normalizedValue) {
        params.set('query', normalizedValue);
        params.set('page', '1');
      } else {
        params.delete('query');
        params.delete('page');
      }

      const nextParams = params.toString();
      const nextUrl = nextParams ? `${pathname}?${nextParams}` : pathname;
      const currentUrl = currentParams ? `${pathname}?${currentParams}` : pathname;

      if (nextUrl !== currentUrl) {
        router.replace(nextUrl);
      }
    },
    500,
  );

  useEffect(() => {
    // 뒤로가기처럼 URL이 외부에서 바뀌면 예약된 쓰기를 취소하고 입력값을 동기화합니다.
    updateQuery.cancel();
    const currentQuery = searchParams.get('query') || '';
    setInputValue(currentQuery);
  }, [searchParams, updateQuery]);

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setInputValue(value);
    updateQuery(value, searchParams.toString());
  };

  return (
    <>
      <label htmlFor="book-search" className="sr-only">
        도서 검색
      </label>
      <input
        id="book-search"
        type="search"
        value={inputValue}
        onChange={handleChange}
        placeholder="도서명 또는 저자 검색..."
        className="w-full p-3 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
      />
    </>
  );
}