웹훅 완벽 가이드: 서명된 페이로드와 안정적인 재시도

Backend2026-10-23TryQuickToolBox

결제 연동을 배포하고, 제공자가 웹훅을 보내고, 엔드포인트가 200을 반환했습니다. 그런데 세 시간 뒤 지원팀에서 주문이 누락됐다고 보고합니다. 더 심한 경우도 있습니다. 같은 이벤트가 네 번 도착해서 고객에게 이중으로 청구되는 겁니다. 웹훅은 단순해 보이지만(URL로 보내는 HTTP POST), 어려운 부분은 신뢰와 전달 보장입니다.

이 가이드는 두 가지를 다룹니다. 서명된 페이로드로 해당 요청이 정말 발신자로부터 온 것인지 검증하는 방법, 그리고 다운타임·타임아웃·중복 전달에도 견디는 재시도 모델을 구축하는 방법입니다.

웹훅이란 정확히 무엇인가

웹훅은 역방향 API 호출입니다. 서버가 매분 GET /events를 폴링하는 대신, 이벤트가 발생하면 제공자가 등록된 URL로 HTTP 요청을 푸시합니다. 페이로드는 보통 JSON이고, 메서드는 거의 항상 POST이며, 반환하는 응답은 신호입니다. 2xx는 "수신됨", 그 외는 "나중에 다시 시도"를 뜻합니다.

이 한 문장이 계약의 전부입니다. 서명, 재시도, 순서 보장 같은 나머지 모든 것은 불안정한 네트워크 위에서 이 계약을 안전하게 만들기 위해 존재합니다.

서명 없는 웹훅이 위험한 이유

웹훅 엔드포인트는 공개 URL입니다. 이를 발견한 누구든 주문이 결제됐다고 주장하는 가짜 페이로드를 POST할 수 있습니다. 검증 없이 본문을 신뢰한다면, 청구 로직으로 통하는 열린 문을 만들어 놓은 셈입니다.

IP 허용 목록은 조금 도움이 되지만 CDN, 서버리스 이그레스, 제공자의 IP 변경 앞에서는 무너집니다. 견고한 해법은 공유 시크릿과 원본 요청 본문에 대한 HMAC 서명입니다.

서명된 페이로드의 작동 방식

제공자와 여러분은 시크릿 키를 공유합니다. 전달할 때마다 제공자는 원본 본문(때로는 타임스탬프와 본문)에 대한 HMAC(보통 SHA-256)을 계산하고, 16진수 다이제스트를 X-Signature 같은 헤더에 담아 보냅니다. 여러분은 보유한 시크릿으로 같은 다이제스트를 다시 계산해 비교합니다.

사람들이 생각하는 것보다 중요한 두 가지 세부 사항이 있습니다.

최소한의 검증 예제 (Node.js)

const crypto = require('crypto');

function verifySignature(rawBody, headerSig, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)          // raw Buffer 또는 문자열, 파싱된 JSON이 아님
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(headerSig || '', 'utf8');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

같은 패턴이 Python에서는 hmac.compare_digest, Go에서는 hmac.Equal, Java에서는 MessageDigest.isEqual로 동작합니다. 원리는 어디서나 동일합니다. 원본 바이트에 대한 HMAC, 상수 시간 비교.

타임스탬프를 이용한 재전송 방지

유효한 서명은 해당 페이로드가 시크릿을 가진 누군가로부터 왔음을 증명하지만, 가로챈 요청은 영원히 재전송될 수 있습니다. 이를 신경 쓰는 제공자는 서명 대상 문자열에 타임스탬프를 포함시킵니다. 예를 들어 t=1712345678,v1=<hex> 같은 형식입니다. 여러분 쪽에서는:

  1. 헤더에서 타임스탬프를 파싱합니다.
  2. 허용 시간 범위(흔히 5분)보다 오래된 요청이면 거부합니다.
  3. timestamp + "." + rawBody에 대해 HMAC을 다시 계산합니다.

이렇게 하면 요청이 유출되더라도 피해 범위가 영원이 아니라 몇 분으로 제한됩니다.

실제로 전달되는 재시도 설계

네트워크는 실패하고, 서비스는 재시작되며, 배포는 파드를 교체합니다. 진지한 제공자는 실패한 전달을 일정에 따라 재시도합니다. 진지한 수신자는 모든 이벤트가 한 번 이상 도착할 수 있다고 가정해야 합니다.

발신자 측 재시도 규칙

