주문 및 검색
주문 처리와 도서 검색 및 페이지네이션을 구현합니다.
주문 페이지 및 주문 처리
장바구니 내용을 기반으로 주문을 생성하고 데이터베이스에 저장하는 Server Action을 구현합니다.
'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에서 처리합니다.
'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>
);
}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>
);
}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, 서버 쿼리, 페이지네이션에 함께 반영되어야 하는 이유를 보여줍니다.
도서 검색 구현 흐름
검색어를 state에만 두면 새로고침과 페이지 이동에서 깨진다. URL query를 기준으로 서버 컴포넌트가 같은 조건을 다시 계산해야 한다.
- input
SearchInput이 사용자의 검색어를 받는다.
- debounce
0.5초 지연으로 불필요한 라우팅을 줄인다.
- URL
?query=... 값을 기준 상태로 둔다.
- DB query
title/author regex 조건으로 count와 find를 같은 조건에 적용한다.
- pagination
페이지 링크가 query를 유지해 결과 범위가 흔들리지 않게 한다.
도서 목록 페이지에 검색 기능을 추가하여 특정 도서를 찾을 수 있도록 합니다.
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>
);
}"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"
/>
</>
);
}