웹후크 실패는 소리를 내지 않는다
이 글은 결제, 깃허브, 알림 같은 외부 서비스와 웹후크로 연동하는 1인 개발자와 소규모 SaaS 팀을 위한 것입니다. 읽고 나면 얻는 것이 하나 있습니다. 보내는 쪽의 약속은 ‘최소 한 번’뿐이고 나머지는 전부 내가 받는 쪽에서 지어야 한다는 것입니다.
결론부터 말합니다. 웹후크 실패는 소리를 내지 않습니다. 크래시는 안전한 실패입니다. 500을 남기면 보내는 쪽이 다시 보내니까. 위험한 쪽은 예외를 던지지 않고 답만 틀리는 엔드포인트입니다. 편지가 조용히 사라지거나, 보내는 쪽이 영원히 같은 쓰레기를 배달하게 됩니다.
반복되는 장면이 있습니다. 첫날 테스트 이벤트가 도착해 대시보드가 초록이 됩니다. 둘째 날 첫 실전 이벤트의 서명 검증이 실패합니다. 셋째 날 같은 이벤트가 5초 뒤에 또 와서 고객이 라이선스 메일을 두 번 받습니다. 다섯째 날 재배포 후 환불이 결제보다 먼저 도착하고 최종 상태는 ‘발급됨’입니다. 이 네 사건 모두 보내는 쪽의 정상 동작입니다. 이 글은 그 계약에 맞는 수신 장치를 순서대로 논증합니다.
글의 핵심 개념을 형상화했습니다.
보내는 쪽의 약속은 ‘최소 한 번’뿐이다
폴링은 보내는 쪽의 문을 1분마다 두드리는 것이고 웹후크는 그쪽이 내 문을 두드리는 것입니다. 통제의 방향이 바뀝니다. 폴링에서는 내가 타이밍을 잡고 상대는 답만 합니다. 웹후크에서는 상대가 타이밍을 잡고 나는 받아야 합니다. 설계 부담이 ‘언제 물을지’에서 ‘아무거나 올 수 있는 것을 어떻게 받을지’로 이동합니다.
보내는 쪽이 내게 해 주는 약속은 정확히 하나입니다. 최소 한 번. 정확히 한 번도 아니고 순서대로도 아닙니다. 보내는 쪽은 2xx를 정해진 시간 안에 받지 못하면 다시 보냅니다. 처리까지 성공했는데 응답 패킷이 네트워크에서 사라졌다고 해 보겠습니다. 보내는 쪽 타이머가 먼저 다 됩니다. 같은 편지가 두 번 옵니다. 두 번 모두 합법적인 동작입니다.
재시도는 양날입니다. 잘 쓰면 보내는 쪽의 재시도가 내 안전망이 됩니다. 데이터베이스가 20분 죽어 있어도 편지는 사라지지 않고 보내는 쪽이 보관하며 계속 두드립니다. 반면 내 핸들러에 버그가 있으면 모든 재시도가 같은 벽에 부딪힙니다. 재시도의 횟수와 총 기간은 서비스마다 다르지만, 대개 하루 이틀을 넘기지는 않습니다 [추정].
보내는 쪽 서버는 느려질 수 있고 재시도할 수 있고 아무에게도 말하지 않고 형식을 바꿀 수 있습니다. 엔드포인트가 한 시간 죽어 있으면, 그 사이 쌓인 메시지가 돌아온 뒤 한꺼번에 쏟아집니다.
그래서 설계 전제는 명확합니다. 여러 번 올 수 있고 순서가 뒤집혀 올 수 있고 안 올 수도 있고 위조돼 올 수도 있습니다. ‘정확히 한 번만 온다’로 설계하면 재시도가 처음 발생하는 순간 시스템이 깨집니다. 이 네 가지 가능성에 대해 받는 쪽은 네 가지 약속을 지킵니다. 보내는 이를 믿고 중복되지 않고 잃지 않고 무너지지 않는 것입니다.
NotebookLM이 소스를 종합해 생성한 인포그래픽입니다.
편지 상자는 공개 주소다. 보내는 이를 확인하라
웹후크 URL이 노출될 자리는 많습니다. 서버 로그, 벤더 설정 화면, 프록시. URL을 아는 사람은 누구나 ‘결제 완료’ 메시지를 만들어 올 수 있습니다. 위조자는 secret이 필요 없고 JSON 모양만 복사하면 됩니다. 바디를 그대로 처리한다면, 위조자는 내 제품을 무료 구입한 셈입니다.
IP 허용 목록은 충분한 답이 아닙니다. 보내는 쪽은 IP 범위가 여러 개이고 예고 없이 바꿉니다. 목록을 하드코딩하면, 그쪽이 바꾼 날 내 엔드포인트가 죽습니다. 허용 목록에 없는 IP에서 온 편지는 전부 버리는데, 보내는 쪽이 새 범위로 이주한 날에는 실전 이벤트가 전부 버려집니다.
진짜 답은 서명입니다. 나와 보내는 쪽은 secret이라는 키를 공유합니다. 보내는 쪽은 이 키로 메시지에 대한 해시를 계산해 헤더에 실어 보내고, 내 쪽은 같은 secret으로 다시 계산합니다. 둘이 맞으면 ‘이 편지는 secret을 아는 자가 쓴 것’입니다. secret 자체는 네트워크를 지나가지 않습니다. 지나가는 것은 secret의 효과뿐입니다. 그래서 위조가 비싸집니다. 알고리즘은 대개 HMAC-SHA256입니다.
‘secret을 바디 뒤에 붙여 sha256을 돌리면 되지 않나’는 유혹이 있습니다. 안 됩니다. 해시 함수의 내부 구조를 아는 공격자는, 원래 해시 값과 secret을 모르고서도 바디 뒤에 임의의 데이터를 이어 붙인 새 메시지의 해시를 만들어낼 수 있습니다. HMAC은 해시를 두 번 돌리며 secret을 두 갈래로 뿌려 이 문제를 구조적으로 회피합니다. 표준 알고리즘이 존재할 때, 스스로 인증을 발명할 이유는 없습니다.
실무에서 무너지는 지점은 서명에 들어가는 문자열의 모양입니다. 서명은 보낸 그대로의 raw 바이트에 대해 계산됩니다. 파싱해서 다시 직렬화한 값이 아닙니다. 공백 하나, 키 순서 하나가 해시를 바꾸고 로컬에서는 되고 프로덕션에서만 깨지는 고전적 버그의 정체가 여기 있습니다. GitHub는 raw 바디의 HMAC에 sha256= 접두어를 붙이고, Stripe는 타임스탬프가 서명에 참여해 ‘t’와 마침표와 바디를 이어 서명합니다. 서명 문자열 둘은 항상 같은 시간에 끝나는 비교로 대조해야 합니다. 평범한 등호 비교라면, 공격자는 응답 시간을 재며 해시를 한 바이트씩 맞춰 갈 수 있습니다.
남은 공격은 도난입니다. 재생 공격. 유효한 서명과 바디는 내 서버 로그나 프록시에 남아 있을 수 있고 그것을 나중에 다시 보내면 서명 검증은 그대로 통과합니다. 진짜 편지니까. 방어는 두 겹입니다. 첫 번째는 시간. 타임스탬프가 서명에 들어 있으면 정해진 시간, 대개 5분, 이상 지난 편지는 무효입니다. 두 번째는 중복 검사. 재생된 편지에는 event id가 있고 이미 처리한 id면 낡은 편지로 판단해 버립니다. 서명은 ‘이건 진짜냐’에 답하고, 중복 테이블은 ‘이건 새것이냐’에 답합니다. 재생은 의도가 있는 중복입니다.

