Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 Stripe 결제 구현하기: Checkout, Webhook, 환불, 세금 인계

Claude Code로 소규모 SaaS 결제를 구현합니다. 가격표, Checkout, Webhook, 멱등성, 환불, 세금 인계를 다룹니다.

Claude Code로 Stripe 결제 구현하기: Checkout, Webhook, 환불, 세금 인계

작은 SaaS나 유료 콘텐츠 제품에서 결제를 붙인다는 것은 버튼 하나를 추가하는 일이 아닙니다. 가격표, Checkout Session API, Webhook 서명 검증, 중복 실행에 안전한 fulfillment, 환불 후 권한 회수, 세금과 회계 인계까지 한 흐름으로 연결해야 합니다. 그렇지 않으면 Stripe에는 매출이 있는데 앱에서는 권한이 열리지 않거나, 환불했는데 유료 기능은 그대로 남는 문제가 생깁니다.

Claude Code는 이 작업을 빠르게 만들 수 있습니다. 다만 “Stripe 결제 만들어줘”처럼 넓게 지시하면 성공 페이지에서 바로 권한을 부여하거나 Webhook 검증을 생략한 코드가 나올 수 있습니다. 결제 코드는 매출, 개인정보, 운영 정책과 연결되므로 Claude Code에는 구현자이자 리뷰어 역할을 맡겨야 합니다.

이 글은 Next.js App Router, Node.js, TypeScript, Stripe Checkout을 기준으로 합니다. Webhook은 Stripe가 내 서버에 보내는 결제 이벤트 알림입니다. fulfillment는 결제 후 사용자에게 다운로드, SaaS 플랜, 좌석, 예약 같은 실제 가치를 제공하는 처리입니다. 멱등성은 같은 처리가 두 번 실행되어도 결과가 한 번 처리된 것처럼 유지되는 성질입니다.

공식 문서로 확인할 것

결제 서비스별 세부 사항은 공식 문서를 기준으로 확인해야 합니다. Checkout Session 생성은 Checkout Session create API, 결제 후 제공 절차는 Checkout fulfillment, Webhook 수신과 서명 검증은 Receive Stripe events, 재시도 안전성은 Idempotent requests를 참고합니다.

환불은 Create a refund, 세금 처리는 Stripe Tax for Checkout, 로컬 검증은 Stripe CLI를 봅니다. Next.js API 라우트는 Route Handlers, Claude Code 자체는 Claude Code overview를 기준으로 삼습니다.

Paddle, Lemon Squeezy, PayPal 같은 다른 결제 서비스로 바꾸더라도 원칙은 같습니다. 가격은 서버가 결정하고, 결제 완료는 Webhook으로 확정하며, 같은 이벤트가 두 번 와도 권한이 두 번 늘지 않아야 하고, 환불과 앱 권한 상태가 함께 움직여야 합니다.

수익화 유스케이스

유스케이스과금 방식fulfillment실패 모드
프롬프트 팩, PDF 강의, 템플릿 번들일회성 결제다운로드 권한을 부여하고 구매 메일을 보냄성공 페이지가 유일한 전달 경로이면 브라우저를 닫은 사용자가 접근하지 못함
작은 SaaS Pro 플랜월 구독plan:pro 권한을 부여하고 이후 청구 이벤트로 상태 갱신해지나 결제 실패 이벤트를 무시하면 유료 기능이 계속 열림
Claude Code 워크숍 예약금일회성 결제 또는 인보이스 운영예약 슬롯을 잡고 운영자에게 알림환불 시 예약 슬롯을 되돌릴지 정책이 불분명해짐

Masa가 작은 콘텐츠 제품을 시험했을 때 처음 실패한 지점은 Stripe API가 아니라 성공 페이지였습니다. 테스트 카드로는 통과했지만 Webhook 재전송, 브라우저 종료, 지연 결제, 환불을 고려하면 위험했습니다. Claude Code 지시를 “성공 페이지는 상태만 보여주고, 권한 부여는 Webhook 기반 fulfillment 함수만 한다”로 바꾸자 리뷰 가능한 구조가 됐습니다.

아키텍처

