Claude Code로 Stripe Checkout 구현하기: Sessions, Webhook, 멱등성
Claude Code로 Stripe Checkout을 안전하게 구현하는 방법. hosted checkout, webhook, metadata, 멱등성, 환불 점검까지 다룹니다.
Stripe Checkout은 카드 입력 화면을 직접 만들지 않아도 되는 강력한 선택지입니다. 하지만 결제 화면을 Stripe에 맡긴다고 해서 주문 상태, 권한 부여, 환불 처리까지 자동으로 끝나는 것은 아닙니다. 운영 가능한 구현에는 Checkout Sessions API, hosted checkout 리다이렉트, webhook fulfillment, metadata.order_id, 멱등성, 테스트 모드 검증, 취소와 환불 확인이 모두 필요합니다.
Claude Code는 이 작업을 빠르게 만들 수 있지만, “결제 기능 만들어줘”처럼 넓게 요청하면 위험합니다. 주문 스키마, 환경 변수 검증, Checkout Route Handler, webhook 서명 검증, 멱등한 fulfillment, 테스트 체크리스트처럼 작은 단위로 맡겨야 리뷰 가능한 차이가 나옵니다.
이 글은 Next.js App Router와 Node TypeScript를 기준으로 합니다. 비밀키를 코드에 쓰지 않고, 내부 주문을 먼저 만든 뒤 Stripe Checkout Session을 생성하며, 결제 완료 후에는 하나의 서버 함수로 안전하게 권한을 부여합니다.
공식 흐름에서 확인할 점
Stripe의 Checkout Sessions API는 hosted Checkout, embedded Checkout, custom flow의 기반입니다. 처음 도입하는 팀은 hosted checkout부터 시작하는 편이 안전합니다. 결제 양식, 인증 단계, 오류 메시지, 결제 수단 처리를 Stripe가 맡기 때문입니다.
How Checkout works는 흐름을 명확히 설명합니다. 앱이 Checkout Session을 만들고, 사용자는 Stripe가 호스팅하는 페이지에서 결제하며, 이후 checkout.session.completed 같은 webhook 이벤트가 fulfillment를 트리거합니다. fulfillment는 결제 후 다운로드 권한, SaaS 플랜, 좌석, 배송, 예약을 실제로 제공하는 처리입니다.
Stripe의 Fulfill orders는 webhook이 필수이며 같은 Checkout Session으로 fulfillment 함수가 여러 번 호출될 수 있다고 설명합니다. Checkout Sessions API reference에서도 Session이 한 번의 결제 시도를 나타내며 성공 후 Customer, PaymentIntent 또는 Subscription을 참조한다고 나옵니다.
전체 결제 설계는 Claude Code 결제 시스템 통합, Claude Code 보안 모범 사례, Claude Code 환경 변수 관리와 함께 보면 좋습니다.
구현 구조
flowchart LR
Buyer["구매자"] --> App["Next.js 앱"]
App --> CheckoutRoute["/api/checkout"]
CheckoutRoute --> Orders["Order 테이블"]
CheckoutRoute --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Hosted Checkout"]
Hosted --> Webhook["/api/stripe/webhook"]
Webhook --> Fulfill["fulfillCheckout"]
Hosted --> Success["/checkout/success"]
Success --> Fulfill
Fulfill --> Orders
Checkout route는 내부 주문 생성 또는 재사용, Stripe Session 생성, session.url 반환까지만 담당합니다. Webhook route는 raw body로 Stripe 서명을 검증하고 결제 완료 이벤트를 fulfillment로 전달합니다. Success page도 같은 fulfillment 함수를 호출하지만, webhook을 대체하지는 않습니다.
환경 변수와 주문 테이블
npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init
로컬에서는 테스트 모드 값만 사용합니다. Claude Code 프롬프트에 실제 sk_test_, sk_live_, whsec_ 값을 붙여 넣지 마세요.
APP_URL=http://localhost:3000
DATABASE_URL=postgresql://user:password@localhost:5432/checkout_dev
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_EBOOK=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO=price_xxxxxxxxxxxxxxxxxxxxx
Checkout Session보다 내부 주문을 먼저 만듭니다. 그래야 client_reference_id와 metadata.order_id에 안정적인 주문 ID를 넣고, webhook과 환불 확인에서 같은 주문을 찾을 수 있습니다.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Order {
id String @id @default(cuid())
cartId String @unique
userId String
email String?
productKey String
quantity Int @default(1)
status OrderStatus @default(PENDING)
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique
stripeSubscriptionId String? @unique
amountTotal Int?
currency String?
fulfilledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs FulfillmentLog[]
}
model FulfillmentLog {
id String @id @default(cuid())
orderId String
stripeCheckoutSessionId String
event String
createdAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id])
@@unique([orderId, event])
@@index([stripeCheckoutSessionId])
}
enum OrderStatus {
PENDING
PAID
FULFILLED
CANCELED
REFUNDED
FAILED
}
서버 유틸리티
// src/lib/env.ts
import { z } from "zod";
const envSchema = z.object({
APP_URL: z.string().url(),
DATABASE_URL: z.string().min(1),
STRIPE_SECRET_KEY: z.string().regex(/^sk_(test|live)_/),
STRIPE_WEBHOOK_SECRET: z.string().regex(/^whsec_/),
STRIPE_PRICE_EBOOK: z.string().startsWith("price_"),
STRIPE_PRICE_PRO: z.string().startsWith("price_"),
});
export const env = envSchema.parse(process.env);
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// src/lib/stripe.ts
import Stripe from "stripe";
import { env } from "@/lib/env";
export const stripe = new Stripe(env.STRIPE_SECRET_KEY);
Checkout Session 생성
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { z } from "zod";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
const checkoutRequestSchema = z.object({
cartId: z.string().min(8),
userId: z.string().min(1),
email: z.string().email().optional(),
productKey: z.enum(["ebook", "pro"]),
});
const priceByProduct = {
ebook: env.STRIPE_PRICE_EBOOK,
pro: env.STRIPE_PRICE_PRO,
} as const;
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = checkoutRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid checkout request" }, { status: 400 });
}
const { cartId, userId, email, productKey } = parsed.data;
const mode: Stripe.Checkout.SessionCreateParams.Mode =
productKey === "pro" ? "subscription" : "payment";
const order = await prisma.order.upsert({
where: { cartId },
update: {},
create: { cartId, userId, email, productKey, quantity: 1, status: "PENDING" },
});
if (order.stripeCheckoutSessionId) {
const existing = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (existing.status === "open" && existing.url) {
return NextResponse.json({ url: existing.url, orderId: order.id });
}
return NextResponse.json(
{ error: "Checkout session is not open. Create a new cart." },
{ status: 409 },
);
}
const metadata = {
order_id: order.id,
cart_id: cartId,
product_key: productKey,
user_id: userId,
};
const session = await stripe.checkout.sessions.create(
{
mode,
line_items: [{ price: priceByProduct[productKey], quantity: 1 }],
customer_email: email,
success_url: `${env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/pricing?checkout=cancelled&order_id=${order.id}`,
client_reference_id: order.id,
metadata,
allow_promotion_codes: true,
payment_intent_data: mode === "payment" ? { metadata } : undefined,
subscription_data: mode === "subscription" ? { metadata } : undefined,
},
{ idempotencyKey: `checkout:${order.id}:v1` },
);
await prisma.order.update({
where: { id: order.id },
data: { stripeCheckoutSessionId: session.id },
});
return NextResponse.json({ url: session.url, orderId: order.id });
}
metadata에는 내부 ID만 넣습니다. 이메일, 주소, 카드 정보처럼 민감한 데이터는 넣지 않습니다. idempotencyKey는 Session 생성 재시도를 보호하지만, webhook 중복 실행은 별도로 DB에서 막아야 합니다.
멱등 fulfillment와 webhook
// src/lib/checkout-fulfillment.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
function stripeObjectId(
value: string | Stripe.PaymentIntent | Stripe.Subscription | null,
) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
export async function fulfillCheckout(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "payment_intent", "subscription"],
});
if (session.payment_status === "unpaid") {
return { status: "waiting_for_payment" as const, sessionId };
}
const orderId = session.metadata?.order_id ?? session.client_reference_id;
if (!orderId) throw new Error(`Checkout Session ${session.id} has no order_id`);
return prisma.$transaction(async (tx) => {
const updated = await tx.order.updateMany({
where: { id: orderId, fulfilledAt: null },
data: {
status: "FULFILLED",
stripeCheckoutSessionId: session.id,
stripePaymentIntentId: stripeObjectId(session.payment_intent),
stripeSubscriptionId: stripeObjectId(session.subscription),
amountTotal: session.amount_total,
currency: session.currency,
fulfilledAt: new Date(),
},
});
if (updated.count === 0) {
return { status: "already_fulfilled" as const, orderId };
}
await tx.fulfillmentLog.create({
data: { orderId, stripeCheckoutSessionId: session.id, event: "checkout.fulfilled" },
});
return { status: "fulfilled" as const, orderId };
});
}
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
import { fulfillCheckout } from "@/lib/checkout-fulfillment";
export const runtime = "nodejs";
function idFromStripeObject(value: string | { id: string } | null | undefined) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
async function markSession(sessionId: string, status: "CANCELED" | "FAILED") {
await prisma.order.updateMany({
where: { stripeCheckoutSessionId: sessionId, fulfilledAt: null },
data: { status },
});
}
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ error: `Webhook signature verification failed: ${message}` },
{ status: 400 },
);
}
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
await fulfillCheckout((event.data.object as Stripe.Checkout.Session).id);
break;
case "checkout.session.async_payment_failed":
await markSession((event.data.object as Stripe.Checkout.Session).id, "FAILED");
break;
case "checkout.session.expired":
await markSession((event.data.object as Stripe.Checkout.Session).id, "CANCELED");
break;
case "charge.refunded": {
const charge = event.data.object as Stripe.Charge;
const paymentIntentId = idFromStripeObject(charge.payment_intent);
if (paymentIntentId) {
await prisma.order.updateMany({
where: { stripePaymentIntentId: paymentIntentId },
data: { status: "REFUNDED" },
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await prisma.order.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: { status: "CANCELED" },
});
break;
}
}
return NextResponse.json({ received: true });
}
request.json()으로 먼저 읽으면 Stripe 서명 검증에 필요한 raw body가 깨집니다. Claude Code가 webhook 코드를 고칠 때 이 부분을 바꾸면 반드시 되돌려야 합니다.
테스트와 실패 복구
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
CLI가 출력한 whsec_ 값을 STRIPE_WEBHOOK_SECRET에 넣고 Next.js를 재시작합니다. 테스트 카드 4242 4242 4242 4242로 결제한 뒤 Stripe Dashboard만 보지 말고 DB를 확인합니다. Order.status가 FULFILLED인지, Stripe ID가 저장됐는지, 성공 페이지를 새로고침해도 로그가 하나만 남는지 봅니다.
실패 사례는 구체적입니다. 성공 페이지에서만 권한을 주면 사용자가 브라우저를 닫았을 때 미처리 주문이 생깁니다. order_id가 metadata에 없으면 결제와 주문을 연결할 수 없습니다. Stripe의 idempotencyKey만 믿으면 webhook 중복 실행으로 라이선스가 두 번 생길 수 있습니다. 테스트 키와 라이브 price ID를 섞으면 Checkout은 열리는데 webhook이 맞지 않는 상태가 됩니다. 환불과 구독 취소를 Dashboard에서만 처리하면 앱 권한이 남습니다.
| 사용 사례 | Checkout mode | fulfillment 작업 | 점검할 위험 |
|---|---|---|---|
| PDF 또는 템플릿 판매 | payment | 다운로드 권한 부여 | 성공 페이지에서만 파일 링크를 열지 않기 |
| SaaS Pro 플랜 | subscription | 플랜과 좌석 활성화 | 구독 삭제와 결제 실패 처리 |
| 교육 또는 상담 예약금 | payment | 예약 확정과 운영 알림 | 환불 시 예약 슬롯을 되돌릴지 결정 |
Claude Code review 요청은 이렇게 좁게 작성합니다.
Review only the Stripe Checkout implementation.
Check env-only secrets, metadata order_id without PII, raw-body webhook signature verification,
idempotent fulfillment, test-mode setup, refund and cancellation handling.
Return concrete file and line references.
실제로 시도할 때의 확인 포인트
테스트 모드 결제, webhook 전달 로그, DB 업데이트, 성공 페이지 새로고침, Session 만료, 환불, 테스트 구독 취소까지 확인하세요. 같은 Checkout Session이 여러 번 처리되어도 권한이 한 번만 부여된다면, 프로덕션 전 보안 리뷰로 넘어갈 준비가 된 것입니다.
ClaudeCodeLab은 Stripe Checkout 구현 리뷰, Claude Code 결제 개발 워크플로 교육, 주문 모델과 환불 규칙을 포함한 실장 지원을 도울 수 있습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.