Claude Code로 Stripe 구독 결제 구현하기
Claude Code로 Stripe 구독을 구현합니다. Checkout, Portal, Webhook, 권한 테이블, SaaS 수익화까지 다룹니다.
구독 결제는 버튼이 아니라 운영 흐름이다
Stripe Checkout으로 구독 결제 화면을 여는 일은 어렵지 않습니다. 하지만 실제 SaaS, 유료 콘텐츠, 템플릿 라이브러리, 컨설팅 상품을 운영하려면 그 뒤가 더 중요합니다. 요금제 변경, 결제 실패, 해지 예약, 인보이스, 고객 포털, Webhook 재시도, 권한 회수, 로그 추적까지 설계해야 반복 매출이 안정됩니다.
Claude Code는 단순히 결제 코드를 대신 쓰는 도구로 쓰기보다, 구현의 발판으로 쓰는 편이 좋습니다. 여기서 발판이란 요구사항, 공식 문서, DB 설계, 테스트 명령, 리뷰 기준을 한곳에 정리해 Claude Code가 잘못된 가정을 하지 않게 만드는 구조입니다.
원칙은 단순합니다. Stripe는 과금과 구독 상태의 기준이고, 애플리케이션 DB는 사용자가 지금 어떤 기능을 사용할 수 있는지 판단하는 동기화된 상태입니다. active와 trialing은 권한 부여, past_due는 짧은 유예, unpaid와 canceled는 유료 권한 정지처럼 명확한 표로 관리합니다.
flowchart LR
A["Pricing page"] --> B["Checkout Session<br/>mode=subscription"]
B --> C["Stripe subscription"]
C --> D["Signed webhook"]
D --> E["billing_subscriptions"]
D --> F["entitlements"]
F --> G["Feature gate"]
G --> H["SaaS, course, template library"]
H --> I["Customer Portal"]
I --> C
가격과 권한을 먼저 정한다
Claude Code에 바로 “Stripe 구독을 붙여줘”라고 요청하면 동작하는 예시는 나오지만, 수익 모델은 흐릿하게 남습니다. 먼저 요금제와 권한을 정리합니다.
| 요금제 | 대상 | 월 과금 예시 | 연 과금 예시 | 권한 |
|---|---|---|---|---|
| Free | 콘텐츠를 시험해보는 방문자 | $0 | $0 | 미리보기, 샘플 다운로드 |
| Pro | 개인 개발자, 템플릿 구매자 | $19 | $190 | 전체 글, 템플릿 다운로드, 강의 라이브러리 |
| Studio | 팀, 법인, 반복 컨설팅 고객 | $99 | $990 | Pro 권한, 팀 좌석, 리뷰 요청, 교육 자료 |
구체적인 사용 사례를 세 가지 이상 두면 빠진 요구사항이 보입니다. 첫째, 미니 SaaS에서는 대시보드, 내보내기, 자동화를 유료 플랜에 묶습니다. 둘째, 정보 상품 퍼널에서는 무료 글에서 템플릿, 월간 멤버십, 상담으로 이어집니다. 셋째, 교육 및 컨설팅에서는 팀 좌석, 청구서, 취소 사유, 결제 실패 안내가 중요합니다.
entitlement는 기능 사용권입니다. templates:download가 있으면 템플릿 다운로드를 허용하고, team:seats가 있으면 팀 좌석 기능을 허용합니다. dunning은 결제 실패 후 복구 흐름입니다. 카드 실패, 추가 인증 미완료, 인보이스 미납 상황에서 고객에게 결제 수단 업데이트를 안내합니다. observability는 어떤 이벤트가 어떤 사용자의 권한을 바꿨는지 추적할 수 있는 상태입니다.
Claude Code 프롬프트에는 공식 문서를 포함한다
Stripe 관련 주장은 최신 공식 문서를 기준으로 둡니다. Claude Code에는 다음처럼 요청합니다.
Next.js App Router 앱에 Stripe Billing 구독을 구현하세요.
다음 공식 문서를 기준으로 하세요.
- Checkout Sessions API: https://docs.stripe.com/api/checkout/sessions/create
- Customer Portal: https://docs.stripe.com/customer-management
- Subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Webhooks and local testing: https://docs.stripe.com/webhooks
- Subscription object and statuses: https://docs.stripe.com/api/subscriptions/object
요구사항:
- TypeScript, Next.js App Router, Postgres
- Checkout은 mode="subscription" 사용
- Customer Portal에서 결제 수단, 인보이스, 요금제 변경, 해지를 처리
- Webhook은 Stripe 서명을 검증하고 event_id로 중복 처리 방지
- 애플리케이션 권한은 entitlements 테이블로 판단
- active/trialing은 허용, past_due는 3일 유예, unpaid/canceled/paused는 정지
- 복사해서 실행할 수 있는 코드와 stripe listen 테스트 명령 포함
DB는 과금 상태와 앱 권한을 분리한다
아래 SQL은 Postgres 기준의 최소 구조입니다. billing_subscriptions는 Stripe 구독 상태를 보관하고, entitlements는 앱이 실제로 확인하는 기능 권한입니다. webhook_events는 중복 Webhook과 재시도를 처리하기 위한 테이블입니다.
create table if not exists app_users (
id text primary key,
email text not null unique,
stripe_customer_id text unique,
created_at timestamptz not null default now()
);
create table if not exists billing_subscriptions (
user_id text primary key references app_users(id) on delete cascade,
stripe_customer_id text not null,
stripe_subscription_id text not null unique,
plan_key text not null,
status text not null check (
status in ('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused')
),
access_state text not null check (access_state in ('pending', 'granted', 'grace', 'revoked')),
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
grace_until timestamptz,
updated_at timestamptz not null default now()
);
create table if not exists entitlements (
user_id text not null references app_users(id) on delete cascade,
feature_key text not null,
active boolean not null default true,
expires_at timestamptz,
updated_at timestamptz not null default now(),
primary key (user_id, feature_key)
);
create table if not exists webhook_events (
event_id text primary key,
event_type text not null,
status text not null default 'processing',
attempts integer not null default 1,
processed_at timestamptz,
last_error text,
created_at timestamptz not null default now()
);
Checkout과 Customer Portal 구현
npm install stripe postgres
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_STUDIO_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_AUTOMATIC_TAX=false
// src/lib/db.ts
import postgres from "postgres";
export const sql = postgres(process.env.DATABASE_URL!, {
max: 5,
idle_timeout: 20,
});
// src/lib/billing.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
export const PLANS = {
free: { priceId: null, features: ["article:preview"] },
pro: {
priceId: process.env.STRIPE_PRO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library"],
},
studio: {
priceId: process.env.STRIPE_STUDIO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library", "team:seats", "review:request"],
},
} as const;
export type PlanKey = keyof typeof PLANS;
export type PaidPlanKey = Exclude<PlanKey, "free">;
export function isPaidPlanKey(value: unknown): value is PaidPlanKey {
return value === "pro" || value === "studio";
}
export async function createCheckoutSession(userId: string, planKey: PaidPlanKey) {
const customerId = await findOrCreateCustomer(userId);
const plan = PLANS[planKey];
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: plan.priceId, quantity: 1 }],
client_reference_id: userId,
allow_promotion_codes: true,
automatic_tax: { enabled: process.env.STRIPE_AUTOMATIC_TAX === "true" },
success_url: `${APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${APP_URL}/pricing`,
subscription_data: { metadata: { userId, planKey } },
metadata: { userId, planKey },
});
if (!session.url) throw new Error("Stripe did not return a Checkout URL");
return session.url;
}
export async function createPortalSession(userId: string) {
const customerId = await getCustomerId(userId);
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${APP_URL}/settings/billing`,
});
return session.url;
}
async function findOrCreateCustomer(userId: string) {
const users = await sql`select id, email, stripe_customer_id from app_users where id = ${userId}`;
const user = users[0] as { id: string; email: string; stripe_customer_id: string | null } | undefined;
if (!user) throw new Error("User not found");
if (user.stripe_customer_id) return user.stripe_customer_id;
const customer = await stripe.customers.create({ email: user.email, metadata: { userId } });
await sql`update app_users set stripe_customer_id = ${customer.id} where id = ${userId}`;
return customer.id;
}
async function getCustomerId(userId: string) {
const rows = await sql`select stripe_customer_id from app_users where id = ${userId}`;
const customerId = rows[0]?.stripe_customer_id as string | undefined;
if (!customerId) throw new Error("Stripe customer is not linked");
return customerId;
}
// src/app/api/billing/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createCheckoutSession, isPaidPlanKey } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
const { planKey } = await request.json();
if (!isPaidPlanKey(planKey)) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
const url = await createCheckoutSession(userId, planKey);
return NextResponse.json({ url });
}
// src/app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createPortalSession } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
const url = await createPortalSession(userId);
return NextResponse.json({ url });
}
Webhook으로 권한을 동기화한다
성공 URL은 결제 증거가 아니라 사용자가 돌아오는 경로입니다. 유료 기능 개방은 서명 검증을 통과한 Webhook 또는 Stripe API에서 가져온 최신 상태를 기준으로 합니다. Stripe 이벤트는 순서대로 도착한다고 가정하면 안 되므로, Subscription을 다시 조회한 뒤 DB에 씁니다.
// src/lib/entitlements.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { PLANS, PlanKey, stripe } from "@/lib/billing";
type AccessState = "pending" | "granted" | "grace" | "revoked";
export async function syncSubscriptionFromStripe(subscriptionId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ["items.data.price"] });
const item = subscription.items.data[0];
const planKey = planKeyFromPrice(item?.price.id);
const userId = subscription.metadata.userId;
if (!userId) throw new Error(`Subscription ${subscription.id} has no userId metadata`);
const accessState = accessStateFor(subscription.status);
const currentPeriodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
const graceUntil = accessState === "grace" ? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) : null;
await sql.begin(async (tx) => {
await tx`
insert into billing_subscriptions (
user_id, stripe_customer_id, stripe_subscription_id, plan_key,
status, access_state, current_period_end, cancel_at_period_end, grace_until, updated_at
)
values (
${userId}, ${subscription.customer as string}, ${subscription.id}, ${planKey},
${subscription.status}, ${accessState}, ${currentPeriodEnd},
${subscription.cancel_at_period_end}, ${graceUntil}, now()
)
on conflict (user_id) do update set
stripe_customer_id = excluded.stripe_customer_id,
stripe_subscription_id = excluded.stripe_subscription_id,
plan_key = excluded.plan_key,
status = excluded.status,
access_state = excluded.access_state,
current_period_end = excluded.current_period_end,
cancel_at_period_end = excluded.cancel_at_period_end,
grace_until = excluded.grace_until,
updated_at = now()
`;
const features = accessState === "granted" || accessState === "grace" ? PLANS[planKey].features : PLANS.free.features;
await tx`delete from entitlements where user_id = ${userId}`;
for (const feature of features) {
await tx`insert into entitlements (user_id, feature_key, active, expires_at, updated_at) values (${userId}, ${feature}, true, ${graceUntil}, now())`;
}
});
console.info("stripe.subscription.synced", { userId, subscriptionId: subscription.id, status: subscription.status, accessState, planKey });
}
export async function hasEntitlement(userId: string, featureKey: string) {
const rows = await sql`
select 1 from entitlements
where user_id = ${userId}
and feature_key = ${featureKey}
and active = true
and (expires_at is null or expires_at > now())
limit 1
`;
return rows.length > 0;
}
function planKeyFromPrice(priceId: string | undefined): PlanKey {
const entry = Object.entries(PLANS).find(([, plan]) => plan.priceId === priceId);
return entry ? (entry[0] as PlanKey) : "free";
}
function accessStateFor(status: Stripe.Subscription.Status): AccessState {
if (status === "active" || status === "trialing") return "granted";
if (status === "past_due") return "grace";
if (status === "incomplete") return "pending";
return "revoked";
}
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { stripe } from "@/lib/billing";
import { syncSubscriptionFromStripe } from "@/lib/entitlements";
export async function POST(request: NextRequest) {
const payload = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing 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 });
}
const rows = await sql`
insert into webhook_events (event_id, event_type, status)
values (${event.id}, ${event.type}, 'processing')
on conflict (event_id) do nothing
returning event_id
`;
if (rows.length === 0) return NextResponse.json({ received: true, duplicate: true });
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionFromStripe(subscription.id);
}
if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
const value = invoice.parent?.subscription_details?.subscription as string | { id: string } | null | undefined;
const subscriptionId = typeof value === "string" ? value : value?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
await sql`update webhook_events set status = 'processed', processed_at = now() where event_id = ${event.id}`;
return NextResponse.json({ received: true });
}
로컬 테스트
stripe listen 출력에 있는 whsec_... 값을 STRIPE_WEBHOOK_SECRET에 넣습니다.
stripe listen --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
가장 좋은 검증은 테스트 모드의 Product와 Price로 실제 Checkout을 끝까지 진행한 뒤 Customer Portal에서 결제 수단 변경과 해지를 확인하는 것입니다.
자주 발생하는 실패
성공 URL만 보고 권한을 열면 안 됩니다. 사용자가 돌아오지 않을 수도 있고, 결제가 추가 인증을 기다릴 수도 있습니다. UI에서는 처리 중 상태를 보여주고, 권한은 Webhook 또는 API 조회 결과로 결정합니다.
metadata를 Checkout Session에만 넣는 것도 흔한 실수입니다. Subscription 기준으로 동기화하려면 subscription_data.metadata에도 userId와 planKey를 넣어야 합니다.
past_due, unpaid, canceled를 같은 상태로 취급하지 마세요. past_due는 결제 복구 중일 수 있으므로 짧은 유예가 유용합니다. unpaid와 canceled는 보통 유료 권한을 회수합니다.
Customer Portal API만 구현하고 Stripe Dashboard 설정을 잊는 경우도 많습니다. 고객이 어떤 상품으로 변경할 수 있는지, 해지를 허용할지, 결제 수단을 관리할 수 있는지는 Dashboard에서 설정해야 합니다.
수익화 CTA
ClaudeCodeLab에서 이 흐름을 실제 콘텐츠와 템플릿 판매에 적용해 보니, 가장 큰 효과는 코드 생성보다 상태표를 먼저 고정한 데서 나왔습니다. 상태표가 있으면 Claude Code 프롬프트, 리뷰, 테스트, 고객 안내가 같은 기준을 따릅니다.
관련 구현은 Claude Code 결제 통합, Claude Code 인증 구현, Claude Code 분석 구현을 참고하세요. 실제 SaaS, 강의 사이트, 멤버십 퍼널에 맞춘 Stripe Billing과 Claude Code 리뷰 흐름이 필요하다면 Claude Code 교육 및 상담 또는 제품과 템플릿에서 시작할 수 있습니다.
구현 전에는 Checkout Sessions API, Customer Portal, subscription webhooks, webhooks, subscription statuses, Claude Code setup을 다시 확인하세요.
무료 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, 상담 경로 체크리스트.