flowchart LR
  Buyer["구매자"] --> Pricing["가격표"]
  Pricing --> CheckoutApi["/api/checkout"]
  CheckoutApi --> Order["Order 테이블"]
  CheckoutApi --> Stripe["Stripe Checkout Session"]
  Stripe --> Hosted["Stripe hosted checkout"]
  Hosted --> Webhook["/api/webhooks/stripe"]
  Webhook --> Fulfill["fulfillCheckoutSession"]
  Fulfill --> Entitlement["Entitlement 테이블"]
  Hosted --> Success["성공 페이지"]
  Success --> ReadOnly["주문 상태 표시"]

성공 페이지는 권한을 여는 장소가 아니라 확인 화면입니다. 실제 권한 변경은 Webhook에서 일어납니다. Stripe 서명을 검증하고 이벤트 ID를 기록한 뒤, 결제가 완료된 Checkout Session만 하나의 fulfillment 함수로 넘깁니다.

Claude Code에는 이렇게 지시합니다.

Implement Stripe Checkout in a Next.js App Router app.
Do not grant access from the success page alone.
Verify the Stripe webhook with the raw request body.
Make fulfillment safe if the same Checkout Session is processed twice.
Do not print secrets or read .env values into chat.
Before editing, explain the target files and design.

상품표와 환경 변수

브라우저는 productKey만 보냅니다. 실제 Price ID, 과금 모드, 권한 키는 서버의 상품표에서 결정합니다.

// src/lib/billing/catalog.ts
export const BILLING_PRODUCTS = {
  promptPack: {
    label: "Claude Code Prompt Pack",
    mode: "payment",
    entitlement: "download:prompt-pack",
    priceEnv: "STRIPE_PRICE_PROMPT_PACK",
  },
  proMonthly: {
    label: "SaaS Pro Monthly",
    mode: "subscription",
    entitlement: "plan:pro",
    priceEnv: "STRIPE_PRICE_PRO_MONTHLY",
  },
  workshopDeposit: {
    label: "Claude Code Workshop Deposit",
    mode: "payment",
    entitlement: "booking:workshop-deposit",
    priceEnv: "STRIPE_PRICE_WORKSHOP_DEPOSIT",
  },
} as const;

export type ProductKey = keyof typeof BILLING_PRODUCTS;

export function getBillingProduct(productKey: string) {
  if (productKey in BILLING_PRODUCTS) {
    return {
      key: productKey as ProductKey,
      ...BILLING_PRODUCTS[productKey as ProductKey],
    };
  }

  throw new Error(`Unknown productKey: ${productKey}`);
}

export function getPriceId(priceEnv: string) {
  const priceId = process.env[priceEnv];
  if (!priceId) throw new Error(`Missing environment variable: ${priceEnv}`);
  return priceId;
}

로컬에서는 테스트 모드 값만 사용합니다. 비밀 키를 Claude Code 프롬프트에 붙여 넣지 마세요.

APP_URL=http://localhost:3000
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PROMPT_PACK=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO_MONTHLY=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_WORKSHOP_DEPOSIT=price_xxxxxxxxxxxxxxxxx

멱등성을 위한 데이터 모델

운영 결제 상태는 데이터베이스에 저장해야 합니다. StripeEvent.id는 같은 Webhook 이벤트의 중복 처리를 막고, Entitlement는 앱이 실제로 조회하는 권한입니다.

// prisma/schema.prisma
model Order {
  id                      String    @id @default(cuid())
  userId                  String
  productKey              String
  entitlement             String
  status                  String    @default("PENDING")
  stripeCheckoutSessionId String?   @unique
  stripePaymentIntentId   String?
  stripeSubscriptionId    String?
  stripeRefundId          String?
  fulfilledAt             DateTime?
  createdAt               DateTime  @default(now())
  updatedAt               DateTime  @updatedAt
}

model Entitlement {
  id        String   @id @default(cuid())
  userId    String
  key       String
  active    Boolean  @default(true)
  source    String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@unique([userId, key])
}

model StripeEvent {
  id        String   @id
  type      String
  createdAt DateTime @default(now())
}
npx prisma db push

