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

웹훅: Stripe + 외부 서비스 연동


핵심 개념

서명 검증·idempotency·재시도·Stripe events 처리·event log — 안전한 웹훅 수신.

본문

웹훅 흐름

📋 코드 (6줄)
1. 외부 서비스 (Stripe·GitHub) 이벤트 발생
2. POST 요청 → 우리 endpoint
3. 서명 검증 (위변조 방지)
4. Idempotency 체크 (중복 처리 방지)
5. 비즈니스 로직 실행
6. 200 응답 (3초 안에 — 외부 서비스 timeout)

Stripe Webhook 엔드포인트

TS📋 코드 (63줄)
// src/app/api/stripe/webhook/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'
import { createServiceClient } from '@/lib/supabase/service'

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

export async function POST(request: Request) {
  const body = await request.text()  // 원본 body (서명 검증용)
  const sig = (await headers()).get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, sig, WEBHOOK_SECRET)
  } catch (err) {
    return new Response('signature mismatch', { status: 400 })
  }

  const supabase = createServiceClient()  // service role

  // Idempotency 체크
  const { data: existing } = await supabase
    .from('webhook_events')
    .select('id')
    .eq('event_id', event.id)
    .single()

  if (existing) return new Response('already processed', { status: 200 })

  await supabase.from('webhook_events').insert({
    event_id: event.id,
    type: event.type,
    payload: event.data.object,
  })

  // 이벤트별 처리
  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
        break
      case 'customer.subscription.created':
      case 'customer.subscription.updated':
        await handleSubscriptionChange(event.data.object as Stripe.Subscription)
        break
      case 'customer.subscription.deleted':
        await handleSubscriptionCanceled(event.data.object as Stripe.Subscription)
        break
      case 'invoice.payment_succeeded':
        await handleInvoicePaid(event.data.object as Stripe.Invoice)
        break
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object as Stripe.Invoice)
        break
    }
  } catch (err) {
    // Stripe가 재시도 → 200 안 보내면 자동 retry
    return new Response('error', { status: 500 })
  }

  return new Response('ok', { status: 200 })
}

webhook_events 테이블

SQL📋 코드 (9줄)
create table webhook_events (
  id uuid primary key default gen_random_uuid(),
  event_id text unique not null,  -- Stripe event ID
  type text not null,
  payload jsonb,
  processed_at timestamptz default now()
);

create index idx_webhook_event_id on webhook_events(event_id);

Subscription 처리

TS📋 코드 (27줄)
async function handleSubscriptionChange(sub: Stripe.Subscription) {
  const supabase = createServiceClient()

  // customer ID로 user 조회
  const { data: user } = await supabase
    .from('users')
    .select('id')
    .eq('stripe_customer_id', sub.customer as string)
    .single()

  if (!user) return

  await supabase.from('subscriptions').upsert({
    user_id: user.id,
    stripe_subscription_id: sub.id,
    plan: sub.items.data[0].price.lookup_key ?? 'pro',
    status: sub.status,
    current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
  }, { onConflict: 'stripe_subscription_id' })

  // 알림 발송
  await supabase.from('notifications').insert({
    user_id: user.id,
    type: 'subscription_updated',
    title: '구독이 갱신되었습니다',
  })
}

로컬 개발 (Stripe CLI)

BASH📋 코드 (14줄)
# Stripe CLI 설치
brew install stripe/stripe-cli/stripe

# 로그인
stripe login

# 웹훅 forward
stripe listen --forward-to localhost:3000/api/stripe/webhook

# 출력된 webhook signing secret을 .env.local에:
# STRIPE_WEBHOOK_SECRET=whsec_...

# 테스트 이벤트 trigger
stripe trigger payment_intent.succeeded

next.config.ts 설정 (Body 파싱)

TS📋 코드 (4줄)
// Webhook은 raw body 필요 → middleware exclude
export const config = {
  matcher: '/((?!api/stripe/webhook).*)',
}

안티패턴

📋 코드 (5줄)
❌ 서명 검증 생략 → 위조 가능
❌ Idempotency 없음 → 중복 결제 처리
❌ 5초 이상 처리 → Stripe timeout (재시도 폭발)
❌ async 작업 동기 처리 → queue 사용
✓ 빠른 응답 + queue로 비동기 작업

큐 사용 (큰 처리)

TS📋 코드 (15줄)
// inngest / trigger.dev / Vercel Queue
import { inngest } from '@/lib/inngest'

// webhook → queue
await inngest.send({
  name: 'stripe.subscription.updated',
  data: { subscriptionId: sub.id },
})

return new Response('ok')  // 즉시 응답

// 별도 worker가 처리
inngest.createFunction({ id: 'process-sub' }, { event: 'stripe.subscription.updated' }, async ({ event }) => {
  // 무거운 작업
})

다음 챕터

CH.33 "백엔드 종합: API 전체 테스트".


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

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

내 마스터 프로젝트의 웹훅 처리 부분을 분석해서
실전 적용 + 개선 우선순위 3가지를 알려줘.
ChatGPT

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

웹훅 처리 관련 모범 사례·안티패턴 5개를
비교 분석해서 실전 적용를 위한 추천 방안을 알려줘.
Gemini

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

내 프로젝트 전체에서 웹훅 처리
최적화 가능 위치와 리스크를 보고해줘.
Grok

무료: Grok 4.1 / SuperGrok $30/mo

2026년 한국 1인 개발자 시장의
웹훅 처리 트렌드와 차별화 포인트를 정리해줘.

⭐ 이것만 기억하세요
웹훅: Stripe + 외부 서비스 연동 이 3가지만 확실히 잡으세요
1.서명 검증 + Idempotency = 안전한 웹훅
2.5초 안에 200 응답 + 무거운 작업은 큐로
3.다음 챕터에서 백엔드 종합


공유하기
진행도 32 / 50