중복은 예외가 아니라 정상값이다
웹후크 수신자에게 중복은 예외가 아니라 정상값입니다. 보내는 쪽은 최소 한 번을 약속합니다. 그래서 엔드포인트는 두 조건을 만족해야 합니다. 같은 편지를 두 번 처리해도 결과가 같아야 하고, 늦게 온 편지나 순서가 뒤집힌 편지가 상태를 깨지 않아야 합니다.
가장 단순한 구조는 이미 받은 event id를 기록하는 테이블입니다. 편지가 도착하면 event id를 insert합니다. 성공하면 처음 온 것이고 중복 키로 실패하면 이미 받은 것입니다. primary key 자체가 필터입니다. 중복 편지에 대해서도 2xx를 답해야 합니다. 보내는 쪽은 2xx를 받을 때까지 재시도를 멈추지 않습니다. 중복에 400을 답하면, 보내는 쪽은 영원히 중복을 배달하고 내 로그는 쓰레기로 가득 찹니다.
순서가 중요합니다. 200을 돌려주기 전에 테이블에 씁니다. 처리를 먼저 하고 쓰기를 나중에 하면, 그 사이 죽는 순간 편지를 잃고 잃었는지조차 모릅니다. ‘받았지만 아직 처리하지 않았다’는 상태를 두면, 테이블이 곧 큐가 됩니다. 서버를 시작할 때 pending인 편지를 다시 읽으면 됩니다.
중복 테이블에 하나 함정이 있습니다. 보존 기간입니다. 영원히 키면 테이블은 쓸모없는 돌탑이 됩니다. received_at 기준으로 보존 기간을 정해 그 뒤의 행을 지우는 cron 하나면 충분합니다. 다만 기간은 보내는 쪽의 최대 재전송 기간보다 길어야 합니다. 보내는 쪽이 3일까지 재전송하는데 내가 1일 뒤 기록을 지우면, 3일 차 재전송은 ‘처음 온 편지’로 취급됩니다.
중복 테이블은 같은 id의 편지만 막아 줍니다. ‘다른 id로 온 같은 사실’은 막지 못합니다. ‘결제 성공’ 이벤트에 라이선스를 발급했는데, 얼마 뒤 같은 결제에 ‘인보이스 결제’ 이벤트가 옵니다. 사실은 하나인데 타입은 둘이고 id는 다릅니다. 로직이 ‘라이선스를 하나 추가한다’면 두 번 추가됩니다. ‘고객의 상태를 licensed로 세팅한다’면 몇 번을 돌려도 결과는 하나입니다. 쿠폰을 더하는 것은 연산이고 플래그를 세팅하는 것은 상태입니다. 중복 테이블은 1차 방어선이고 멱등 로직은 마지막 방어선입니다.
다음은 순서입니다. 서버가 잠깐 죽어 있어서 ‘환불’이 먼저 도착했다고 해 보겠습니다. 아직 발급하지도 않은 결제를 환불하려다 실패하고 그다음 ‘결제 완료’가 오면 라이선스를 발급합니다. 최종 상태는 ‘발급됨’인데, 고객은 환불했습니다. 돈이 새는 구조입니다. 답은 수신자가 올바른 순서를 기다리는 것이 아니라, 상태 기계입니다. 합법적인 이동만 허용하고 알 수 없는 이동은 추측하지 말고 보내는 쪽 API에 현재 상태를 물어봅니다. 웹후크 바디는 스냅샷이고 API의 답은 현재입니다.