Checkout API

Stripe SDK와 Webhook 검증은 Node.js runtime에서 실행합니다. API 라우트는 인증, 내부 주문 생성, Checkout Session 생성, Session ID 저장, URL 반환에만 집중합니다.

// src/lib/billing/stripe.ts
import Stripe from "stripe";

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error("STRIPE_SECRET_KEY is required");
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export function appUrl(path: string) {
  const baseUrl = process.env.APP_URL ?? "http://localhost:3000";
  return `${baseUrl.replace(/\/$/, "")}${path}`;
}
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getBillingProduct, getPriceId } from "@/lib/billing/catalog";
import { appUrl, stripe } from "@/lib/billing/stripe";
import { requireUser } from "@/lib/auth";

export const runtime = "nodejs";

const checkoutSchema = z.object({
  productKey: z.string().min(1),
});

export async function POST(req: NextRequest) {
  const user = await requireUser();
  const { productKey } = checkoutSchema.parse(await req.json());
  const product = getBillingProduct(productKey);

  const order = await prisma.order.create({
    data: {
      userId: user.id,
      productKey: product.key,
      entitlement: product.entitlement,
    },
  });

  const session = await stripe.checkout.sessions.create(
    {
      mode: product.mode,
      customer_email: user.email,
      line_items: [{ price: getPriceId(product.priceEnv), quantity: 1 }],
      client_reference_id: order.id,
      metadata: {
        order_id: order.id,
        product_key: product.key,
      },
      automatic_tax: { enabled: true },
      tax_id_collection: { enabled: true },
      success_url: appUrl("/checkout/success?session_id={CHECKOUT_SESSION_ID}"),
      cancel_url: appUrl("/pricing"),
    },
    {
      idempotencyKey: `checkout:${order.id}`,
    }
  );

  await prisma.order.update({
    where: { id: order.id },
    data: { stripeCheckoutSessionId: session.id },
  });

  if (!session.url) {
    return NextResponse.json({ error: "Checkout URL was not created" }, { status: 500 });
  }

  return NextResponse.json({ url: session.url });
}

automatic_taxtax_id_collection은 세금 관련 정보를 수집하고 회계로 넘기기 쉽게 해 주는 설정입니다. 세금 신고, 인보이스 문구, 등록 의무까지 자동으로 해결한다는 뜻은 아닙니다.

가격표 컴포넌트

UI는 금액이나 Price ID를 보내지 않습니다. 표시 가격은 안내 문구이고, 실제 결제 금액은 Stripe Price에서 결정됩니다.

// src/components/PricingTable.tsx
"use client";

import { useState } from "react";

const cards = [
  {
    productKey: "promptPack",
    name: "Prompt Pack",
    price: "₩49,000",
    description: "템플릿과 학습 자료 일회성 결제",
  },
  {
    productKey: "proMonthly",
    name: "Pro",
    price: "₩39,000/월",
    description: "작은 SaaS를 위한 월간 플랜",
  },
  {
    productKey: "workshopDeposit",
    name: "Workshop Deposit",
    price: "₩150,000",
    description: "교육 또는 컨설팅 예약금",
  },
];

export function PricingTable() {
  const [loadingKey, setLoadingKey] = useState<string | null>(null);

  async function startCheckout(productKey: string) {
    setLoadingKey(productKey);
    const res = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ productKey }),
    });

    const data = await res.json();
    if (!res.ok) {
      setLoadingKey(null);
      alert(data.error ?? "Checkout failed");
      return;
    }

    window.location.assign(data.url);
  }

  return (
    <div className="grid gap-4 md:grid-cols-3">
      {cards.map((card) => (
        <section key={card.productKey} className="rounded-lg border p-5">
          <h3 className="text-lg font-semibold">{card.name}</h3>
          <p className="mt-2 text-2xl font-bold">{card.price}</p>
          <p className="mt-2 text-sm text-gray-600">{card.description}</p>
          <button
            className="mt-4 rounded bg-black px-4 py-2 text-white disabled:opacity-60"
            disabled={loadingKey === card.productKey}
            onClick={() => startCheckout(card.productKey)}
          >
            {loadingKey === card.productKey ? "이동 중..." : "Checkout으로 이동"}
          </button>
        </section>
      ))}
    </div>
  );
}

