OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 62 / 90
stack-analysis
CHAPTER 62 / 90
읽기 약 2
FUNCTION

결제 연동: Stripe Subscription 전체 흐름


핵심 개념

Checkout·Customer Portal·Webhook·Proration — 구독 SaaS 결제 표준.

본문

핵심 개념

📋 코드 (5줄)
Customer:    한 사용자(=tenant) = 1 customer
Product:     상품 (Pro Plan, Business Plan)
Price:       가격 (월/연·금액·통화)
Subscription: customer가 price 구독 중
Invoice:     매달 청구서 (자동 생성)

Stripe 셋업

BASH📋 코드 (1줄)
pnpm add stripe @stripe/stripe-js
TYPESCRIPT📋 코드 (13줄)
// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20.acacia',
});


// .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

1. Checkout Session 생성

TYPESCRIPT📋 코드 (40줄)
// app/api/checkout/route.ts
import { stripe } from '@/lib/stripe';
import { auth } from '@/lib/auth';

export async function POST(req: Request) {
  const { user } = await auth();
  if (!user) return new Response('Unauthorized', { status: 401 });

  const { priceId } = await req.json();

  // Stripe customer 생성/조회
  let customerId = user.stripeCustomerId;
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      metadata: { userId: user.id, tenantId: user.tenantId },
    });
    customerId = customer.id;
    await db.user.update({
      where: { id: user.id },
      data: { stripeCustomerId: customerId },
    });
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.APP_URL}/billing?success=true`,
    cancel_url: `${process.env.APP_URL}/billing?canceled=true`,
    allow_promotion_codes: true,
    billing_address_collection: 'auto',
    subscription_data: {
      trial_period_days: 14,  // 14일 무료 체험
      metadata: { tenantId: user.tenantId },
    },
  });

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

클라이언트

TSX📋 코드 (14줄)
'use client';

export function UpgradeButton({ priceId }: { priceId: string }) {
  const handleClick = async () => {
    const res = await fetch('/api/checkout', {
      method: 'POST',
      body: JSON.stringify({ priceId }),
    });
    const { url } = await res.json();
    window.location.href = url;
  };

  return <button onClick={handleClick}>Pro로 업그레이드</button>;
}

2. Webhook — 구독 상태 동기화

TYPESCRIPT📋 코드 (54줄)
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';

export async function POST(req: Request) {
  const body = await req.text();
  const sig = (await headers()).get('stripe-signature')!;

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      const sub = event.data.object;
      await db.tenant.update({
        where: { id: sub.metadata.tenantId },
        data: {
          plan: priceIdToPlan(sub.items.data[0].price.id),
          stripeSubscriptionId: sub.id,
          subscriptionStatus: sub.status,
          currentPeriodEnd: new Date(sub.current_period_end * 1000),
        },
      });
      break;

    case 'customer.subscription.deleted':
      await db.tenant.update({
        where: { stripeSubscriptionId: event.data.object.id },
        data: { plan: 'free', subscriptionStatus: 'canceled' },
      });
      break;

    case 'invoice.payment_failed':
      // 이메일 알림 + UI에 결제 실패 배너
      await emailQueue.add('payment_failed', {
        customerId: event.data.object.customer,
      });
      break;
  }

  return Response.json({ received: true });
}


// Next.js — body raw 보장
export const runtime = 'nodejs';
export const preferredRegion = 'icn1';

3. Customer Portal (해지·플랜 변경)

TYPESCRIPT📋 코드 (19줄)
// app/api/billing/portal/route.ts
export async function POST(req: Request) {
  const { user } = await auth();
  if (!user?.stripeCustomerId) {
    return new Response('No subscription', { status: 400 });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.APP_URL}/billing`,
  });

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


// → 사용자가 플랜 변경, 결제수단 변경, 구독 해지 모두
// → Stripe가 호스팅하는 표준 UI
// → 별도 UI 개발 불필요

4. Proration (요금 비례 계산)

📋 코드 (10줄)
사용자가 월중 Pro($20) → Business($50) 변경:
- 남은 일수: 15일
- Pro 환불: $20 × 15/30 = $10
- Business 청구: $50 × 15/30 = $25
- 즉시 청구: $25 - $10 = $15


Stripe 자동 처리 — proration_behavior:
- 'create_prorations' (기본): 다음 청구서에 추가
- 'always_invoice': 즉시 청구

다음 챕터

CH.63 "사용자 관리: 초대/역할/팀".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 프로젝트의 Stripe 결제 부분을 분석해서
실전 분석 + 개선 우선순위를 알려줘.
ChatGPT

무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro

Stripe 결제 관련 실제 서비스 5개를
비교 분석해서 패턴 추출를 알려줘.
Gemini

무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro

내 코드베이스에서 Stripe 결제
최적화 가능 위치를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 풀스택 시장의
Stripe 결제 트렌드를 솔직히 알려줘.

⭐ 이것만 기억하세요
결제 연동: Stripe Subscription 전체 흐름 이 3가지만 확실히 잡으세요
1.Stripe Checkout + Webhook + Portal = 표준 3단 — 자체 결제 UI 불필요
2.Webhook으로 DB 동기화 — single source of truth는 Stripe
3.Customer Portal로 셀프 서비스 — 해지·플랜 변경 자동


공유하기
진행도 62 / 90