이벤트 유실을 방지하기 위해 내가 사용하는 Stripe Webhook 패턴
요약
Stripe 웹훅 처리 시 이벤트 유실과 보안 위협을 방지하기 위한 4가지 핵심 패턴을 소개합니다. 에지에서의 서명 검증, 멱등성 키 활용, 데드 레터 테이블 구축, 수동 재전송을 통해 안정적인 결제 시스템을 구축하는 방법을 다룹니다.
핵심 포인트
- 에지(Edge)에서 원시 바이트 기반의 서명 검증을 수행하여 보안 강화
- 멱등성 키(Idempotency keys)를 사용하여 중복 주문 생성 방지
- 데드 레터 테이블을 통해 처리 실패 이벤트를 별도로 포착
- Stripe 대시보드의 수동 재전송 기능을 활용한 장애 복구
-
에지(Edge)에서의 서명 검증(Signature verification)은 로직에 도달하기 전 위조된 페이로드(Payload)를 차단합니다.
-
멱등성 키(Idempotency keys)는 Stripe의 재시도(Retry)로 인한 중복 주문 생성을 방지합니다.
-
데드 레터 테이블(Dead-letter table)은 처리에 실패하는 0.3%의 이벤트를 포착합니다.
-
Stripe 대시보드에서의 수동 재전송(Manual replay)은 5분 이내에 멈춰버린 웹훅(Webhook)을 복구합니다.
디지털 제품을 판매한 첫 달에 유료 주문 3개를 놓쳤습니다. 웹훅이 조용히 실패했고 저는 전혀 알지 못했기 때문입니다. 에러도, 알림도 없었고, 그저 고객이 다운로드 링크가 어디 있느냐고 묻는 이메일만 왔을 뿐입니다. 그 이후로 저는 4가지 패턴을 중심으로 Stripe 처리 로직을 재구축했고, 그 이후로는 단 하나의 이벤트도 놓치지 않았습니다. 제가 출시하는 모든 유료 제품에 적용되는 방식은 다음과 같습니다.
패턴 1: 에지(Edge)에서 서명 검증하기
모든 Stripe 웹훅 엔드포인트가 가장 먼저 수행하는 작업은 요청이 실제로 Stripe에서 왔음을 증명하는 것입니다. 이것이 없다면 엔드포인트 URL을 찾아낸 누구나 가짜 결제 이벤트를 POST하여 무료 다운로드를 유도할 수 있습니다. 저는 보호되지 않은 엔드포인트가 라이브된 지 몇 시간 만에 공격받는 것을 본 적이 있습니다.
Stripe는 모든 웹훅에 비밀 키(Secret)로 서명합니다. 서명은 Stripe-Signature 헤더에 들어 있습니다. 제 핸들러(Handler)는 원시 요청 본문(Raw request body, 파싱된 JSON이 아닌 이것이 중요합니다)을 읽은 다음, 원시 본문, 헤더, 그리고 저의 서명 비밀 키를 사용하여 검증 함수를 호출합니다. 검증에 실패하면 즉시 400 에러를 반환하고 카운터 외에는 아무것도 로그에 남기지 않습니다.
사람들이 실수하는 디테일: 반드시 원시 바이트(Raw bytes)를 기준으로 검증해야 합니다. 만약 사용 중인 프레임워크가 핸들러가 실행되기 전에 본문을 JSON으로 파싱한다면, 바이트 순서가 변경되었기 때문에 서명 확인은 매번 실패하게 됩니다. 저는 에지 함수(Edge functions)에서 해당 라우트에 대해서만 본문 파싱을 비활성화하고 직접 원시 스트림(Raw stream)을 가져옵니다. 설정에 약 15분 정도 소요되지만, 가장 큰 보안 허점을 메워줍니다.
저는 이 검증을 에지(Edge)에서 실행합니다. 즉, 데이터베이스 호출이 발생하기 전, 요청에 가장 가까운 함수에서 검증이 이루어집니다. 위조된 페이로드(Payload)는 제 주문 로직에 절대 닿지 않으며, 연결을 열지도 않고, 거부된 이후에는 추가적인 컴퓨팅 사이클(Compute cycle)을 소모하지도 않습니다. 실제로 이는 제 엔드포인트(Endpoint)에 공격을 퍼붓는 봇이 벽에 부딪혀 포기하게 된다는 것을 의미합니다.
제가 고생하며 배운 한 가지가 더 있습니다. 테스트 모드(Test mode)와 라이브 모드(Live mode)의 서명 비밀키(Signing secrets)를 분리하여 관리해야 한다는 점입니다. 한 번은 테스트용 비밀키를 운영 환경(Production)에 배포한 적이 있는데, 그 결과 모든 실제 결제 웹훅(Webhook)이 40분 동안 서명 오류로 인해 튕겨 나갔습니다. 대시보드 카운터(이에 대해서는 나중에 더 자세히 다루겠습니다)가 있었기에 겨우 잡아낼 수 있었습니다. 이제 비밀키는 환경별로 교체되는 환경 변수(Environment variable)로 관리되며, 키가 어떤 모드에 속하는지 출력하는 시작 로그(Startup log) 라인을 두어 불일치가 발생하면 즉시 알 수 있도록 했습니다.
서명 검증(Signature verification)이 통과되면, 저는 바로 다음의 딱 한 가지 작업만 수행합니다. 즉시 200 응답을 반환하고 작업을 다음 패턴으로 넘기는 것입니다. Stripe는 몇 초 이내의 응답을 기대하며, 느린 핸들러(Handler)는 원치 않는 재시도(Retry)를 유발합니다.
패턴 2: 중복 전달에 대비한 멱등성 키 (Idempotency Keys)
Stripe는 웹훅이 정확히 한 번 도착한다고 보장하지 않습니다. 대신 '최소 한 번(At-least-once)' 전달을 보장합니다. 이는 동일한 checkout.session.completed 이벤트가 엔드포인트에 두 번, 때로는 세 번까지 도착할 수 있음을 의미하며, 특히 첫 번째 응답이 느렸을 때 더욱 그렇습니다. 보호 조치가 없다면, 한 번의 구매가 두 개의 주문 기록, 두 개의 다운로드 이메일, 즉 모든 것이 두 배로 생성됩니다.
모든 Stripe 이벤트는 evt_1abc...와 같이 생긴 고유한 id를 가지고 있습니다. 저는 이 id를 멱등성 키(Idempotency key)로 취급합니다. 무언가를 처리하기 전에, 해당 이벤트 id를 id 컬럼에 유니크 제약 조건(Unique constraint)이 걸린 processed_events 테이블에 삽입하려고 시도합니다. 삽입에 성공하면 이를 처음 보는 이벤트로 간주하고 정상적으로 처리합니다. 만약 유니크 제약 조건으로 인해 삽입에 실패한다면, 이미 처리된 이벤트이므로 200을 반환하고 중단합니다.
먼저 삽입하는 방식(insert-first approach)이 확인 후 삽입하는 방식(check-then-insert)보다 나은 이유는 경합 조건(race condition)을 피할 수 있기 때문입니다. 만약 동일한 이벤트의 복사본 두 개가 밀리초 단위 내에 도착한다면(실제로 발생합니다), 읽기 후 쓰기(read-then-write) 패턴은 둘 중 어느 것도 쓰기 작업을 수행하기 전에 둘 다 확인 단계를 통과하게 만듭니다. 데이터베이스 수준의 유니크 제약 조건(unique constraint)은 이러한 경합을 불가능하게 만듭니다. 오직 하나의 삽입만이 성공합니다.
저는 이벤트 ID, 타임스탬프(timestamp), 그리고 이벤트 유형(event type)만을 저장합니다. 그 외에는 아무것도 저장하지 않습니다. 덕분에 테이블은 작고 빠르게 유지됩니다. Stripe는 어차피 그렇게 오래된 이벤트는 재시도하지 않으므로, 예약된 작업(scheduled job)을 통해 30일이 지난 행들을 정리(prune)합니다. 제 테이블은 평범한 주간 기준으로 약 4,000개의 행을 유지하며, 인덱싱된 ID(indexed id)를 통한 조회는 즉각적입니다.
이 하나의 패턴이 초기 고객 지원 업무의 대부분을 차지했던 중복 이메일 발송 불만 사항을 제거했습니다. 이러한 패턴들을 실제 상점에 연결하는 방법에 대한 더 깊은 맥락이 궁금하다면, 제가 운영하는 유료 제품 설정 전체를 다루는 Claude Blueprint를 확인해 보세요.
미묘하지만 큰 이점은, 멱등성(idempotency)이 버그 수정 중에 이벤트를 수동으로 재전송(replay)할 때도 당신을 보호해 준다는 점입니다. 이미 처리된 이벤트는 건너뛰어질 것임을 알고 있으므로, 엔드포인트로 200개의 이벤트를 안전하게 다시 보낼 수 있습니다. 이러한 안전망이 바로 패턴 4를 두려움 없이 사용할 수 있게 만드는 핵심입니다.
패턴 3: 실패한 이벤트를 위한 데드 레터(Dead-Letter) 처리
제 이벤트의 약 0.3%는 첫 번째 시도에서 실패합니다. 데이터베이스 타임아웃(timeout), 제3자 API의 다운, 혹은 그날 오후에 배포한 버그 때문일 수 있습니다. 잘못된 대응은 500 에러를 반환하고 Stripe가 재시도하기를 기도하는 것입니다. Stripe는 재시도를 하지만, 그 일정은 몇 시간에 걸쳐 분산되며 결국 포기합니다. 만약 당신의 버그가 재시도 기간보다 오래 지속된다면, 해당 이벤트는 사라집니다.
저의 해결책은 데드 레터 테이블(dead-letter table)입니다. 처리에 오류가 발생하면, 이를 캐치(catch)하여 전체 이벤트 페이로드(payload)와 에러 메시지, 그리고 재시도 횟수(retry count)를 failed_events 테이블에 기록한 다음, Stripe에 200을 반환합니다. 200을 반환하는 것은 Stripe에 이벤트가 수신되었음을 알려주어 Stripe가 자체 일정에 따라 재시도하는 것을 중단하게 만듭니다. 이제 재시도의 주도권은 Stripe가 아닌 제가 갖게 됩니다.
예약된 작업 (Scheduled job)이 10분마다 실행되어 failed_events 테이블에서 재시도 횟수가 5회 미만인 모든 행을 가져옵니다. 이 작업은 저장된 페이로드 (payload)를 동일한 핸들러 (handler)를 통해 다시 처리합니다. 패턴 2가 멱등성 키 (idempotency keys)를 사용하기 때문에, 첫 번째 시도에서 부분적으로 성공했던 재처리가 중복을 발생시키지는 않습니다. 처리에 성공하면 해당 행을 해결됨 (resolved) 상태로 표시합니다. 만약 5번의 시도 모두 실패하면, 수동 검토 (manual review) 대상으로 플래그를 지정하고 저에게 알림을 보냅니다.
자동화보다 중요한 것은 알림입니다. 무효 편지함 (dead-letter) 테이블에 데이터가 쌓이고 스스로 복구될 수 없는 상황이 발생했을 때, 저는 2일 뒤 고객의 이메일을 통해서가 아니라 몇 분 이내에 그 사실을 알고 싶습니다. 저는 이 알림을 개인 채널과 제 휴대폰으로 전달합니다. 지난달에 다운로드 호스팅 제공업체의 장애로 인해 이벤트 하나가 멈춘 적이 있었습니다. 제 작업이 재시도를 시도했고, 22분 후 제공업체가 복구되자 다음 재시도에서 문제가 해결되었습니다. 고객은 아무것도 눈치채지 못했습니다.
또한 저는 모든 무효 편지함 (dead-letter) 기록을 카운터 (counter)와 함께 로그로 남깁니다. 하루 2건이던 기록이 갑자기 50건으로 급증한다면, 고객이 불만을 제기하기 전에 제가 배포 (deploy) 과정에서 무언가 망가뜨렸음을 알 수 있습니다. 이 카운터 덕분에 두 번이나 위기를 넘겼습니다. 배경 설명: 이러한 알림 습관은 제가 다른 모든 것을 모니터링하는 방식에도 그대로 적용되며, 이에 대해 Claude Blueprint에서 더 자세히 다루었습니다.
사고방식의 전환은 간단합니다. 이벤트는 실패할 것이라고 가정하십시오. 그리고 복구 경로를 먼저 구축하십시오.
패턴 4: 웹훅이 막혔을 때의 수동 재생 (Manual Replay)
앞선 3가지 패턴이 있더라도, 때로는 사람의 개입이 필요한 이벤트가 발생합니다. 예를 들어, 제가 버그가 있는 코드를 배포하여 12개의 이벤트가 5번의 자동 재시도를 모두 실패했을 수도 있습니다. 혹은 핸들러가 인식하지 못하는 새로운 제품 유형을 추가했을 수도 있습니다. 그런 일이 발생하면 저에게는 두 가지 복구 경로가 있으며, 두 가지 모두 5분 이내에 완료됩니다.
첫 번째 경로는 Stripe 대시보드입니다. Stripe의 모든 웹훅 엔드포인트(webhook endpoint)는 응답 코드와 함께 최근 전송된 로그를 보여줍니다. 저는 실패한 항목들을 필터링하고, 특정 이벤트를 클릭한 뒤 "Resend"를 누릅니다. 그러면 Stripe가 제 엔드포인트로 정확히 동일한 페이로드 (payload)를 다시 전송합니다. 저의 서명 검증 (signature verification)과 멱등성 (idempotency) 처리가 재전송을 깔끔하게 처리하므로, 이를 수십 번 반복해도 안전합니다. 한 번은 버그를 수정한 후 30개의 이벤트를 연속으로 재전송했는데, 재전송된 모든 이벤트가 올바르게 처리되었습니다.
두 번째 경로는 저만의 데드 레터 테이블 (dead-letter table)입니다. 만약 Stripe가 이미 전송을 포기하여 이벤트가 제 failed_events 행에만 남아 있다면, 저는 Stripe가 전혀 필요하지 않습니다. 저는 필요할 때 단일 행을 재처리할 수 있는 작은 관리자 액션 (admin action)을 가지고 있습니다. 근본적인 코드를 수정하고 배포한 다음, 재처리 (reprocess)를 클릭합니다. 저장된 페이로드가 수정된 핸들러 (handler)를 통해 실행됩니다.
재생 (replay)을 고통 없이 만드는 비결은 이를 초기에 결정하는 것입니다. 첫 장애가 발생한 후에 재생 기능을 덧붙이지 마세요. 첫날부터 원본 페이로드를 저장하세요. 저장하지 않은 실패한 이벤트는 복구할 수 없는 실패한 이벤트이며, Stripe는 제한된 기간 동안만 자체 복사본을 보관합니다. 전체 JSON을 저장하는 것은 비용이 거의 들지 않으며, 새벽 2시의 패닉을 차분한 클릭 한 번으로 바꿔줍니다.
또한 저는 코드 옆에 일반 텍스트로 된 짧은 런북 (runbook)을 작성해 둡니다. 세 단계입니다: 데드 레터 테이블 확인, 대시보드 재전송 또는 관리자 재처리 결정, 카운터가 0으로 떨어졌는지 확인. 단계가 적혀 있으면 압박감 속에서도 고민할 필요 없이 그냥 목록을 따르면 됩니다. 제가 스케줄링 및 신디케이션 (syndication) 흐름을 작성할 때도 동일한 런북 습관을 사용했으며, Buffer가 소셜 포스팅 측면을 처리해 준 덕분에 전체 파이프라인에 신경 쓰지 않고 결제 신뢰성에 집중할 수 있었습니다.
요점 (Bottom Line)
이 4가지 패턴은 결코 영리한 기술이 아닙니다. 서명 검증 (Signature verification)은 위조된 요청을 차단하고, 멱등성 키 (Idempotency keys)는 중복을 제거하며, 데드 레터 테이블 (Dead-letter table)은 실패를 포착하고, 수동 재전송 (Manual replay)은 나머지를 복구합니다. 이들이 결합되어 반복되던 골칫거리였던 주문 유실 문제를 1년 넘게 한 번도 발생하지 않은 문제로 바꾸어 놓았습니다. 전체 설정은 핸들러 코드 약 150줄과 두 개의 작은 테이블만으로 충분합니다.
Stripe를 통해 무엇인가를 판매하고 있다면, 첫 장애가 발생한 후가 아니라 출시하기 전에 복구 경로를 구축하십시오. 원본 페이로드 (Raw payload)를 저장하고, 고유 제약 조건 (Unique constraint)을 추가하며, 휴대폰으로 알림이 오도록 연결하십시오. 실패하는 0.3%의 이벤트가 바로 고객들이 기억하는 이벤트이므로, 그 이벤트들을 가장 완벽하게 처리할 수 있도록 만드십시오.
결제부터 호스팅, 그리고 Shopify 기반의 스토어 자체에 이르기까지, 이것이 전체 1인 제품 스택에 어떻게 적용되는지 알고 싶다면 Claude Blueprint에서 제가 매일 운영하는 전체 시스템을 확인할 수 있습니다. 이번 주에는 서명 검증 (Signature verification)부터 시작하십시오. 가장 빠르게 성과를 낼 수 있으며 가장 큰 허점을 메워줍니다.
이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면 귀하에게 추가 비용 부담 없이 저에게 소정의 수수료가 지급될 수 있습니다. (광고)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기