버튼 비활성화는 UX 보조 장치입니다. 결제 안전성은 서버 상품표, Stripe idempotency key, Webhook 이벤트 ID, DB 유니크 제약으로 확보합니다.

Webhook 검증과 fulfillment

검증 전에 await req.json()을 호출하지 마세요. Stripe 서명 검증에는 변형되지 않은 raw body가 필요합니다.

// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { fulfillCheckoutSession } from "@/lib/billing/fulfillment";

export const runtime = "nodejs";

export async function POST(req: NextRequest) {
  const payload = await req.text();
  const signature = req.headers.get("stripe-signature");

  if (!signature || !process.env.STRIPE_WEBHOOK_SECRET) {
    return NextResponse.json({ error: "Missing webhook signature" }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      payload,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  try {
    await prisma.stripeEvent.create({ data: { id: event.id, type: event.type } });
  } catch {
    return NextResponse.json({ received: true, duplicate: true });
  }

  if (
    event.type === "checkout.session.completed" ||
    event.type === "checkout.session.async_payment_succeeded"
  ) {
    const session = event.data.object as Stripe.Checkout.Session;
    await fulfillCheckoutSession(session.id);
  }

  if (event.type === "checkout.session.async_payment_failed") {
    const session = event.data.object as Stripe.Checkout.Session;
    await prisma.order.updateMany({
      where: { stripeCheckoutSessionId: session.id, status: "PENDING" },
      data: { status: "CANCELED" },
    });
  }

  return NextResponse.json({ received: true });
}
// src/lib/billing/fulfillment.ts
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";

function stripeObjectId(value: string | { id: string } | null | undefined) {
  if (!value) return undefined;
  return typeof value === "string" ? value : value.id;
}

export async function fulfillCheckoutSession(sessionId: string) {
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ["payment_intent", "subscription"],
  });

  if (session.payment_status !== "paid") {
    return { status: "waiting_for_payment" as const };
  }

  const orderId = session.metadata?.order_id;
  if (!orderId) throw new Error(`Missing order_id metadata for ${session.id}`);

  return prisma.$transaction(async (tx) => {
    const order = await tx.order.findUnique({ where: { id: orderId } });
    if (!order) throw new Error(`Order not found: ${orderId}`);

    if (order.status === "FULFILLED") {
      return { status: "already_fulfilled" as const, order };
    }

    const updatedOrder = await tx.order.update({
      where: { id: order.id },
      data: {
        status: "FULFILLED",
        stripeCheckoutSessionId: session.id,
        stripePaymentIntentId: stripeObjectId(session.payment_intent),
        stripeSubscriptionId: stripeObjectId(session.subscription),
        fulfilledAt: new Date(),
      },
    });

    await tx.entitlement.upsert({
      where: {
        userId_key: {
          userId: order.userId,
          key: order.entitlement,
        },
      },
      update: { active: true, source: `stripe:${session.id}` },
      create: {
        userId: order.userId,
        key: order.entitlement,
        active: true,
        source: `stripe:${session.id}`,
      },
    });

    return { status: "fulfilled" as const, order: updatedOrder };
  });
}

핵심은 권한 부여 지점을 하나로 유지하는 것입니다. Webhook, 복구 작업, 상태 확인 페이지가 같은 함수를 호출해도 결과가 안정적이어야 합니다.

환불과 권한 회수

환불은 Stripe API 호출만으로 끝나지 않습니다. 접근권을 회수할지, 부분 환불은 어떻게 처리할지, 예약 슬롯을 되돌릴지 정책이 필요합니다.

// src/app/api/admin/refunds/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { requireAdmin } from "@/lib/auth";

export const runtime = "nodejs";

const refundSchema = z.object({
  orderId: z.string().min(1),
  amount: z.number().int().positive().optional(),
});

