Next.js로 쇼핑몰 만들기 #5 주문 완료 페이지와 배포

7 분 소요

지난 시간에는 체크아웃 폼과 Server Action, 결제 시뮬레이션까지 묶어 카트가 주문이 되는 경로를 완성했습니다. 마지막 글에서는 주문 완료 페이지 로 사용자 흐름을 닫고, Vercel 에 배포 해 실제로 인터넷에 띄우고, 시리즈 전체와 “실서비스로 가려면 어디를 손봐야 하는가” 까지 정리하며 마무리하겠습니다.

/orders/[orderId] — 주문 완료 페이지 #

주문 직후 클라이언트가 router.push('/orders/${orderId}') 로 도착하는 페이지입니다. 주문 ID 로 메모리 저장소를 조회해 내용을 보여줍니다.

src/app/orders/[orderId]/page.js:

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 부분):

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 보강):

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:

.env.local
NEXT_PUBLIC_SITE_URL=http://localhost:3000

Vercel 프로젝트의 Environment Variables 에는 실제 도메인을 등록합니다.

Vercel 환경 변수
NEXT_PUBLIC_SITE_URL=https://your-shop.vercel.app

metadataBase 가 이 값을 기준으로 OG 이미지의 절대 URL 을 생성합니다. 개발/프리뷰/프로덕션이 자기 도메인을 자동으로 갖게 됩니다.

Vercel 배포 #

블로그 시리즈와 같은 흐름입니다.

  1. GitHub 리포지터리에 코드를 푸시
  2. vercel.com 에서 “New Project” → 리포지터리 선택
  3. Framework Preset: Next.js (자동 감지)
  4. Environment Variables 에 NEXT_PUBLIC_SITE_URL 추가
  5. 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
주문 저장소모듈 스코프 메모리 MapDB. 단일 인스턴스 가정 불가
가격 검증클라이언트가 보낸 가격 합산서버가 상품 마스터에서 가격 재조회 후 합산
재고 관리정적 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 같은 도구가 차차 쌓일 뿐입니다. 본질을 알고 있으면 새 도구가 등장해도 빠르게 자기 자리를 찾습니다.

X