다음은 Stripe가 실제로 보낸 웹훅 페이로드로, 일부는 잘라내고 가렸지만 구조는 그대로 남겨두었다 — checkout.session.completed 이벤트, 누군가 Checkout 페이지에서 결제를 완료할 때 발생하는 이벤트다. 대부분의 개발자는 이걸 한 번 훑어보고 data.object.customer 와 data.object.amount_total만 꺼내 쓴 뒤 넘어간다. 데모라면 보통 그걸로 충분하다. 그게 바로 앱이 주문을 중복 처리하고, 구독 갱신을 놓치고, "환불이 안 됐다"는 첫 항의 메일을 받게 되는 이유다. 전체를 하나씩 뜯어보자.
{
"id": "evt_1P8xQ2K7z3n9lWqA00Ff2gLm",
"object": "event",
"api_version": "2024-06-20",
"created": 1725456000,
"type": "checkout.session.completed",
"livemode": true,
"pending_webhooks": 1,
"request": { "id": "req_9F2mA1", "idempotency_key": null },
"data": {
"object": {
"id": "cs_live_a1B2c3D4",
"object": "checkout.session",
"customer": "cus_Q3fZ8xLmN2",
"customer_details": {
"email": "[email protected]",
"tax_ids": []
},
"payment_status": "paid",
"payment_intent": "pi_3P8xQ2K7z3n9lWqA1gH4iJ5k",
"subscription": "sub_1P8xQaK7z3n9lWqA",
"amount_total": 2900,
"currency": "usd",
"metadata": {
"app_user_id": "usr_38821",
"plan": "pro_monthly"
}
}
}
}
id와 그로 인해 공짜로 물려받는 멱등성 문제
evt_1P8xQ2K7z3n9lWqA00Ff2gLm 은 소음처럼 보이지만, 사실 중복 주문을 막아주는 유일한 안전장치다. Stripe는 웹훅을 적어도 한 번 전달하며, 정확히 한 번만 전달하지는 않는다. 서버가 요청을 받고도 200 응답을 보내기 전에 타임아웃되면, Stripe는 실패로 간주하고 같은 이벤트, 같은 id를 재전송하며, 몇 분 뒤일 수도, 다음 날일 수도 있다. 핸들러가 checkout.session.completed 를 볼 때마다 구독 권한을 부여하면서 이 정확한 id를 이미 처리했는지 확인하지 않는다면, 결국 두 번 부여하게 된다. 해결책은 데이터베이스에 한 줄만 추가하면 된다: 이 필드를 키로 하는 processed_webhook_events 테이블에 유니크 제약을 걸고, 다른 무엇보다 먼저 확인하는 것. 전체 통합 코드 중 가장 화려하지 않은 한 줄이지만, 실제로 가장 중요한 한 줄이다.
본문 안에 전혀 없는 서명 헤더
위 페이로드는 Stripe가 요청 본문으로 보내는 내용이다. 보이지 않는 것은 Stripe-Signature 헤더로, 타임스탬프와 웹훅 서명 시크릿으로 계산한 HMAC-SHA256 서명이 함께 온다. 이를 검증하지 않으면 웹훅 엔드포인트는 누구든 손으로 만든 "결제 성공" JSON을 보내 유료 플랜을 공짜로 열 수 있는 공개 POST 라우트가 되어버린다. 이건 이론적인 공격이 아니다. 엔드포인트 URL은 클라이언트 측 JS, 로그, Slack 붙여넣기 등을 통해 유출되며, 스캐너들은 정확히 이런 형태의 라우트를 찾아다닌다.
이 정확히 똑같은 버그를 프로덕션에서 두 번이나 본 적이 있다 — 두 번 다 수정 자체는 5분이면 끝났지만, 데이터베이스에 공짜 "pro" 계정이 몇 달째 살아있는 걸 아무도 눈치채지 못했다.
검증은 Stripe SDK로 세 줄이면 끝나고(stripe.webhooks.constructEvent(body, sig, secret)), 원본 그대로의 파싱되지 않은 요청 본문에서 실행되어야 한다 — 프레임워크의 JSON 미들웨어가 핸들러에 도달하기 전에 이미 객체로 파싱해버렸다면, 실제 공격과는 무관하게 바이트 단위 불일치로 서명 검증이 실패한다. 이게 Stripe 자체 포럼에 가장 많이 올라오는 "웹훅이 항상 400을 반환하는 이유" 버그다.
data.object.customer 대 data.object.customer_details
이 둘은 중복처럼 보이지만 그렇지 않다. customer 는 Stripe 고객 ID다 — 안정적이고 재사용 가능하며, 외래 키로 저장해야 할 값이다. customer_details 은 그 순간 구매자가 체크아웃 양식에 입력한 내용의 스냅샷이다 — 이메일, 세금 ID, 때로는 이름 — 그리고 customer 이 null일 때도 존재할 수 있는데, 이는 Stripe에 Customer 객체 생성을 요청하지 않은 일회성 Checkout 세션에서 발생한다. 온보딩 로직이 customer 항상 채워져 있다고 가정하고 읽으면, 게스트 체크아웃에서 조용히 깨진다.
metadata: 직접 넣은 딱 두 개의 필드
app_user_id 와 plan 은 Stripe 필드가 아니다 — Checkout 세션을 생성할 때 직접 붙인 값이다. 이건 전체 통합에서 가장 중요한 설계 결정인데도, Stripe 퀵스타트가 이를 강조하지 않아서 놓치기 쉽다. 자체 사용자 ID를 metadata에 넣지 않으면, 이 결제를 데이터베이스의 특정 행과 연결할 유일한 방법은 이메일 매칭뿐이며, 이메일은 바뀌거나 오타가 나거나 팀원을 대신해 결제하는 사람의 것일 수 있다. 돌이켜보면 내가 형편없이 만든 웹훅 핸들러는 전부 customer_details.email 에서 신원을 재구성하려던 것들이었지, 세 단계 전에 스스로 설정해둔 metadata를 신뢰한 게 아니었다.
payment_status: paid만 있는 게 아니다
이 이벤트가 존재한다는 사실만으로 결제가 완료됐다고 단정하고 싶어진다. 항상 그런 건 아니다 — payment_status 는 unpaid 일 수도 있고(세션은 완료됐지만 계좌이체 같은 지연 결제 수단이 아직 처리되지 않음), 또는 no_payment_required 일 수도 있다(전액 할인 체크아웃, 카드 청구가 아직 없는 무료 체험). 이 필드를 확인하지 않고 checkout.session.completed 만으로 상품을 처리한다는 건 돈이 실제로 확정되기도 전에 제품을 발송하는 것과 같다. 몇 달러 이상이라면 payment_status: "paid" 를 기다리거나, 더 낫게는 invoice.paid / payment_intent.succeeded 를 기준으로 상품 처리를 트리거하고 세션 이벤트에 의존하지 않는 편이 좋다.
| 이벤트 타입 | 발생 시점 | 이걸로 무엇을 해야 하는가 |
|---|---|---|
checkout.session.completed | 구매자가 Checkout 양식을 완료함 | 로그는 남기되, 처리 전에 payment_status 를 확인할 것 |
payment_intent.succeeded | 실제로 결제가 완료됨 | 일회성 구매를 처리해도 안전한 시점 |
invoice.paid | 구독 인보이스 결제 완료(최초 또는 갱신) | 이용 기간 연장, 사용량 카운터 초기화 |
customer.subscription.updated | 플랜 변경, 수량 변경, cancel-at-period-end 토글 | 권한 동기화, 취소를 의미한다고 단정하지 말 것 |
charge.refunded | 본인 또는 구매자의 은행이 결제를 취소함 | 접근 권한 회수, 가장 자주 놓치는 부분 |
이 페이로드에 없는 필드: 그다음에 벌어지는 일
이 JSON에는 Stripe가 전송 실패 시 최대 3일 동안 백오프 방식으로 재시도한다는 사실도, 연속 실패가 일정 횟수를 넘으면 엔드포인트를 비활성화하고 이메일로 알린다는 사실도 담겨 있지 않다. 이 동작은 페이로드가 아니라 대시보드 설정에 있으며, 대부분의 개발자는 배포로 라우트 경로가 바뀌어 엔드포인트가 일주일간 조용히 죽어 있었던 뒤에야 이 사실을 알게 된다.
웹훅 엔드포인트를 연결한 바로 그날 성공률 모니터링을 걸어두라, 첫 갱신을 놓친 뒤가 아니라. 페이로드는 무슨 일이 일어났는지는 알려주지만, 핸들러가 조용히 응답을 멈췄을 때는 경고해주지 않는다.



