주문 및 검색
주문 처리와 도서 검색 및 페이지네이션을 구현합니다.
주문 페이지 및 주문 처리
장바구니 내용을 기반으로 가상 주문을 저장합니다. 아래 코드는 유효한 도서·수량 데이터와 트랜잭션을 지원하는 MongoDB 배포를 전제로 합니다. 재고 차감, 주문 생성, 장바구니 삭제에 같은 session을 전달합니다.
수동 startTransaction·commitTransaction 예제이므로 일시적 충돌이나 커밋 결과 불명 오류를 자동으로 재시도하지 않습니다. 응답을 받지 못했다고 주문이 없다고 단정해 재주문하지 않도록, 운영에서는 주문 식별자·멱등성과 확인·복구 절차를 정해야 합니다.
'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 {
// 주문 대상 조회에도 같은 트랜잭션의 session을 전달합니다.
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 밖에서 호출합니다.
}주문 트랜잭션과 후속 화면 갱신
성공 경로를 그렸습니다. 커밋이 확정된 뒤 캐시 재검증이나 화면 이동이 실패해도 이미 저장된 주문이 자동으로 취소되지는 않습니다.
현재 재고는 주문 처리에서 다시 읽으므로 주문 확인 화면의 가격·수량 표시가 저장 시점의 최종 값까지 고정하지는 않습니다. completed는 이 실습의 가상 주문 완료 상태이며 결제·배송 완료를 뜻하지 않습니다.
성공 경로는 값을 반환하지 않고 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>
);
}주문 확인 페이지도 참조한 도서가 존재한다고 가정합니다. 도서를 삭제하면 populate() 결과가 null일 수 있으므로, 운영에서는 누락 도서 처리와 참조 보존 정책을 정합니다. 확인 화면의 조회와 주문 액션은 별도 요청입니다.
import connectToDatabase from '@/lib/db';
import CartItemModel from '@/models/CartItem';
import Book, { 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 }>({ path: 'bookId', model: Book })
.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>
);
}검색 기능 구현
다음 전체 코드로 기존 도서 목록 페이지를 교체합니다. 입력은 500ms 디바운스 뒤 URL의 query에 반영하고, 검색어가 바뀌면 첫 페이지로 돌아갑니다. 서버는 같은 검색 조건으로 건수와 목록을 조회하며 페이지 링크에도 검색어를 유지합니다.
건수 조회와 목록 조회는 같은 트랜잭션이 아니므로 동시 데이터 변경 시 완전히 같은 스냅샷이라고 보장하지 않습니다. _id 정렬은 고유한 순서를 정하지만 offset 이동 중의 추가·삭제까지 고정하지는 않습니다.
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 requestedPage = /^\d+$/.test(pageValue ?? '') ? Number(pageValue) : 1;
const limit = 12;
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 totalPages = Math.ceil(totalBooks / limit);
const page = Number.isSafeInteger(requestedPage)
? Math.min(Math.max(1, requestedPage), Math.max(1, totalPages))
: 1;
const skip = (page - 1) * limit;
const books: IBook[] = await Book.find(searchCondition)
.sort({ _id: 1 })
.skip(skip)
.limit(limit)
.lean();
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);
return () => updateQuery.cancel();
}, [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"
/>
</>
);
}정규식 메타 문자를 이스케이프하고 검색어를 80개의 UTF-16 코드 단위로 제한했지만, 대소문자를 무시하는 부분 정규식 검색의 인덱스 효율이나 1초 목표 달성을 보장하지는 않습니다. 실제 데이터 규모에서 별도로 측정합니다.