내 응답 코드는 보내는 쪽에게 내리는 명령이다
그럼 무엇을 답할까요. 엔드포인트의 응답 코드는 보내는 쪽에게 내리는 명령입니다. 2xx는 ‘받았다, 다시 보낼 필요 없다’. 4xx는 ‘이 편지가 잘못됐다, 다시 보내지 마라’. 5xx는 ‘내 쪽이 고장 났다, 나중에 다시 보내라’. 타임아웃은 5xx와 같습니다. 응답 코드는 프로토콜입니다.
첫 번째 규칙은 ‘못한다’와 ‘안 한다’를 구분하는 일입니다. 안 한다는 것은 페이로드 형식이 틀리고 이벤트 타입을 모르며 검증이 실패한 경우입니다. 재시도가 내용을 바꾸지 못합니다. 그래서 4xx. 못한다는 것은 데이터베이스가 죽어 있고 하류 API가 타임아웃되고 버그가 예외를 던진 경우입니다. 재시도가 성공할 가능성이 있습니다. 그래서 5xx.
| 상황 | 답 | 이유 |
|---|---|---|
| 검증 실패, 형식 오류 | 4xx | 재시도가 편지를 바꾸지 못합니다 |
| DB 다운, 하류 타임아웃 | 5xx | 재시도가 성공할 수 있습니다 |
| 중복, 이미 받은 event | 2xx | 보내는 쪽의 재전송 루프를 끊습니다 |
| 안 구독한 이벤트 타입 | 2xx + 시끄러운 로그 | 내 일이 아니지만, 알려 주십시오 |
가장 위험한 실수는 ‘못한다’에 200을 돌려주는 것입니다. 보내는 쪽은 배달 완료로 여겨 다시 보내지 않고, 편지는 조용히 사라집니다. 에러도, 로그도, 알림도 없습니다. 고객이 ‘결제했는데 안 된다’고 글을 보낼 때야 비로소 알아차립니다. 소리 없는 실패는 상태 코드 한 칸에서 태어납니다. 두 번째로 위험한 실수는 ‘안 한다’에 500을 돌려주는 것입니다. 보내는 쪽은 영영 성공하지 못할 편지를 계속 재전송하고, 내 로그와 중복 테이블이 쓰레기로 가득 찹니다.
예상하지 못한 이벤트 타입이 오면 어떻게 답할까요. ‘못한다/안 한다’ 질문의 다른 모양입니다. 정말로 구독한 타입인데 핸들러를 아직 못 만들었다면 500을 답합니다. 이 고장을 알아채고 싶으니까. 안 구독한 타입이 온다면 200을 답하고 시끄럽게 로그를 남깁니다. 전자는 내가 고칠 bug이고 후자는 바깥세계에서 온 소문입니다.

