Stripe 웹훅 서명 검증과 중복 처리로 결제 이벤트를 안전하게 받기

원본 본문 검증, 이벤트 ID 기록, 빠른 2xx 응답, 순서 비보장 대응, 로컬 테스트

핵심 요약

  1. 웹훅은 원본 본문, Stripe-Signature 헤더, whsec_ 비밀값으로 서명을 검증한다.
  2. 같은 이벤트가 중복 도착할 수 있으므로 이벤트 ID를 고유 제약으로 기록해 한 번만 처리한다.
  3. 복잡한 처리 전에 2xx를 먼저 반환하고 무거운 작업은 큐로 넘긴다.
  4. 이벤트 도착 순서는 보장되지 않으므로 created 시각에 의존하지 않고 필요하면 API로 조회한다.

결제 완료 처리를 클라이언트 리다이렉트에만 의존하면, 사용자가 창을 닫거나 네트워크가 끊겼을 때 결제는 됐는데 주문은 생성되지 않는 문제가 생긴다. 그래서 결제 상태 변경은 Stripe가 서버로 보내는 웹훅 이벤트로 처리한다. 하지만 웹훅 엔드포인트는 인터넷에 공개된 주소라, 서명 검증이 없으면 누구나 가짜 "결제 완료" 이벤트를 보낼 수 있다. 이 글은 Stripe 웹훅 문서를 바탕으로 검증·중복 처리·응답 순서를 Next.js Route Handler 예시와 함께 정리한다. 비동기 처리 구조는 비동기 API 설계 글을 참고한다.

웹훅 처리에서 지켜야 할 다섯 가지

원칙문서 내용이유
서명 검증원본 본문, Stripe-Signature 헤더, whsec_ 서명 비밀값으로 검증가짜 이벤트 차단
원본 본문 유지프레임워크가 본문을 변형하면 검증 실패JSON 파싱·재직렬화 금지
중복 처리 방지같은 이벤트가 두 번 이상 올 수 있으므로 처리한 이벤트 ID를 기록이중 주문·이중 지급 방지
빠른 2xx 응답복잡한 로직 전에 성공 코드를 반환타임아웃으로 인한 재전송 방지
순서 비보장이벤트 생성 순서대로 도착한다는 보장이 없음필요하면 API로 최신 객체 조회

1단계: Route Handler에서 서명 검증하기

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!   // whsec_...

export async function POST(request: Request) {
  const rawBody = await request.text()                       // 원본 본문 그대로
  const signature = request.headers.get('stripe-signature') ?? ''

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, endpointSecret)
  } catch {
    return new Response('signature verification failed', { status: 400 })
  }

  const isNew = await recordEventIfNew(event.id)            // 처리한 이벤트 ID 기록
  if (isNew) {
    await enqueueStripeEvent(event)                          // 무거운 처리는 큐에서
  }
  return new Response('ok', { status: 200 })
}

Next.js 문서의 웹훅 예시처럼 request.text()로 원본 본문을 읽고, 검증 전에 request.json()으로 파싱하지 않는다. Stripe 공식 라이브러리는 서명 헤더의 타임스탬프와 현재 시각의 차이를 기본 5분까지 허용하는데, 문서는 이 허용치를 0으로 두면 최신성 검사 자체가 꺼지므로 0을 쓰지 말라고 경고한다. 서버 시계는 NTP로 맞춘다.

2단계: 이벤트 ID로 중복 처리 막기

문서는 웹훅 엔드포인트가 같은 이벤트를 두 번 이상 받을 수 있다며, 처리한 이벤트 ID를 기록하고 이미 기록된 이벤트는 다시 처리하지 말라고 권한다. 데이터베이스의 고유 제약을 이용하면 동시 요청에서도 안전하다.

-- 처리한 이벤트 기록 테이블
CREATE TABLE stripe_events (
  event_id text PRIMARY KEY,
  received_at timestamptz NOT NULL DEFAULT now()
);

-- 삽입에 성공하면 새 이벤트, 충돌하면 이미 처리한 이벤트
INSERT INTO stripe_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;

