Next.js로 쇼핑몰 만들기 #4 체크아웃과 결제 시뮬레이션
지난 시간에는 클라이언트 측에 영속 장바구니를 완성했습니다. 카트는 사용자의 브라우저 안에만 있고, 서버는 아직 그 존재를 모릅니다. 이번 글에서 그 경계가 처음으로 깨집니다 — 체크아웃 폼을 채워 결제 진행을 누르는 순간, 카트가 주문이 되어 서버로 넘어갑니다. Server Actions 가 그 다리 역할을 합니다.
핵심 흐름:
- 사용자가
/checkout에서 배송 정보를 입력 - “결제하기” 클릭 → Server Action 호출
- 서버가 결제 시뮬레이션 실행 (80% 성공 / 20% 실패)
- 성공이면 주문을 메모리 저장소에 저장하고 주문 ID 반환
- 클라이언트는 카트를 비우고
/orders/[orderId]로 이동 - 실패면 사용자에게 에러를 보여주고 재시도 가능하게
결제 시뮬레이션 함수 #
먼저 가짜 결제 게이트웨이를 만듭니다. 실서비스라면 Toss Payments 나 Stripe 같은 외부 PG 의 API 를 호출할 대목이지만, 학습 단계에서는 임의의 결과를 만들어 분기를 연습합니다.
src/app/lib/payments.js:
const SUCCESS_RATE = 0.8;
const NETWORK_DELAY_MS = 800;
export async function processPayment({ amount }) {
await new Promise((resolve) => setTimeout(resolve, NETWORK_DELAY_MS));
if (Math.random() > SUCCESS_RATE) {
return { ok: false, error: '결제가 거절되었습니다. 다시 시도해 주세요.' };
}
const transactionId = `tx_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
return { ok: true, transactionId, amount };
}NETWORK_DELAY_MS 는 의도된 지연입니다. 실제 PG 호출이 즉시 끝나지 않는다는 사실을 학습 코드에서도 느껴보기 위함입니다 — 폼의 로딩 상태가 화면에 보이려면 어느 정도 시간이 걸려야 합니다.
SUCCESS_RATE = 0.8 은 실패 케이스도 자주 만나기 위한 의도된 값입니다. 5 번 결제하면 평균 1 번은 실패합니다. 실패 UI 를 확실히 확인할 수 있습니다.
saveOrder 채우기 #
1편에서 윤곽만 잡았던 orders.js 를 마저 채웁니다. 주문 ID 생성과 저장이 들어갑니다.
src/app/lib/orders.js:
const orders = new Map();
export function createOrderId() {
return `ord_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
export function saveOrder(order) {
orders.set(order.id, order);
}
export function getOrder(orderId) {
return orders.get(orderId) ?? null;
}ID 형식이 결제 트랜잭션 ID 와 닮은 건 의도된 일관성입니다. 둘 다 시간 + 짧은 랜덤 문자열의 조합으로 사람이 읽기 부담스럽지 않으면서 충돌 가능성도 낮습니다.
저장소가 모듈 스코프 Map 이라는 점만 다시 강조해둡니다. 서버를 재시작하면 모든 주문이 사라집니다. 학습 흐름에는 충분하지만, 마지막 글에서 어떤 도구로 옮겨야 할지 짚어두겠습니다.
checkoutAction — Server Action #
폼이 호출할 서버 액션을 별도 파일로 만듭니다. Server Action 은 파일 맨 위에 'use server' 가 붙은 모듈의 export 함수입니다.
src/app/checkout/actions.js:
'use server';
import { createOrderId, saveOrder } from '../lib/orders';
import { processPayment } from '../lib/payments';
export async function checkoutAction(_prevState, formData) {
const name = String(formData.get('name') ?? '').trim();
const phone = String(formData.get('phone') ?? '').trim();
const address = String(formData.get('address') ?? '').trim();
const itemsJson = String(formData.get('items') ?? '[]');
if (!name || !phone || !address) {
return { ok: false, error: '배송 정보를 모두 입력해 주세요.' };
}
let items;
try {
items = JSON.parse(itemsJson);
} catch {
return { ok: false, error: '장바구니 데이터를 읽지 못했습니다.' };
}
if (!Array.isArray(items) || items.length === 0) {
return { ok: false, error: '장바구니가 비어 있습니다.' };
}
const total = items.reduce((sum, it) => sum + it.price * it.quantity, 0);
const payment = await processPayment({ amount: total });
if (!payment.ok) {
return { ok: false, error: payment.error };
}
const order = {
id: createOrderId(),
createdAt: new Date().toISOString(),
items,
total,
shipping: { name, phone, address },
status: 'paid',
};
saveOrder(order);
return { ok: true, orderId: order.id };
}설계 포인트:
useActionState시그니처 — 첫 인자가 이전 상태, 둘째가FormData. 반환값이 다음 상태가 됩니다.- 카트는 hidden field 로 전달 — 카트는 클라이언트 상태이므로 폼에
<input type="hidden" name="items" value={JSON} />로 직렬화해 함께 보냅니다. - 금액은 서버에서 다시 계산 — 클라이언트가 보낸 가격을 그대로 믿지 않습니다. 학습 단계에서는 같은 JSON 의 가격을 그대로 합산하지만, 실서비스라면 서버의 상품 마스터에서 가격을 다시 조회해 합산해야 합니다. 클라이언트가 보낸 가격을 신뢰하면 임의 가격으로 결제하는 취약점이 생깁니다.
- 검증을 서버에서도 다시 — 클라이언트가 입력 빈칸을 막아도, 서버는 그걸 다시 확인합니다. 폼이 직접 호출되는 경로(예: curl) 가 항상 존재합니다.
- redirect 는 클라이언트가 — 서버 액션에서
redirect()를 호출할 수도 있지만, 카트를 비우는 클라이언트 작업과 묶기 위해 redirect 는 클라이언트에서 처리합니다. 액션은 결과 객체만 반환합니다.
CheckoutForm — Client Component #
폼은 카트 상태(useCart)에 의존하므로 Client Component 입니다. useActionState 와 useFormStatus 로 액션 상태와 제출 중 상태를 다룹니다.
src/app/checkout/CheckoutForm.js:
'use client';
import { useActionState, useEffect } from 'react';
import { useFormStatus } from 'react-dom';
import { useRouter } from 'next/navigation';
import { useCart } from '../components/CartProvider';
import { formatPrice } from '../lib/format';
import { checkoutAction } from './actions';
const INITIAL_STATE = { ok: false, error: null, orderId: null };
export default function CheckoutForm() {
const { items, totalPrice, clear } = useCart();
const router = useRouter();
const [state, formAction] = useActionState(checkoutAction, INITIAL_STATE);
useEffect(() => {
if (state.ok && state.orderId) {
clear();
router.push(`/orders/${state.orderId}`);
}
}, [state, clear, router]);
return (
<form action={formAction} className="space-y-6">
<input type="hidden" name="items" value={JSON.stringify(items)} />
<section>
<h2 className="text-lg font-semibold">배송 정보</h2>
<div className="mt-4 space-y-3">
<LabeledInput name="name" label="이름" autoComplete="name" />
<LabeledInput name="phone" label="전화번호" autoComplete="tel" type="tel" />
<LabeledInput name="address" label="주소" autoComplete="street-address" />
</div>
</section>
<section>
<h2 className="text-lg font-semibold">주문 요약</h2>
<ul className="mt-2 divide-y border-y text-sm">
{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(totalPrice)}</span>
</div>
</section>
{state.error && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{state.error}
</div>
)}
<SubmitButton />
</form>
);
}
function LabeledInput({ name, label, type = 'text', autoComplete }) {
return (
<label className="block">
<span className="block text-sm text-gray-700">{label}</span>
<input
name={name}
type={type}
autoComplete={autoComplete}
required
className="mt-1 w-full rounded border border-gray-300 px-3 py-2"
/>
</label>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full rounded-md bg-black px-4 py-3 text-white disabled:bg-gray-400"
>
{pending ? '결제 처리 중…' : '결제하기'}
</button>
);
}세 가지 포인트가 핵심입니다.
1. useActionState
#
폼이 액션과 묶입니다. formAction 을 <form action={...}> 에 넘기면 폼 제출이 액션 호출로 이어집니다. 액션이 반환한 상태(state)는 다음 렌더에 자동으로 반영됩니다.
2. useFormStatus
#
SubmitButton 이 폼 안에 있을 때, useFormStatus() 의 pending 이 제출 진행 중 여부를 알려줍니다. 별도 상태 변수를 만들 필요가 없어 코드가 깔끔합니다.
useFormStatus 는 반드시 폼의 자식 컴포넌트 안에서 호출해야 합니다. 같은 컴포넌트에서 호출하면 항상 false 가 반환됩니다. 그래서 SubmitButton 을 별도 컴포넌트로 분리했습니다.
3. 성공 후 처리는 useEffect
#
서버 액션이 성공 응답을 보내면 state.ok 가 true 가 됩니다. 그 변화를 effect 로 감지해 카트를 비우고 주문 완료 페이지로 이동합니다.
router.push 가 effect 안에서 실행되는 게 어색해 보일 수 있지만, 이게 가장 자연스러운 위치입니다. 서버 액션 안에서 redirect() 를 호출하면 클라이언트의 clear() 를 호출할 기회가 사라집니다. 반대로 effect 에서 둘을 묶으면 한 트랜잭션처럼 처리됩니다.
/checkout 페이지
#
페이지 자체는 얇은 래퍼입니다.
src/app/checkout/page.js:
import CheckoutForm from './CheckoutForm';
export const metadata = {
title: '결제',
description: '배송 정보를 입력하고 결제를 진행합니다.',
};
export default function CheckoutPage() {
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<h1 className="text-2xl font-bold">결제</h1>
<div className="mt-6">
<CheckoutForm />
</div>
</main>
);
}페이지를 Server Component 로 두고 폼만 Client Component 로 분리합니다. 페이지의 메타데이터 와 정적 마크업은 서버에서 그려지고, 폼만 클라이언트로 hydration 됩니다. 익숙한 분리 패턴입니다.
동작 확인 #
정상 흐름 #
- 상품을 카트에 담는다
- 헤더 “장바구니” → “결제 진행” →
/checkout으로 이동 - 이름/전화번호/주소 입력 후 “결제하기”
- 버튼이 “결제 처리 중…” 으로 바뀌며 약 0.8 초 대기
- 운이 좋으면 (80%)
/orders/[orderId]로 이동. 카트는 비워짐 - 새로고침해도 카트는 비어 있음 (localStorage 갱신됨)
실패 흐름 #
운이 나쁘면 (20%) 폼 아래에 빨간 에러 박스가 나타납니다. 카트와 입력값은 그대로 남아 있어 “결제하기” 를 다시 누르기만 하면 재시도됩니다. 입력을 다시 칠 필요 없고, 카트도 비우지 않습니다 — 사용자에게 가장 부드러운 실패 경험입니다.
빈칸 검증 #
이름을 비우고 결제 시도 → HTML5 required 가 막아 줍니다. 클라이언트 가드가 통과돼 서버까지 가더라도 서버 액션의 빈칸 체크가 다시 잡아 줍니다.
빈 카트로 직접 체크아웃 시도 #
/cart 를 거치지 않고 /checkout URL 을 직접 열 수 있습니다. 카트가 비어 있는 채로 “결제하기” 를 누르면 서버 액션이 “장바구니가 비어 있습니다” 에러로 막습니다. 클라이언트에서 items.length === 0 일 때 “결제하기” 버튼을 비활성화해도 좋지만, 서버 측 가드만 있어도 데이터 정합성에는 충분합니다.
마무리 #
이번 글에서는 카트가 처음으로 서버를 만나는 경계를 만들었습니다.
- 결제 시뮬레이션 함수로 외부 PG 의존을 학습 단계에서 분리했다
- 주문 ID 발급과 메모리 저장을 채웠다
- Server Action 으로 검증,결제,저장을 한 흐름에 묶었다
useActionState와useFormStatus로 폼 상태와 진행 중 상태를 다뤘다- 성공/실패 분기를 사용자 입장에서 자연스럽게 처리했다 (재시도 친화)
- 클라이언트 가드와 서버 가드를 둘 다 두었다 (데이터 정합성)
다음 글이 마지막입니다. “Next.js로 쇼핑몰 만들기 #5 주문 완료 페이지와 배포” 에서는 주문 조회 페이지를 만들고, SEO 메타데이터를 다듬고, Vercel 에 배포해 실제로 인터넷에 띄웁니다. 시리즈 회고와 “실서비스로 가려면 어디를 손봐야 하는가” 까지 정리하며 마무리하겠습니다.