주문 및 검색
주문 처리와 도서 검색 및 페이지네이션을 구현합니다.
주문 페이지 및 주문 처리
장바구니 내용을 기반으로 주문을 생성하고 데이터베이스에 저장하는 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, 서버 쿼리, 페이지네이션에 함께 반영되어야 하는 이유를 보여줍니다.
도서 목록 페이지에 검색 기능을 추가하여 특정 도서를 찾을 수 있도록 합니다.
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"
/>
</>
);
}