주문 및 검색
주문 처리와 도서 검색 및 페이지네이션을 구현합니다.
앞 글에서 장바구니를 만들었습니다. 이 글에서는 장바구니의 내용을 하나의 트랜잭션으로 가상 주문으로 바꾸는 주문 처리와, 도서 목록에 제목과 저자 검색을 더하는 기능을 구현합니다.
주문 페이지 및 주문 처리
장바구니의 내용을 바탕으로 가상 주문을 저장합니다. 다음 코드는 유효한 도서와 수량 데이터, 그리고 트랜잭션을 지원하는 MongoDB 배포를 전제로 하며, 재고 차감, 주문 생성, 장바구니 삭제에 같은 session을 넘깁니다.
이 예제는 startTransaction과 commitTransaction을 직접 부르는 방식이므로, 일시적인 충돌이나 커밋 결과를 알 수 없는 오류를 자동으로 다시 시도하지 않습니다. 응답을 받지 못했다고 주문이 없다고 단정해 다시 주문하지 않도록, 운영에서는 주문 식별자와 멱등성(idempotency), 확인과 복구 절차를 정해야 합니다. 요청 키에 고유 제약을 걸어 같은 요청을 한 번만 반영하는 방법은 데이터베이스 교재의 트랜잭션과 멱등성에서 MySQL 예제로 다룹니다.
'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 재검증 경계에 알립니다. |
위 그림은 주문의 저장과 화면 갱신이 어디에서 나뉘는지 보여 줍니다. 같은 session을 붙인 세 가지 데이터 변경, 즉 Book.stock의 재고 차감, Order의 주문 생성, CartItem의 장바구니 삭제가 하나의 트랜잭션 안에서 이루어지고, 커밋이 끝난 뒤에 캐시 경로를 재검증하고 주문 완료 화면으로 이동합니다. 그림의 단계도 트랜잭션 시작, 세 데이터 변경, 커밋 뒤의 화면 갱신 순서입니다. 그림의 설명처럼 커밋 뒤의 재검증과 redirect는 DB 트랜잭션 밖이므로, 커밋이 확정된 뒤 화면 갱신이 실패해도 저장된 주문은 자동으로 취소되지 않습니다. 이 수동 예제에는 자동 재시도, 주문의 멱등성, 커밋 결과를 알 수 없을 때의 복구가 없습니다.
현재 재고는 주문을 처리할 때 다시 읽으므로, 주문 확인 화면에 보인 가격과 수량이 저장 시점의 최종 값까지 고정하지는 않습니다. completed는 이 실습의 가상 주문 완료 상태이며, 결제나 배송이 끝났다는 뜻이 아닙니다.
성공 경로는 값을 돌려주지 않고 redirect()로 끝납니다. 이때 클라이언트에서 기다리던 placeOrder()의 Promise는 결과 값 대신 Next.js의 내부 리다이렉트 오류로 거절되고, 화면 이동은 Next.js가 따로 처리합니다. 그래서 OrderActionResult에는 화면에서 다시 시도할 수 있는 실패만 담고, 클라이언트의 catch는 첫 줄의 unstable_rethrow(error)로 이 내부 오류를 다시 던진 뒤 남은 예상하지 못한 오류만 처리합니다.
'use client';
import { useState, useTransition } from 'react';
import { unstable_rethrow } from 'next/navigation';
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 (error) {
unstable_rethrow(error);
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>
);
}확인 페이지는 장바구니와 같은 방식으로 자기 항목만 조회해 예상 금액을 보여 주고, 실제 주문은 PlaceOrderButton이 호출하는 서버 액션이 처리합니다. 화면의 금액은 이 페이지를 그린 시점의 계산일 뿐이며, 주문 액션은 버튼을 누른 시점에 도서의 가격과 재고를 다시 읽어 저장합니다. 주문이 끝나면 이동할 성공 페이지를 만듭니다.
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와 서버 기록으로 결과를 확인하고 운영에서는 별도의 확인 경로를 마련해야 합니다.
검색 기능 구현
다음 전체 코드로 기존 도서 목록 페이지를 바꿉니다. 입력은 500ms 디바운스(debounce) 뒤에 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>
);
}서버는 검색어의 앞뒤 공백을 지우고 80자로 자른 뒤, 정규식의 특수 문자를 이스케이프해 title이나 author에 대소문자 구분 없이 들어 있는지 찾습니다. 예를 들어 ?query=react는 시드의 React 마스터를 찾습니다. 검색창에 c++를 입력하면 URL의 query 값은 c%2B%2B로 인코딩되어 들어가고, 서버에서 +가 이스케이프되어 문자 그대로 비교됩니다. 주소창에 ?query=c++를 직접 적으면 URL 쿼리 규칙에 따라 +가 공백으로 해석되므로, 공백을 지운 검색어는 c가 됩니다. 페이지 링크는 encodeURIComponent로 인코딩한 검색어를 붙여, 다음 페이지에서도 같은 조건을 유지합니다.
검색 입력은 클라이언트 컴포넌트로 만들어, 입력이 멈추면 URL을 바꿉니다. useDebouncedCallback은 마지막 입력 뒤 500ms 동안 새 입력이 없을 때만 함수를 실행하고, 그 함수가 router.replace로 주소를 바꿉니다. push 대신 replace를 쓰므로 글자를 칠 때마다 방문 기록이 쌓이지 않고, 주소가 바뀌면 서버 컴포넌트인 목록 페이지가 새 searchParams로 다시 렌더링됩니다.
"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초 목표의 달성을 보장하지는 않습니다. 실제 데이터 규모에서 따로 측정합니다.
정리
- 주문 액션은 세션의 사용자 ID로 장바구니를 읽고, 같은 트랜잭션 안에서 재고 차감, 주문 생성, 장바구니 삭제를 한 뒤 커밋합니다.
- 주문 항목에는 처리 시점에 다시 읽은 도서의 가격을
priceAtPurchase로 저장합니다. - 재검증과
redirect는 커밋 뒤의 트랜잭션 밖이며,redirect는try/catch바깥에서 호출합니다. - 화면에서 다시 시도할 수 있는 실패는 결과로 돌려주고, 예상하지 못한 오류는 예외로 넘기며, 결과를 알 수 없을 때 곧바로 다시 주문하지 않습니다.
- 검색은 디바운스한 입력을 URL의
query로 옮기고, 서버가 이스케이프한 검색어로 건수와 목록을 같은 조건으로 조회합니다.
연습 문제
1. 재고가 부족한 책이 섞인 주문
장바구니에 책 A 2권(재고 5)과 책 B 3권(재고 2)이 담겨 있고, 장바구니는 A, B 순서로 조회됩니다. 주문을 누르면 A의 재고와 주문, 장바구니는 어떻게 될까요?
해설 보기
A를 처리하며 트랜잭션 안에서 재고를 5에서 3으로 줄이지만, B에서 재고 2가 수량 3보다 적으므로 OrderRejectedError를 던집니다. catch에서 abortTransaction()을 부르므로, 같은 트랜잭션 안에서 바꾼 A의 재고도 커밋되지 않고 5로 남습니다. 주문은 만들어지지 않고 장바구니도 그대로이며, 화면에는 B의 재고가 부족하다는 메시지가 보입니다.
2. 주문 버튼을 두 번 누를 때
사용자가 주문 버튼을 누른 직후 네트워크가 느려 응답이 오지 않자, 새로고침한 뒤 주문 확인 페이지에서 버튼을 다시 눌렀습니다. 첫 요청이 사실은 커밋까지 끝났다면 두 번째 요청은 어떻게 될까요?
해설 보기
첫 요청의 커밋이 끝난 뒤에 페이지를 새로 고쳤다면 장바구니가 이미 비었으므로, 주문 확인 페이지는 "주문할 상품이 없습니다."를 보여 주고 PlaceOrderButton을 그리지 않아 다시 누를 버튼이 없습니다. 커밋되기 전에 그려진 확인 화면에서 버튼을 다시 눌렀다면, 두 번째 placeOrder()는 이미 비워진 장바구니를 읽고 "장바구니가 비어있어 주문할 수 없습니다."로 거절됩니다. 이 예제에서는 장바구니를 비우는 동작이 같은 주문이 두 번 만들어지는 것을 어느 정도 막아 주지만, 이는 멱등성을 설계한 결과가 아닙니다. 사용자가 그 사이 같은 책을 다시 담았다면 두 번째 주문도 만들어질 수 있으므로, 운영에서는 주문 식별자로 같은 요청을 알아보는 멱등성 설계가 필요합니다.
3. 검색어 안의 정규식 문자
사용자가 검색창에 C++를 입력했습니다. escapedQuery의 이스케이프가 없다면 어떤 문제가 생길까요?
해설 보기
+는 정규식에서 앞 문자를 한 번 이상 반복한다는 뜻이고, MongoDB가 정규식에 쓰는 PCRE2에서는 수량자 뒤에 붙은 +가 소유 수량자로 해석되므로 C++도 오류 없는 정규식이 됩니다. 그래서 C++를 그대로 $regex에 넣으면 대소문자를 무시하는 이 조건에서 제목이나 저자에 c가 한 글자라도 있는 도서가 모두 찾아지고, 시드의 React 마스터도 결과에 나옵니다. 사용자가 일부러 .* 같은 패턴을 넣어 모든 도서와 일치시키거나 계산이 오래 걸리는 패턴을 보낼 수도 있습니다. 이 예제처럼 메타 문자 앞에 역슬래시(\)를 하나씩 붙여 이스케이프하면 C++를 글자 그대로 찾고, 길이를 80으로 제한해 지나치게 긴 입력도 막습니다.
Mongoose의 트랜잭션은 Mongoose 트랜잭션 문서에서, 검색 매개변수를 다루는 방법은 Next.js 공식 문서의 useSearchParams 레퍼런스에서 확인할 수 있습니다.
다음 글 전역 레이아웃 및 배포에서는 전역 레이아웃을 정리하고 프로젝트를 배포한 뒤, 학습 범위 안에서 무엇이 완성되었는지 확인합니다.