편지가 죽을 때: 데드레터와 재플레이
보내는 쪽 대시보드에는 실패한 편지가 주차되는 자리가 있습니다. 하지만 그것은 보조일 뿐 기초가 될 수 없습니다. 유효 기간이 지나면 사라질 수 있고 형식이 바뀔 수 있으며 잃어버린 편지를 찾으려면 벤더 콘솔에 로그인해야 하는 구조입니다. 내 쪽에 기록을 둡니다. 원본 편지와, 그 편지에 무슨 일이 있었는지를.
원본, 즉 raw 바디를 저장하는 일이 핵심입니다. 로그에 남은 바디는 내 코드가 파싱하고 다시 직렬화한 값이므로, 보내는 쪽이 실제로 보낸 것과 다를 수 있습니다. raw 바이트만이 같은 서명 검증으로 재실행할 수 있는 유일한 출처입니다. headers도 secret을 제외한 채로 저장합니다.
편지는 언제 죽는가. 단순한 기준이 있습니다. 처리 실패 3회, 혹은 24시간 이상 미처리입니다. 해당 행을 데드레터 테이블로 옮기는 cron 하나면 충분합니다. 그 순간 나에게 알림을 보냅니다. 알림은 나 자신에게 오는 문자입니다. 채널이 중요한 게 아니라, 내가 알아채는지가 중요합니다.
데드레터를 발견한 뒤의 복구 절차는 고정되어 있습니다. 첫째, 버그를 고칩니다. 고장 난 핸들러에 재전송하면 데드레터만 더 생깁니다. 둘째, 알려진 정상 이벤트로 파이프라인이 살아있는지 확인합니다. 셋째, 원래 순서대로, 여러 개면 received_at 순서로 재전송합니다. 넷째, 반복은 이미 안전합니다. 중복 테이블과 멱등 로직 덕입니다. 다섯째, API로 간극을 메웁니다. 다운이 길었으면 보내는 쪽이 전달하지도 못했고 id조차 모르는 이벤트가 있습니다. 중요한 엔티티에 현재 상태를 물어봅니다. raw 바디를 저장해 두면, 이 전체 절차는 10분짜리 작업이 됩니다.
운영에서 볼 숫자는 세 개면 충분합니다. 첫 번째는 실패입니다. 최근 1시간의 5xx와 예외 수. 0이면 좋고 연속이면 문제는 코드입니다. 실패가 반복되면 코드 한 줄이 틀린 것이고 빨리 고치는 것이 운영 규율입니다. 두 번째는 정체입니다. 가장 오래된 pending 행의 received_at부터 지금까지의 경과. 편지가 처리되지 않은 채 앉아 있으면, 뭔가 막혀 있습니다.
세 번째는 침묵입니다. 마지막 편지가 도착한 지부터의 시간. 이것이 제일 미묘합니다. 웹후크 스트림에는 기준선이 있습니다. 항상 편지가 오던 결제 서비스가 갑자기 조용해지면, 이상 신호입니다. URL 변경, secret 교체, 보내는 쪽 사고라는 가능성들. 오지 않는 것 자체도 이벤트입니다. 기준선은 평소 침묵 기간으로 정합니다. 대충 잡으면 침묵 경고가 매번 거짓 경보가 되고, 거짓 경보 세 번이면 사람은 경보에 반응하지 않습니다.

