에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드

[백엔드] 웹훅 연동의 함정: 중복 수신, 순서 역전, 유실 그리고 대사(reconciliation)

반응형
[백엔드] 웹훅 연동의 함정: 중복 수신, 순서 역전, 유실 그리고 대사(reconciliation)

[백엔드] 웹훅 연동의 함정: 중복 수신, 순서 역전, 유실 그리고 대사(reconciliation)

결제사·외부 플랫폼 연동의 절반은 웹훅 받기예요. "이벤트가 오면 처리한다" — 단순해 보이는데, 막상 운영하면 같은 웹훅이 두 번 와요. 취소가 승인보다 먼저 와요. 와야 할 게 안 와요. 웹훅은 친절한 알림이지 신뢰할 수 있는 전달 채널이 아니거든요.

이 글은 웹훅의 3대 불확실성(중복·순서 역전·유실), 수신 처리의 정석(빠른 ACK + 멱등 처리), 그리고 돈이 걸린 연동의 필수 장치인 대사(reconciliation)까지 짚어볼게요. 함정 시리즈 7편이에요.

01. 웹훅을 믿으면 안 되는 세 가지 이유

웹훅의 3대 불확실성. 응답 유실 시 상대가 재전송해 중복이 오고, 재시도와 병렬 전송으로 취소가 승인보다 먼저 도착하는 순서 역전이 생기며, 상대 장애나 내 다운타임에 영영 유실될 수도 있다
  • 중복 — 상대도 그 비대칭을 똑같이 겪어요. 내가 200을 줬는데 그 응답이 유실되면, 상대는 "전달 실패"로 보고 재전송해요. 성실한 외부사일수록 재시도를 잘 하니까, 중복은 연동에서 늘 깔리는 기본 조건이에요.
  • 순서 역전 — "승인 → 취소" 순서로 발생한 이벤트가 "취소 → 승인" 순서로 도착할 수 있어요. 앞 이벤트가 실패해 재시도되는 사이 뒤 이벤트가 먼저 성공하는, 카프카에서 본 그 역학이에요. 발생 순서와 도착 순서는 다른 거예요.
  • 유실 — 상대 장애, 내 배포 순간의 5xx, 재전송 한도 소진... 영영 안 오는 웹훅도 있어요. "웹훅만 믿고 상태를 확정"하면 그 구멍이 그대로 데이터 불일치가 돼요.

요컨대 웹훅 수신자는 외부 회사를 브로커로 둔 at-least-once 컨슈머예요. 카프카 시리즈에서 배운 규율이 그대로 적용돼요 — 단지 상대 설정을 우리가 못 만질 뿐이죠.

02. 수신의 정석, 빨리 받고 비동기로 처리

웹훅 수신 시퀀스. 외부사의 POST를 받으면 서명을 검증하고 원문을 저장한 뒤 즉시 200을 응답하며, 처리는 워커가 비동기로 미처리 이벤트를 픽업해 이벤트 ID 기반 멱등 처리와 상태 전이 규칙으로 순서를 판정한다. 처리를 끝내고 응답하면 상대 타임아웃으로 재전송 중복이 쌓인다

① 서명 검증부터

웹훅 엔드포인트는 공개 URL이에요. 아무나 가짜 "결제 완료"를 쏠 수 있죠. 상대가 제공하는 시크릿 기반 서명(HMAC 등)을 반드시 검증해요. 이거 없는 웹훅 수신은 보안 사고 대기예요.

② 저장하고 즉시 200

흔한 실수가 웹훅 핸들러 안에서 무거운 처리(DB 갱신 + 알림 + 후속 API)를 동기로 다 하는 거예요. 처리가 길어지면 상대 입장에선 타임아웃 → 재전송 → 중복 증폭의 악순환이에요. 정석은 받자마자 원본을 저장(DB나 큐)하고 즉시 200 — "잘 받았다"와 "잘 처리했다"를 분리하는 거예요. 처리 실패는 내 쪽 재시도로 풀고요. 어디서 봤죠? Streams·카프카 컨슈머의 ack 분리와 같은 사고방식이에요.

③ 이벤트 ID로 멱등 처리

상대가 주는 이벤트 고유 ID(없으면 본문 해시)로 처리 이력을 남기고 이미 처리한 ID는 건너뛰어요. 컨슈머 멱등성의 그 방법들 그대로요. 단, "이미 처리한 이벤트"에도 200을 응답해야 해요 — 에러를 주면 상대가 또 재전송하니까요.

