Next.js로 쇼핑몰 만들기 #5 주문 완료 페이지와 배포
지난 시간에는 체크아웃 폼과 Server Action, 결제 시뮬레이션까지 묶어 카트가 주문이 되는 경로를 완성했습니다. 마지막 글에서는 주문 완료 페이지 로 사용자 흐름을 닫고, Vercel 에 배포 해 실제로 인터넷에 띄우고, 시리즈 전체와 “실서비스로 가려면 어디를 손봐야 하는가” 까지 정리하며 마무리하겠습니다.
/orders/[orderId] — 주문 완료 페이지
#
주문 직후 클라이언트가 router.push('/orders/${orderId}') 로 도착하는 페이지입니다. 주문 ID 로 메모리 저장소를 조회해 내용을 보여줍니다.
src/app/orders/[orderId]/page.js:
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getOrder } from '../../lib/orders';
import { formatPrice } from '../../lib/format';
export const metadata = {
title: '주문 완료',
description: '주문이 정상적으로 접수되었습니다.',
robots: { index: false, follow: false },
};
export default async function OrderConfirmationPage({ params }) {
const { orderId } = await params;
const order = getOrder(orderId);
if (!order) notFound();
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<section className="rounded-lg bg-green-50 p-6 text-center">
<h1 className="text-2xl font-bold text-green-800">주문이 완료되었습니다</h1>
<p className="mt-2 text-sm text-green-700">
주문 번호 <code className="font-mono">{order.id}</code>
</p>
</section>
<section className="mt-6">
<h2 className="text-lg font-semibold">주문 내역</h2>
<ul className="mt-2 divide-y border-y text-sm">
{order.items.map((it) => (
<li key={it.slug} className="flex justify-between py-2">
<span>{it.name} × {it.quantity}</span>
<span>{formatPrice(it.price * it.quantity)}</span>
</li>
))}
</ul>
<div className="mt-3 flex justify-between text-lg font-semibold">
<span>합계</span>
<span>{formatPrice(order.total)}</span>
</div>
</section>
<section className="mt-6">
<h2 className="text-lg font-semibold">배송 정보</h2>
<dl className="mt-2 grid grid-cols-3 gap-y-1 text-sm">
<dt className="text-gray-500">받는 분</dt>
<dd className="col-span-2">{order.shipping.name}</dd>
<dt className="text-gray-500">전화번호</dt>
<dd className="col-span-2">{order.shipping.phone}</dd>
<dt className="text-gray-500">주소</dt>
<dd className="col-span-2">{order.shipping.address}</dd>
</dl>
</section>
<div className="mt-8 flex gap-3">
<Link
href="/products"
className="rounded-md border px-4 py-2 text-sm"
>
쇼핑 계속하기
</Link>
<Link
href="/"
className="rounded-md bg-black px-4 py-2 text-sm text-white"
>
홈으로
</Link>
</div>
</main>
);
}설계 디테일:
robots: { index: false, follow: false }— 주문 완료 페이지는 검색 엔진이 인덱싱할 이유가 없습니다. 개인 정보(이름,전화,주소)가 포함되는 페이지가 검색에 노출될 가능성을 처음부터 차단합니다.- 잘못된 ID 는
notFound()— 다른 사용자의 주문 ID 를 추측해서 들어와도 그 주문이 메모리에 없으면(서버 재시작 등) 404 로 보냅니다. - 다음 행동 두 갈래 — “쇼핑 계속하기” 와 “홈으로”. 결제 직후 사용자가 막다른 골목에 갇히지 않게 다음 동선을 명시합니다.
주문 페이지 보안에 대한 짧은 코멘트 #
지금 구현은 주문 ID 만 알면 누구나 그 주문을 볼 수 있는 구조입니다. 학습용 데모에는 충분하지만 실서비스라면 다음 보강이 필요합니다.
- 주문 ID 를 길고 추측 불가능한 난수로 (현재는 timestamp + 6 문자라 일부 추측 가능)
- 비로그인 결제라면 주문 조회용 일회성 토큰을 별도 발급 (이메일 링크에 첨부)
- 로그인 사용자라면 본인 주문만 볼 수 있도록 세션과 매칭
마지막 섹션에서 다시 짚습니다.
사이트 metadata 정리 #
마지막으로 metadata 를 다듬어 SEO 기본을 챙겨둡니다. 블로그 시리즈의 #5 와 비슷한 패턴이지만 쇼핑몰 특성에 맞게 조정합니다.
src/app/layout.js (metadata 부분):
export const metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
title: {
default: '우리 쇼핑몰',
template: '%s | 우리 쇼핑몰',
},
description: '학습용 쇼핑몰 빌드 — 좋은 상품들을 만나보세요.',
openGraph: {
type: 'website',
locale: 'ko_KR',
siteName: '우리 쇼핑몰',
},
};상품 상세 페이지의 OG 도 product 타입으로 다듬으면 SNS 미리보기가 더 풍부해집니다.
src/app/products/[slug]/page.js (generateMetadata 보강):
export async function generateMetadata({ params }) {
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product) return {};
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
type: 'website',
images: [{ url: product.image }],
},
};
}본격적인 OG 이미지 자동 생성(ImageResponse) 까지는 다루지 않습니다. 정적 이미지를 OG 로 노출하는 수준만 챙기고, 더 정교한 OG 는 별도 주제로 남깁니다.
환경 변수와 배포 준비 #
배포 환경에서 도메인이 바뀌므로 사이트 URL 을 환경 변수로 분리합니다.
.env.local:
NEXT_PUBLIC_SITE_URL=http://localhost:3000Vercel 프로젝트의 Environment Variables 에는 실제 도메인을 등록합니다.
NEXT_PUBLIC_SITE_URL=https://your-shop.vercel.appmetadataBase 가 이 값을 기준으로 OG 이미지의 절대 URL 을 생성합니다. 개발/프리뷰/프로덕션이 자기 도메인을 자동으로 갖게 됩니다.
Vercel 배포 #
블로그 시리즈와 같은 흐름입니다.
- GitHub 리포지터리에 코드를 푸시
- vercel.com 에서 “New Project” → 리포지터리 선택
- Framework Preset: Next.js (자동 감지)
- Environment Variables 에
NEXT_PUBLIC_SITE_URL추가 - Deploy
빌드가 끝나면 https://your-shop.vercel.app 같은 도메인이 발급됩니다. 한 번 들어가서 카탈로그를 둘러보고, 카트에 담고, 결제까지 한 번 해 봅니다.
배포 후에 드러나는 메모리 저장소의 한계 #
배포해서 실제로 사용해 보면 학습 단계에서 안 보이던 문제가 두 가지 드러납니다.
1. 결제 후 주문 페이지에서 404 #
체크아웃까지는 성공했는데 /orders/[orderId] 가 404 를 띄우는 경우가 생깁니다. 원인은 Vercel 의 서버리스 함수 인스턴스 분리 입니다.
- 결제 요청(
checkoutAction) 은 인스턴스 A 에서 처리되어 주문이 A 의 메모리Map에 저장됨 - 직후 주문 조회(
/orders/[orderId]의 Server Component) 는 인스턴스 B 에서 실행됨 - B 의 메모리에는 그 주문이 없음 →
notFound()
메모리 저장소는 한 프로세스 안에서만 의미가 있습니다. 여러 인스턴스에 걸쳐 데이터를 공유하려면 그 데이터를 어딘가 외부에 두어야 합니다. DB 가 가장 표준적인 답입니다.
2. 재배포하면 모든 주문이 사라짐 #
새 배포가 올라가면 서버리스 함수가 새 인스턴스로 시작합니다. 모듈 스코프 Map 이 다시 비어 있는 상태에서 출발합니다. 과거 주문은 모두 증발합니다.
학습 데모로는 충분한 트레이드오프지만, “실서비스라면 어디서 막혀야 하는가” 를 정확히 보여주는 좋은 학습 포인트입니다.
실서비스로 가려면 어디를 손봐야 하는가 #
이번 시리즈가 일부러 단순화한 영역들을 다시 보겠습니다.
| 영역 | 학습용 구현 | 실서비스 권장 |
|---|---|---|
| 상품 저장소 | JSON 파일 | DB (PostgreSQL, MySQL, SQLite) 또는 Headless CMS |
| 주문 저장소 | 모듈 스코프 메모리 Map | DB. 단일 인스턴스 가정 불가 |
| 가격 검증 | 클라이언트가 보낸 가격 합산 | 서버가 상품 마스터에서 가격 재조회 후 합산 |
| 재고 관리 | 정적 stock 필드, 차감 없음 | 결제 시 트랜잭션 내 재고 차감 + 동시성 처리 |
| 결제 | 시뮬레이션 (Math.random) | Toss Payments / Stripe / Iamport 등 PG 연동 + 웹훅 |
| 인증 | 없음. 누구나 결제 | NextAuth.js / Clerk / Lucia 등 |
| 주문 조회 권한 | 주문 ID 만 알면 누구나 | 로그인 사용자만 본인 주문 / 비로그인은 일회성 토큰 |
| 주문 상태 | paid 고정 | pending / paid / preparing / shipped / delivered / canceled 상태 머신 |
| 이메일 알림 | 없음 | 결제/배송 단계마다 Resend / SendGrid 등으로 메일 |
| 관리자 페이지 | 없음 | 주문 목록 / 재고 관리 / 환불 처리 |
| 보안 | HTML5 required + 서버 가드 | + CSRF 토큰 / rate limit / input sanitization |
각 항목 하나하나가 단독 시리즈로 풀 만한 깊이를 갖습니다. 하지만 이 시리즈에서 만든 카탈로그 → 카트 → 체크아웃 → 주문 완료 흐름의 골격은 그대로 살아남습니다. 위 항목들은 그 골격에 차차 덧붙여질 내용입니다.
시리즈 회고 — Next.js로 쇼핑몰 만들기 #
이 시리즈에서 우리는 빈 프로젝트에서 시작해 흐름이 닫힌 데모 쇼핑몰까지 만들었습니다.
| # | 추가된 기능 | 등장한 핵심 패턴/도구 |
|---|---|---|
| 1 | 설계, 데이터 모델, 셋업 | App Router, JSON 시드, lib/ 분리 |
| 2 | 카탈로그 (목록 / 상세 / 필터) | Server Component, searchParams, generateStaticParams, notFound, next/image |
| 3 | 장바구니 | Client Context, localStorage 영속화, hydration 안전 패턴 |
| 4 | 체크아웃 + 결제 | Server Action, useActionState, useFormStatus, 결제 시뮬레이션 |
| 5 | 주문 완료 + 배포 | metadata API, robots, Vercel, 메모리 저장소의 한계 |
블로그 시리즈와 같은 5 편이지만 다루는 영역이 다릅니다. 블로그는 읽기와 댓글 이 중심이었고, 쇼핑몰은 사용자 상태(카트) → 트랜잭션(주문) 이 중심이었습니다. 두 빌드를 모두 따라오신 분은 읽기 중심 사이트 와 상태/트랜잭션 중심 앱 양쪽의 실전 패턴이 손에 잡혔을 것입니다.
다음으로 갈 만한 곳 #
이제 본인 프로젝트를 시작할 베이스가 더 단단해졌습니다. 다음 단계로 갈 만한 방향들:
이 쇼핑몰을 진짜로 만든다면 #
- DB 도입 — Prisma + PostgreSQL 또는 Drizzle + SQLite 부터.
products.js/orders.js의 호출 시그니처는 유지한 채 내부만 갈아끼우면 페이지 코드는 거의 손대지 않아도 됩니다 - 실제 결제 PG 연동 — Toss Payments(한국) 또는 Stripe(글로벌) 의 결제창 띄우기와 웹훅 수신
- 인증 도입 — NextAuth.js 로 이메일/소셜 로그인, 본인 주문 조회
- 관리자 페이지 — 주문 목록 / 상품 추가 / 재고 관리
다른 빌드 #
- 소셜 앱 — 게시판, 팔로우, 알림 — 실시간성과 다대다 관계
- 대시보드 — 차트, 필터, 데이터 시각화 — 외부 데이터 소스 연동
- 본인 도메인 도구 — 평소에 쓰고 싶었던 작은 도구. 가장 학습 효과가 큰 선택
마무리 #
여기까지 따라와주셔서 감사합니다. 작은 카탈로그 한 페이지에서 출발해 카트와 결제, 주문 완료까지 한 흐름으로 묶어보았습니다. 학습용 단순화가 곳곳에 있지만, 그 단순화가 막히는 지점이 곧 실서비스에서 어떤 도구가 필요해지는지 알려주는 신호입니다 — 메모리 저장소가 인스턴스 사이에서 분리되는 순간 DB 가 필요해지고, 가격을 클라이언트가 보내는 구조가 신경 쓰이는 순간 서버 측 검증 강화가 필요해집니다.
리액트 생태계는 빠르게 변하지만, 이 시리즈에서 다룬 “이 코드는 어디서 실행되나” 의 멘탈 모델은 변하지 않습니다. Server Component / Client Component / Server Action 의 분담은 한 번 손에 익히면 다른 도구로 갈아타도 같은 직관이 작동합니다. 그 위에 DB, 인증, 결제 PG 같은 도구가 차차 쌓일 뿐입니다. 본질을 알고 있으면 새 도구가 등장해도 빠르게 자기 자리를 찾습니다.