엔드포인트를 열기 전: 테스트 세 단계
URL을 진짜 세상에 넘기기 전에, 세 단계로 테스트합니다. 첫 번째, 형식 테스트. 페이로드를 직접 만들어 내 secret으로 서명하고 로컬 엔드포인트에 POST합니다. 20줄짜리 스크립트로 raw 바디, 헤더 파싱, 서명 비교의 버그를 잡습니다. 두 번째, 보내는 쪽 테스트. 대시보드의 ‘테스트 이벤트 전송’을 써서 secret, URL, 이벤트 구독이 제대로 맞물리는지 확인합니다.
세 번째, 실패 테스트. 가장 흔히 건너뛰는 단계이자, 전체 설계를 증명하는 곳입니다. 의도적으로 500을 한 번 답하고 보내는 쪽이 재전송하는지를, 두 번째를 중복 테이블이 잡는지를 확인합니다. 출시 전에 합니다. 테스트에서 중복이 안 보이면 두 가지 중 하나입니다. 보내는 쪽이 재전송하지 않는 것(문서 확인)이거나, 내 중복 검사가 안 되는 것(버그).
이 모든 것 전에, 보내는 쪽 문서를 향해 네 가지 질문을 합니다. 이 편지가 진짜 보내는 쪽의 것인지 어떻게 알지? 하나의 전달을 유일하게 식별하는 id는 뭐지? 내가 답을 안 하면 그들은 뭘 하지? 내가 500을 답하면 그들은 뭘 하지?
보내는 쪽의 약속은 하나입니다. 최소 한 번. 나머지는 내 쪽의 네 장치입니다. 보내는 이를 확인하는 서명, 중복과 순서를 안전하게 삼키는 멱등 테이블과 상태 기계, 참을 말해 주는 응답 코드, 잃어버린 편지를 데려오는 데드레터와 재플레이 루틴. 엔드포인트를 열기 전에 이 네 가지를 세우면, 보내는 쪽의 재시도 기전은 내 안전망이 됩니다. 이 네 약속을 코드와 예시로 더 깊이 펼쳐 보고 싶다면, 전자책 ‘웹후크의 규율’이 33페이지 동안 바로 그 장치를 세웁니다.
참고 자료
본문의 서명 검증, 응답 코드 규율, HMAC, 상수 시간 비교는 아래 자료와 대조해 보실 수 있습니다.
- Validating webhook deliveries (GitHub Docs)
- Handling webhook deliveries (GitHub Docs)
- Receive Stripe events in your webhook endpoint (Stripe Docs)
- RFC 2104: HMAC - Keyed-Hashing for Message Authentication (RFC Editor)
- hmac - compare_digest (Python Docs)
NotebookLM이 소스를 종합해 생성한 인포그래픽입니다.