OPEN HYPER STEP
← 목록으로 (master-project)
MASTER-PROJECT · 44 / 50
master-project
CHAPTER 44 / 50
읽기 약 2
FUNCTION

결제 연동: Stripe Checkout + 웹훅


핵심 개념

Stripe 제품·가격·Checkout·Customer Portal·구독 상태 동기화 — 결제 풀스택.

본문

Stripe 셋업

BASH📋 코드 (1줄)
pnpm add stripe @stripe/stripe-js
ENV📋 코드 (3줄)
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

제품·가격 생성 (Stripe Dashboard)

📋 코드 (9줄)
1. stripe.com Dashboard → Products
2. + Add product:
   - Name: Pro Plan
   - Price: $19/mo (Recurring)
   - Lookup key: pro_monthly
3. 추가 가격: $190/yr (할인)
   - Lookup key: pro_yearly

→ Price IDs 메모 (price_1ABC...)

Checkout 세션 생성

TS📋 코드 (42줄)
// src/app/api/stripe/checkout/route.ts
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const { priceId } = await request.json()
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return new Response('unauthorized', { status: 401 })

  // 기존 customer 확인
  const { data: existing } = await supabase
    .from('users')
    .select('stripe_customer_id')
    .eq('id', user.id)
    .single()

  let customerId = existing?.stripe_customer_id
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email!,
      metadata: { user_id: user.id },
    })
    customerId = customer.id
    await supabase.from('users').update({ stripe_customer_id: customerId }).eq('id', user.id)
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?welcome=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    allow_promotion_codes: true,  // 쿠폰 적용 가능
    billing_address_collection: 'required',
    metadata: { user_id: user.id },
  })

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

Pricing 페이지

TSX📋 코드 (58줄)
// src/app/pricing/page.tsx
'use client'

const PLANS = [
  {
    name: 'Free',
    price: '$0',
    features: ['월 5회 생성', '기본 템플릿'],
    cta: '시작하기',
    href: '/signup',
  },
  {
    name: 'Pro',
    price: '$19/mo',
    priceId: 'price_1ABC...',
    features: ['무제한 생성', '모든 템플릿', '우선 지원'],
    cta: '시작하기',
    popular: true,
  },
  {
    name: 'Team',
    price: '$49/user/mo',
    priceId: 'price_1DEF...',
    features: ['Pro 전체', '팀 협업', 'API 접근'],
    cta: '시작하기',
  },
]

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

  return (
    <div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto p-6">
      {PLANS.map(p => (
        <div key={p.name} className={`border rounded-lg p-6 ${p.popular ? 'ring-2 ring-primary' : ''}`}>
          <h3 className="font-bold text-xl">{p.name}</h3>
          <p className="text-3xl font-bold my-3">{p.price}</p>
          <ul className="space-y-2 mb-6">
            {p.features.map(f => <li key={f} className="text-sm">✓ {f}</li>)}
          </ul>
          <button
            onClick={() => p.priceId ? handleCheckout(p.priceId) : (window.location.href = p.href!)}
            className="w-full bg-primary text-primary-foreground py-2 rounded"
          >
            {p.cta}
          </button>
        </div>
      ))}
    </div>
  )
}

Customer Portal (구독 관리)

TS📋 코드 (13줄)
// src/app/api/stripe/portal/route.ts
export async function POST() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  const { data: u } = await supabase.from('users').select('stripe_customer_id').eq('id', user!.id).single()

  const session = await stripe.billingPortal.sessions.create({
    customer: u!.stripe_customer_id!,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings`,
  })

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

Webhook 동기화 (CH.32 참조)

TS📋 코드 (13줄)
// 핵심 이벤트
case 'checkout.session.completed':
  // 결제 완료 → 구독 활성화
  break
case 'customer.subscription.updated':
  // 갱신·플랜 변경
  break
case 'customer.subscription.deleted':
  // 취소 → free로 전환
  break
case 'invoice.payment_failed':
  // 결제 실패 → 사용자 알림
  break

환불·해지

📋 코드 (9줄)
[해지]
- Customer Portal에서 사용자가 직접
- 또는 Stripe Dashboard에서 관리자가
- 즉시 vs 기간 종료 시 (cancel_at_period_end)

[환불]
- Stripe Dashboard → Refund
- 또는 stripe.refunds.create({ charge })
- 자동 환불 정책: 7일 안 무조건 (한국 법)

한국 결제 (KakaoPay·Toss)

📋 코드 (4줄)
Stripe는 한국 카드 지원이지만 KakaoPay·Toss는 별도:
- Toss Payments (1순위)
- KakaoPay
- 결제 단가 낮으면 Stripe 충분 (한국 카드 OK)

다음 챕터

CH.45 "Product Hunt + 커뮤니티 런칭".


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

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

내 마스터 프로젝트의 Stripe 결제 부분을 분석해서
실전 적용 + 개선 우선순위 3가지를 알려줘.
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년 한국 1인 개발자 시장의
Stripe 결제 트렌드와 차별화 포인트를 정리해줘.

⭐ 이것만 기억하세요
결제 연동: Stripe Checkout + 웹훅 이 3가지만 확실히 잡으세요
1.Stripe Checkout + Customer Portal = 결제 풀스택
2.Webhook 동기화로 구독 상태 항상 정확
3.다음 챕터에서 런칭 전략


공유하기
진행도 44 / 50