주문 및 검색
주문 처리와 도서 검색 및 페이지네이션을 구현합니다.
주문 페이지 및 주문 처리
장바구니 내용을 기반으로 가상 주문을 저장합니다. 아래 코드는 유효한 도서·수량 데이터와 트랜잭션을 지원하는 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 밖에서 호출합니다.
}주문 트랜잭션과 후속 화면 갱신
주문 저장·재고 변경·장바구니 비우기가 같은 transaction에서 수행되고 commit 이후 revalidate로 UI 경로를 갱신합니다. commit 전 데이터 상태와 commit 뒤 UI 무효화의 구분이 단계 추적에 적합합니다.
Next · Order transaction boundary
주문 저장과 화면 갱신의 경계
같은 session으로 장바구니·도서를 읽고 재고 차감·주문 생성·장바구니 삭제를 수행합니다. 아래 성공 경로에서 commit 뒤의 캐시 재검증과 redirect는 DB 트랜잭션 밖입니다.
트랜잭션을 지원하는 MongoDB와 유효한 도서·수량 데이터가 전제입니다. 커밋이 확정된 뒤 화면 갱신이 실패해도 저장된 주문은 자동 취소되지 않습니다. 수동 예제에는 자동 재시도·주문 멱등성·커밋 결과 불명 복구가 없습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 세 데이터 변경을 같은 트랜잭션에서 수행하고 커밋 뒤 캐시와 화면을 갱신하는 성공 경로.
- 재고 차감, 주문 생성, 장바구니 삭제를 묶으며 커밋 뒤의 화면 갱신은 트랜잭션 밖이다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| transaction 시작 주문 성공 경로의 데이터 변경을 한 transaction 안에서 진행합니다. | 범위: 같은 transaction · UI cache: 아직 무효화 전 | 주문 관련 원자적 변경 범위가 시작됩니다. |
| 세 데이터 변경 주문 저장·재고 변경·장바구니 비우기를 같은 transaction에서 수행합니다. | 변경: 주문·재고·장바구니 · commit: 아직 전 | 서로 연결된 세 변경이 transaction에 모입니다. |
| commit 뒤 화면 갱신 commit 성공 뒤 cache 경로를 revalidate하여 화면 갱신으로 이어집니다. | DB: commit · cache: revalidate · 순서: commit 뒤 | 완료한 데이터 결과를 UI 재검증 경계에 알립니다. |
주문 트랜잭션과 후속 화면 갱신
주문 저장·재고 변경·장바구니 비우기가 같은 transaction에서 수행되고 commit 이후 revalidate로 UI 경로를 갱신합니다. commit 전 데이터 상태와 commit 뒤 UI 무효화의 구분이 단계 추적에 적합합니다.
Next · Order transaction boundary
주문 저장과 화면 갱신의 경계
같은 session으로 장바구니·도서를 읽고 재고 차감·주문 생성·장바구니 삭제를 수행합니다. 아래 성공 경로에서 commit 뒤의 캐시 재검증과 redirect는 DB 트랜잭션 밖입니다.
트랜잭션을 지원하는 MongoDB와 유효한 도서·수량 데이터가 전제입니다. 커밋이 확정된 뒤 화면 갱신이 실패해도 저장된 주문은 자동 취소되지 않습니다. 수동 예제에는 자동 재시도·주문 멱등성·커밋 결과 불명 복구가 없습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 세 데이터 변경을 같은 트랜잭션에서 수행하고 커밋 뒤 캐시와 화면을 갱신하는 성공 경로.
- 재고 차감, 주문 생성, 장바구니 삭제를 묶으며 커밋 뒤의 화면 갱신은 트랜잭션 밖이다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| transaction 시작 주문 성공 경로의 데이터 변경을 한 transaction 안에서 진행합니다. | 범위: 같은 transaction · UI cache: 아직 무효화 전 | 주문 관련 원자적 변경 범위가 시작됩니다. |
| 세 데이터 변경 주문 저장·재고 변경·장바구니 비우기를 같은 transaction에서 수행합니다. | 변경: 주문·재고·장바구니 · commit: 아직 전 | 서로 연결된 세 변경이 transaction에 모입니다. |
| commit 뒤 화면 갱신 commit 성공 뒤 cache 경로를 revalidate하여 화면 갱신으로 이어집니다. | DB: commit · cache: revalidate · 순서: commit 뒤 | 완료한 데이터 결과를 UI 재검증 경계에 알립니다. |
현재 재고는 주문 처리에서 다시 읽으므로 주문 확인 화면의 가격·수량 표시가 저장 시점의 최종 값까지 고정하지는 않습니다. 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>
);
}이 성공 페이지는 주문 ID·인증·DB 조회 없이 안내 문구를 보여 줍니다. /order-success를 직접 열어도 같은 문구가 나오므로 화면만으로 주문 저장을 증명할 수 없습니다. 학습에서는 주문의 소유자·수량·구매 가격, 도서 재고, 자기 장바구니 삭제를 최종 완료 안내의 같은 조건으로 따로 비교합니다.
위 액션은 DB 커밋 뒤 재검증과 화면 이동을 시도합니다. 커밋이 확정된 뒤 화면 갱신만 실패했거나 커밋 결과를 알 수 없는 경우에는 곧바로 주문을 다시 제출하지 않습니다. 버튼의 일반 오류도 주문이 저장되지 않았다는 뜻으로 해석하지 않습니다. 이 예제에는 주문 내역 조회 화면이나 멱등성·불명 결과의 복구 절차가 없으므로, 실습에서는 DB·서버 기록으로 결과를 확인하고 운영에서는 별도 확인 경로를 마련해야 합니다.
검색 기능 구현
다음 전체 코드로 기존 도서 목록 페이지를 교체합니다. 입력은 500 ms 디바운스 뒤 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초 목표 달성을 보장하지는 않습니다. 실제 데이터 규모에서 별도로 측정합니다.