④ 순서 역전은 상태 머신으로

"이벤트를 도착 순서대로 적용"하면 역전에 무너져요. 대신 상태 전이 규칙으로 판정해요 — 현재 상태와 이벤트의 조합이 유효한지 봐요. 유효하지 않으면(승인 없는 취소) 보류했다가 재평가하거나, 이벤트의 타임스탬프·버전으로 "더 옛날 이벤트의 늦은 도착"을 무시해요. "취소된 주문에 승인이 늦게 와도 상태가 안 되돌아가는" 게 규칙으로 보장되는 거예요.

03. 대사, 웹훅은 알림이고 진실은 조회로

대사 reconciliation 구조. 웹훅은 빠르지만 유실 가능한 실시간 경로이고, 주기 배치가 상대 조회 API로 내 상태와 비교해 보정하는 경로가 유실을 복구한다. 불일치 건수는 조기 경보 지표가 된다

유실 문제의 근본 해법은 웹훅을 더 잘 받는 게 아니라, 웹훅 없이도 진실에 도달하는 경로를 따로 두는 거예요. 두 경로 구조예요.

  • 실시간 경로 — 웹훅. 빠르게 상태를 반영하지만 유실될 수 있어요.
  • 보정 경로 — 주기 배치가 상대의 조회 API로 직접 물어봐요. 특히 "PENDING 상태로 N분 넘게 머문 건"을 조회해서 확정해요. 웹훅이 안 왔어도 결국 맞는 상태로 수렴해요.

이게 대사(reconciliation)예요. 결제·정산처럼 돈이 걸린 연동에서는 옵션이 아니라 필수고요. 그리고 대사에서 발견되는 불일치 건수를 지표로 걸어두면 — 웹훅 유실이나 내 처리 버그의 조기 경보가 돼요. 평소 0이던 불일치가 늘기 시작하면 뭔가 터지고 있는 거예요.

04. 보내는 쪽이 될 때도 같은 예의를

거꾸로 우리가 웹훅을 발송하는 입장이면, 받는 쪽이 위 가정을 할 수 있게 해주는 게 예의예요 — 이벤트 고유 ID와 타임스탬프를 싣고, 서명을 제공하고, 실패 시 백오프 재시도를 하되 한도를 두고, 놓친 이벤트를 조회할 API를 같이 열어주는 것. 그리고 발송 자체는 아웃박스로 "커밋됐으면 반드시 발송"을 보장하고요. 좋은 웹훅 공급자의 체크리스트가 곧 좋은 수신자의 가정 목록이에요.

05. 자주 만나는 문제

같은 결제 완료 처리가 두 번 됐어요

이벤트 ID 멱등 처리가 없는 거예요. 처리 이력 테이블(유니크 제약)을 깔고 중복 수신에도 200을 응답해요.

취소했는데 상태가 다시 승인으로 돌아갔어요

늦게 도착한 옛 이벤트가 상태를 덮은 거예요. 상태 전이 규칙(최종 상태에서 역행 금지)과 이벤트 타임스탬프 비교를 넣어요.

웹훅이 안 와서 주문이 PENDING에 멈춰 있어요

유실이에요. "N분 넘은 PENDING 건 조회·확정" 대사 배치가 답이에요. 이미 있다면 그 주기·범위를 점검해요.

웹훅 핸들러가 느려서 상대가 계속 재전송해요

동기 처리 과적이에요. 저장 후 즉시 200 + 비동기 처리로 분리해요.

정리

웹훅 연동의 본질은 한 문장이에요 — 웹훅은 빠른 알림일 뿐, 진실은 멱등한 처리와 대사가 만든다. 그래서 서명을 검증해요. 저장 후 즉시 200을 주고(처리는 비동기로) 이벤트 ID로 멱등하게 처리해요. 순서는 도착 순서가 아니라 상태 전이 규칙으로 판정해요. 그리고 유실은 대사(reconciliation)로 보정하고 그 불일치 건수를 조기 경보 지표로 걸어둬요. 결국 카프카 컨슈머의 규율을 회사 바깥 경계에 그대로 적용하는 일이에요.

여기까지가 "받는 쪽"의 함정이었어요. 다음은 모든 게 터진 다음에 봐야 하는 곳 — JVM 메모리·스레드 진단으로, 함정 시리즈를 마무리합니다.

출처: Stripe — Webhooks best practices · webhooks.fyi — Webhook Security & Reliability

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."