수신자 측 규칙

  1. 먼저 검증하고, 그다음 파싱하세요. 데이터베이스를 건드리기 전에 잘못된 서명은 401로 거부합니다.
  2. 이벤트를 저장한 뒤 200을 반환하세요. 원본 페이로드와 이벤트 ID를 큐나 테이블에 기록하고 즉시 응답한 뒤 워커에서 처리합니다. 처리가 실패하면 내부적으로 재시도합니다.
  3. 핸들러를 멱등하게 만드세요. 제공자의 이벤트 ID를 고유 키로 사용합니다. 이미 존재하면 부수 효과를 다시 실행하지 않고 200을 반환합니다.
  4. 오류를 정직하게 반환하세요. 데이터베이스가 다운됐다면 제공자가 재시도하도록 500을 반환합니다. 200을 반환하고 이벤트를 조용히 버리지 마세요.
응답발신자에게 의미다음에 일어나는 일
200 / 204전달됨재시도 없음
400형식 오류 또는 거부됨보통 재시도 없음, 디버깅용 로그 기록
401서명 무효재시도 없음, 시크릿 조사 필요
429속도 제한백오프 후 재시도
5xx / 타임아웃일시적 실패백오프 후 재시도

실전에서의 멱등성

여기서 멱등성은 유행어가 아닙니다. 재시도와 이중 청구 사이를 막아주는 유일한 장치입니다. 패턴은 다음과 같습니다.

INSERT INTO webhook_events (event_id, payload, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING;

삽입이 0행에 영향을 주면 이미 본 이벤트이므로 확인 응답하고 넘어갑니다. 동시 전달 상황에서도 고유 제약 조건이 데이터베이스 수준에서 직렬화해 주기 때문에 이 방식은 동작합니다.

정확히 한 번만 일어나야 하는 부수 효과(이메일 발송, 카드 청구)의 경우, 하위 서비스로 자체 멱등성 키를 전달하세요. 대부분의 결제 및 이메일 API가 이를 받아들입니다.

순서 보장, 디버깅, 관측성

이벤트가 순서대로 도착한다고 가정하지 마세요. 재시도는 전달 순서를 바꿀 수 있고, 병렬 워커도 마찬가지입니다. 순서가 중요하다면 페이로드에 시퀀스 번호나 타임스탬프를 포함시키고, 델타를 무작정 적용하기보다 상태를 조정(reconcile)하세요.

모든 전달 시도를 이벤트 ID, 서명 결과, 응답 코드, 처리 시간과 함께 로그로 남기세요. 고객이 "제 주문이 도착하지 않았어요"라고 할 때, 몇 초 안에 답할 수 있어야 합니다. 웹훅이 수신됐는지, 서명이 유효했는지, 처리가 성공했는지. 이벤트마다 구조화된 로그 한 줄이면 충분합니다.

트래픽이 많은 엔드포인트라면 배포 후 재시도 폭주를 주의하세요. 이를 빠르게 발견하는 방법은 액세스 로그의 요청 패턴을 보는 것입니다. 한 IP에서 동일한 경로로 5xx 응답이 급증한다면 전형적인 재시도 루프입니다. Nginx 로그 분석기 같은 도구를 쓰면 쿼리를 직접 작성하지 않고도 그 패턴을 쉽게 확인할 수 있습니다.

피해야 할 흔한 실수

FAQ

본문이 JSON일 때 웹훅 서명은 어떻게 검증하나요?

JSON 파싱 전에 원본 요청 본문을 바이트로 읽고, 그 바이트에 대한 HMAC을 계산한 뒤 상수 시간 함수로 헤더와 비교하세요. 다시 직렬화한 JSON은 다른 다이제스트를 만들어냅니다.

웹훅은 몇 번 재시도해야 하나요?

보편적인 정답은 없지만, 지수 백오프와 지터를 사용해 몇 시간에 걸쳐 5~10회 시도하는 것이 일반적입니다. 중요한 것은 수신자가 멱등이라는 점이며, 그래야 재시도가 잦아도 안전합니다.

웹훅 엔드포인트는 어떤 상태 코드를 반환해야 하나요?

이벤트를 내구성 있게 저장한 뒤에만 2xx를 반환하세요. 잘못된 서명에는 401, 형식이 잘못된 페이로드에는 400, 발신자에게 재시도를 원할 때는 5xx 또는 429를 반환합니다.

웹훅 페이로드를 테스트하거나 수신한 JSON을 로깅한다면, JSON 포매터를 사용해 파싱 로직을 만들기 전에 캡처한 이벤트를 빠르게 예쁘게 출력하고 검증할 수 있습니다.