문서는 경우에 따라 서로 다른 두 이벤트 객체가 같은 변경에 대해 생성될 수 있다고도 설명한다. 이런 중복은 data.object의 ID와 event.type을 함께 보고 식별한다. 주문 생성 같은 최종 작업도 "결제 ID당 주문 하나" 같은 고유 제약을 두면 이중 처리를 한 번 더 막을 수 있다.

3단계: 빠르게 응답하고 순서에 의존하지 않기

  • 빠른 응답: 문서는 인보이스를 결제 완료로 바꾸는 것 같은 작업 전에 200을 먼저 반환하라고 예를 든다. 처리 시간이 길면 타임아웃으로 간주되어 재전송된다.
  • 비동기 큐: 월초 구독 갱신처럼 이벤트가 몰리는 시점을 대비해 큐로 처리량을 조절한다.
  • 순서 비보장: 구독 생성 시 customer.subscription.created, invoice.created, invoice.paid 등이 순서와 다르게 도착할 수 있다. 이벤트의 created 시각은 초 단위라 같은 값이 나올 수 있으므로 순서 판단에 쓰지 않는다. 필요한 객체는 API로 다시 조회한다.
  • 필요한 이벤트만 구독: 모든 이벤트를 받으면 서버 부담만 늘어난다.

4단계: Stripe CLI로 로컬 테스트하기

stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# 출력되는 서명 비밀값(whsec_...)을 STRIPE_WEBHOOK_SECRET에 넣는다

# 다른 터미널에서 테스트 이벤트 발생
stripe trigger payment_intent.succeeded

운영 전 점검 순서

  1. 서명 헤더를 빼거나 본문 한 글자를 바꾼 요청이 400으로 거부되는지 확인한다.
  2. 같은 이벤트를 두 번 보내도 주문이 하나만 생기는지 확인한다(Stripe 대시보드의 재전송 기능 활용).
  3. 처리 로직에 지연을 넣어도 엔드포인트가 즉시 200을 반환하는지 본다.
  4. 테스트 모드와 라이브 모드의 서명 비밀값이 다르므로 환경별로 따로 설정했는지 확인한다.
  5. 서명 비밀값을 정기적으로 교체하는 절차를 문서화한다(교체 시 최대 24시간 동안 이전 비밀값을 함께 유지 가능).

라이브 모드에서 Stripe는 실패한 전달을 최대 3일 동안 지수 백오프로 재시도한다. 장애로 이벤트를 놓쳤다면 대시보드나 CLI의 재전송 기능으로 복구할 수 있다. 결제 관련 비밀값 관리 원칙은 사내 도구 보안 점검 글의 비밀값 항목과 같은 기준을 적용한다.

초보자가 자주 실수하는 포인트

  • 본문을 JSON으로 파싱한 뒤 다시 문자열로 만들어 서명 검증이 실패하는 경우
  • 서명 검증 없이 결제 완료 이벤트를 신뢰하는 경우
  • 주문 생성까지 마친 뒤 응답해 타임아웃과 재전송이 반복되는 경우
  • 허용 시간(tolerance)을 0으로 설정해 재전송 공격 검사를 끄는 경우

체크리스트

  • 원본 본문으로 서명을 검증하는가
  • 처리한 이벤트 ID를 고유 제약으로 기록하는가
  • 복잡한 처리 전에 2xx를 반환하는가
  • 이벤트 순서에 의존하지 않는 처리 로직인가
  • 테스트·라이브 모드 서명 비밀값을 분리했는가

자주 묻는 질문

웹훅 엔드포인트에 CSRF 검사가 걸리면 어떻게 하나요?

Stripe 문서는 Rails, Django처럼 POST마다 CSRF 토큰을 검사하는 프레임워크에서는 웹훅 경로를 CSRF 보호에서 제외하라고 안내한다. Next.js Route Handler는 별도 CSRF 검사가 없다.

IP 허용 목록만으로 충분한가요?

문서는 IP 허용 목록과 서명 검증을 함께 쓰라고 권한다. 서명 검증은 생략하지 않는다.

재시도된 이벤트는 서명이 같나요?

재시도할 때마다 새 타임스탬프와 서명이 생성된다. 그래서 중복 판단은 서명이 아니라 이벤트 ID로 한다.

참고 자료 · 검증 기준

위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.

이 글은 위 참고 자료를 바탕으로 정리했으며, 내용은 운영 과정에서 순차적으로 보완될 수 있습니다.