export async function POST(req: NextRequest) {
  await requireAdmin();
  const { orderId, amount } = refundSchema.parse(await req.json());

  const order = await prisma.order.findUnique({ where: { id: orderId } });
  if (!order?.stripePaymentIntentId) {
    return NextResponse.json({ error: "Refundable payment not found" }, { status: 404 });
  }

  const refund = await stripe.refunds.create(
    {
      payment_intent: order.stripePaymentIntentId,
      amount,
      metadata: { order_id: order.id },
    },
    {
      idempotencyKey: `refund:${order.id}:${amount ?? "full"}`,
    }
  );

  await prisma.$transaction([
    prisma.order.update({
      where: { id: order.id },
      data: { status: "REFUNDED", stripeRefundId: refund.id },
    }),
    prisma.entitlement.updateMany({
      where: { userId: order.userId, key: order.entitlement },
      data: { active: false },
    }),
  ]);

  return NextResponse.json({ refundId: refund.id, status: refund.status });
}

환불 정책이 모호하면 Claude Code가 임의로 결정하지 않게 하세요. “부분 환불은 권한 유지”인지 “전액 환불은 권한 회수”인지는 제품 정책입니다.

로컬 테스트

Stripe CLI로 이벤트를 로컬 라우트에 전달합니다.

npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe

표시된 whsec_...STRIPE_WEBHOOK_SECRET에 넣고 Next.js를 재시작합니다. 기본 성공 경로는 Stripe 테스트 카드 4242 4242 4242 4242, 미래 만료일, 임의의 CVC, 임의의 우편번호로 확인할 수 있습니다. 실패 카드와 인증 흐름은 Stripe Testing을 참고하세요.

확인할 것은 네 가지입니다. Checkout API가 내부 주문을 만들었는지, Webhook에서 checkout.session.completed가 왔는지, 주문이 FULFILLED가 되고 Entitlement가 한 건만 생겼는지, 같은 이벤트를 다시 처리해도 권한이 늘지 않는지입니다.

Claude Code 리뷰 프롬프트

구현 후에는 수정이 아니라 리뷰를 요청합니다.

Review this payment diff critically.
Check these risks:
- Access is not granted from the success page alone.
- The webhook verifies the raw body before JSON parsing.
- Stripe Event ID and Checkout Session ID prevent duplicate processing.
- The browser cannot choose price IDs or amounts.
- Metadata does not contain email, address, card data, or other sensitive data.
- Refunds keep Order and Entitlement state consistent.
- Tax, receipts, and cancellation policy are handed off to operations.

Claude Code는 구현을 도울 수 있지만 결제 정책의 최종 책임자는 아닙니다. API 키, Dashboard 설정, 세무 등록, 약관, 환불 정책, 개인정보 로그는 사람이 확인해야 합니다.

정리와 CTA

결제 통합은 버튼이 아니라 매출 생명주기에서 설계해야 합니다. 가격표는 productKey만 보내고, 서버가 Checkout Session을 만들며, Webhook은 서명을 검증하고, 하나의 멱등 fulfillment 함수가 권한을 부여합니다. 환불과 세금 인계는 나중에 덧붙일 일이 아니라 첫 설계에 포함해야 합니다.

더 구체적인 Checkout 구현은 Claude Code Stripe Checkout 가이드를 참고하세요. 비밀값 관리는 Claude Code 환경 변수 관리, 이벤트 처리는 Claude Code Webhook 구현과 함께 보면 좋습니다.

ClaudeCodeLab은 결제 구현 리뷰, Claude Code 팀 교육, SaaS와 콘텐츠 제품의 수익화 흐름 설계를 지원합니다. 한국어 페이지가 없어도 구현 상담은 English consulting page에서 시작할 수 있고, 템플릿과 체크리스트는 product library에 정리되어 있습니다.

이 흐름은 로컬 Next.js 프로젝트에서 Stripe 테스트 모드, Webhook 전달, 중복 이벤트 처리, 전액 환불로 확인했습니다. 가장 큰 효과는 UI보다 먼저 OrderEntitlement를 모델링한 점이었습니다. 상태 모델이 명확해지면 Claude Code의 변경도 사람의 리뷰도 훨씬 신뢰하기 쉬워집니다.

#Claude Code #Stripe #결제 #Webhook #TypeScript
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.