웹훅 멱등성과 재시도를 안전하게 설계하는 법

웹훅은 외부 서비스가 이벤트 발생 시 우리 엔드포인트로 HTTP 요청을 보내는 역방향 통신입니다. 편리하지만 분산 시스템의 현실을 그대로 안고 있습니다. 같은 이벤트가 두 번 이상 전송될 수 있고, 도착 순서가 발생 순서와 다를 수 있으며, 어떤 이벤트는 늦게 오거나 누락될 수도 있습니다. 따라서 안전한 수신기는 “한 번만 정확히 도착한다”는 가정을 버리고 설계해야 합니다. 핵심은 네 가지입니다. 이벤트 ID로 중복을 걸러 내는 멱등 처리, 서명 검증으로 위조를 막는 인증, 빠른 200 응답 후 비동기 처리로 재시도 폭주를 막는 흐름, 그리고 순서 비보장을 전제로 한 상태 설계입니다. 아래에서 차례로 정리합니다.

웹훅 중복 전송은 정상이다

먼저 받아들여야 할 사실은, 웹훅 중복이 버그가 아니라 정상 동작이라는 점입니다. 대부분의 제공사는 “최소 한 번 전송(at-least-once delivery)”을 보장합니다. 이벤트가 누락되는 것보다 중복되는 편이 낫다는 판단입니다. 그 결과 같은 이벤트가 두 번, 세 번 도착하는 일이 실제로 일어납니다.

중복이 발생하는 경로는 여러 가지입니다. 수신기가 처리에 성공했지만 응답이 네트워크에서 유실되면, 송신자는 실패로 간주해 재전송합니다. 수신기가 너무 늦게 응답해 송신자가 타임아웃을 내면 역시 재전송합니다. 송신자 측 인프라가 재배포·장애 복구를 거치며 큐의 이벤트를 다시 흘려보내기도 합니다. 어느 경우든 우리 수신기 입장에서는 “같은 이벤트가 또 왔다”로 보입니다.

이 현실을 무시하고 매 요청을 그대로 처리하면, 같은 결제가 두 번 기록되거나, 같은 알림이 중복 발송되거나, 재고가 두 번 차감되는 사고가 납니다. 그래서 웹훅 수신기의 첫 번째 설계 원칙은 멱등성, 즉 같은 이벤트를 여러 번 처리해도 결과가 한 번 처리한 것과 같아지도록 만드는 것입니다.

중복·이상 상황발생 원인수신기의 전제
같은 이벤트 재도착응답 유실·타임아웃으로 재전송멱등 처리로 흡수
순서 역전지연·병렬 전송버전·타임스탬프로 판단
지연 도착송신자 큐 적체·재시도오래된 이벤트는 무시
위조 요청공개 URL로 누구나 전송 가능서명 검증 필수

멱등 키와 이벤트 ID

멱등성을 구현하는 표준 방법은 각 이벤트의 고유 식별자를 기록해 두고, 이미 처리한 ID가 다시 오면 무시하는 것입니다. 거의 모든 웹훅 제공사는 이벤트마다 고유 ID를 부여해 페이로드나 헤더에 담아 보냅니다. 이 ID를 데이터베이스의 유니크 제약이 걸린 테이블에 기록하면, 중복은 자연스럽게 걸러집니다.

-- 처리한 이벤트 기록 테이블 (개념 예시)
CREATE TABLE processed_events (
  event_id   TEXT PRIMARY KEY,        -- 제공사가 준 고유 ID
  type       TEXT NOT NULL,
  received_at TIMESTAMPTZ DEFAULT now()
);
// 수신 처리: 먼저 ID를 선점하고, 충돌하면 중복으로 간주
async function handleEvent(evt) {
  try {
    await db.insert("processed_events", {
      event_id: evt.id, type: evt.type
    }); // PK 충돌 시 예외 → 이미 처리됨
  } catch (e) {
    if (isUniqueViolation(e)) return; // 중복 → 조용히 무시
    throw e;
  }
  await applyBusinessLogic(evt); // 실제 작업은 한 번만
}

구현에서 주의할 점은 “기록”과 “실제 처리”의 원자성입니다. 이벤트 ID는 기록했는데 실제 작업 도중 실패하면, 다음 재전송 때는 이미 기록된 ID라며 건너뛰어 작업이 영영 누락될 수 있습니다. 반대로 작업을 먼저 하고 기록을 나중에 하면, 그 사이 재전송이 들어와 이중 처리될 수 있습니다. 가장 안전한 방법은 ID 기록과 비즈니스 작업을 하나의 데이터베이스 트랜잭션으로 묶거나, 작업 자체를 멱등하게(예: UPSERT, 상태 기반 갱신) 만들어 중복 실행에도 결과가 같아지게 하는 것입니다.

서명 검증으로 위조 막기

웹훅 엔드포인트는 공개 URL이므로 누구나 가짜 요청을 보낼 수 있습니다. 그래서 이벤트가 진짜 그 제공사에서 왔는지 검증해야 합니다. 표준 방식은 HMAC 서명입니다. 제공사는 공유 비밀키로 페이로드의 해시 서명을 만들어 헤더에 담아 보내고, 수신기는 같은 비밀키로 직접 서명을 계산해 비교합니다. 일치하면 위조·변조되지 않았다는 뜻입니다.

// HMAC 서명 검증 (개념 예시)
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, signatureHeader, secret) {
  const expected = createHmac("sha256", secret)
    .update(rawBody)          // 반드시 '원문 바이트'로 계산
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  // 타이밍 공격 방지를 위해 상수 시간 비교
  return a.length === b.length && timingSafeEqual(a, b);
}

서명 검증에서 흔한 실수가 두 가지 있습니다. 첫째, 서명은 반드시 가공 전 원문 바디(raw body)로 계산해야 합니다. 프레임워크가 JSON을 파싱한 뒤 다시 직렬화하면 공백·키 순서가 바뀌어 서명이 어긋납니다. 그래서 웹훅 라우트만큼은 바디 파서를 끄고 원문을 그대로 받아야 합니다. 둘째, 서명 비교는 일반 문자열 비교 대신 상수 시간 비교 함수를 써 타이밍 공격을 막아야 합니다.

추가로, 많은 제공사는 서명에 타임스탬프를 포함해 재전송 공격(replay)을 막을 수 있게 합니다. 수신기는 타임스탬프가 허용 시간(예: 수 분) 안인지 확인해, 과거에 가로챈 유효 요청을 나중에 재사용하는 공격을 차단합니다.

바디 파서가 서명 검증을 조용히 망가뜨립니다.

대부분의 웹 프레임워크는 기본으로 JSON 바디를 파싱합니다. 그러면 원문 바이트가 사라져 HMAC 서명이 항상 불일치하거나, 반대로 검증을 건너뛰게 됩니다. 웹훅 엔드포인트에 한해 원문 바디 접근을 보장하도록 라우트별 설정을 점검하세요. 이 한 줄을 놓쳐 보안 검증이 무력화되는 경우가 의외로 많습니다.

재시도 정책과 빠른 응답

송신자는 보통 수신기가 2xx로 빠르게 응답하지 않으면 실패로 보고 재전송합니다. 재전송은 대개 지수 백오프로 간격을 늘려 가며 수 시간에서 수 일에 걸쳐 반복됩니다. 이 메커니즘은 일시적 장애를 자동 복구해 주는 장점이 있지만, 수신기가 처리에 오래 걸리면 타임아웃→재전송→또 처리라는 악순환으로 부하가 증폭됩니다.

해법은 명확합니다. 웹훅을 받으면 서명 검증과 중복 확인만 하고 즉시 200을 응답한 뒤, 실제 무거운 작업은 백그라운드 큐로 넘겨 비동기로 처리하는 것입니다. 송신자는 빠른 200을 받아 재전송하지 않고, 우리는 큐에서 여유 있게 작업을 처리합니다. 동기 처리 중에 외부 API 호출·DB 대량 작업을 하면 응답이 늦어져 재전송을 유발하므로, 수신 핸들러는 가볍게 유지하는 것이 원칙입니다.

응답 코드송신자 해석(일반적)주의
2xx성공 — 재전송 안 함큐 적재까지 끝낸 뒤 응답
4xx영구 실패 — 보통 재전송 안 함서명 실패 외엔 신중히 사용
5xx / 타임아웃일시 실패 — 재전송처리 지연도 타임아웃 유발
무응답실패 — 재전송핸들러 예외로 응답 누락 주의

응답 코드 선택도 신중해야 합니다. 일시적으로 처리할 수 없는 상황(DB 일시 장애 등)에서는 5xx를 돌려줘 재전송을 받는 편이 안전합니다. 반대로 검증에 실패한 위조 요청이나 우리가 다루지 않는 이벤트 타입은 4xx 또는 2xx로 응답해 불필요한 재전송을 막습니다. 송신자별 재전송 규칙이 다르므로 공식 문서로 확인하는 것이 좋습니다.

순서는 보장되지 않는다

또 하나의 중요한 전제는 이벤트 도착 순서가 발생 순서와 다를 수 있다는 점입니다. 네트워크 지연, 재전송, 병렬 전송 때문에 “주문 생성” 다음에 보낸 “주문 취소”가 먼저 도착할 수 있습니다. 순서에 의존하는 코드는 이런 상황에서 잘못된 상태를 만듭니다.

대응 원칙은 순서가 아니라 상태와 타임스탬프에 기반해 처리하는 것입니다. 각 이벤트에는 보통 발생 시각이나 버전(시퀀스) 정보가 있습니다. 수신기는 현재 저장된 상태의 버전보다 오래된 이벤트가 뒤늦게 도착하면 무시하고, 최신 이벤트만 반영합니다. 즉 “마지막에 도착한 것”이 아니라 “가장 최신 상태를 나타내는 것”을 채택합니다. 또는 매번 외부 API로 현재 상태를 다시 조회해 반영하는 방식(웹훅을 트리거로만 쓰고 진실은 조회로 확인)도 순서 문제를 우회하는 견고한 패턴입니다.

비동기 처리와 실패 알림

빠른 200 응답 뒤 큐로 넘긴 작업은 자체적으로 실패할 수 있습니다. 이 비동기 작업에도 재시도와 상한이 필요하며, 끝내 실패한 작업은 데드레터 큐(DLQ)로 보내 유실되지 않게 합니다. DLQ에 쌓인 항목은 원인을 분석해 수동 재처리하거나 코드를 고친 뒤 다시 흘려보냅니다. 실패가 조용히 사라지면 데이터 불일치를 한참 뒤에야 발견하게 됩니다.

운영 가시성도 중요합니다. 웹훅 수신율, 서명 실패율, 중복 비율, 비동기 처리 성공·실패율, DLQ 적체량을 계측하면 문제를 조기에 잡을 수 있습니다. 서명 실패율이 갑자기 오르면 키 교체 누락이나 공격 시도를 의심하고, DLQ가 쌓이면 처리 로직의 버그를 점검합니다.

테스트와 디버깅 환경도 미리 마련해 두는 편이 좋습니다. 웹훅은 외부에서 우리 서버로 들어오는 구조라, 로컬 개발 중에는 외부 서비스가 접근할 공개 주소가 없어 검증이 까다롭습니다. 많은 제공사가 콘솔에서 과거 이벤트를 다시 보내는 재전송 기능이나 테스트 이벤트 발사 기능을 제공하므로 이를 적극 활용합니다. 로컬에서는 터널링 도구로 임시 공개 URL을 만들어 실제 이벤트를 받아 보거나, 저장해 둔 페이로드 샘플을 핸들러에 직접 주입해 단위 테스트로 검증할 수 있습니다. 특히 서명 검증·중복 처리·순서 역전 같은 경계 상황은 운영에서 재현하기 어려우므로, 잘못된 서명, 같은 ID의 반복, 과거 버전 이벤트를 일부러 만들어 테스트로 고정해 두면 회귀를 막을 수 있습니다.

안전한 웹훅 수신 체크리스트

  • 원문 바디로 HMAC 서명을 검증하고 상수 시간 비교를 쓰는가
  • 타임스탬프를 확인해 재전송(replay) 공격을 막는가
  • 이벤트 ID를 유니크 제약으로 기록해 중복을 걸러 내는가
  • ID 기록과 비즈니스 작업을 트랜잭션으로 묶거나 작업 자체를 멱등하게 만들었는가
  • 검증·중복 확인 후 즉시 200을 응답하고 무거운 작업은 비동기 큐로 넘기는가
  • 순서 비보장을 전제로 버전·타임스탬프 기반으로 상태를 갱신하는가
  • 비동기 실패를 데드레터 큐로 보내고 재처리·알림 경로를 두었는가

자주 묻는 질문

웹훅이 중복으로 오는 건 버그인가요?
아닙니다. 대부분의 제공사는 누락보다 중복이 낫다는 판단으로 “최소 한 번 전송”을 보장합니다. 응답 유실·타임아웃·인프라 복구 등으로 같은 이벤트가 여러 번 도착하는 것은 정상이며, 수신기가 멱등 처리로 흡수해야 합니다.
멱등성은 어떻게 구현하나요?
이벤트 고유 ID를 유니크 제약이 걸린 테이블에 기록하고, 이미 있는 ID가 다시 오면 무시합니다. 단, ID 기록과 실제 작업을 한 트랜잭션으로 묶거나 작업 자체를 UPSERT처럼 멱등하게 만들어, 중간 실패로 작업이 누락되지 않도록 해야 합니다.
서명 검증이 자꾸 실패합니다.
대부분 프레임워크가 바디를 파싱·재직렬화해 원문 바이트가 바뀌었기 때문입니다. 웹훅 라우트만 바디 파서를 끄고 원문(raw body)으로 HMAC을 계산하세요. 비교는 상수 시간 함수를 사용해야 타이밍 공격에도 안전합니다.
이벤트 순서를 믿어도 되나요?
아닙니다. 재전송·지연으로 순서가 뒤바뀔 수 있습니다. 도착 순서 대신 이벤트의 버전·타임스탬프를 기준으로 더 오래된 이벤트는 무시하거나, 웹훅을 트리거로만 쓰고 현재 상태는 API로 다시 조회해 반영하는 방식이 안전합니다.
처리가 오래 걸리면 어떻게 하나요?
검증·중복 확인 후 즉시 200을 응답하고, 무거운 작업은 백그라운드 큐로 넘겨 비동기 처리하세요. 동기 처리로 응답이 늦으면 송신자가 타임아웃을 내고 재전송해 부하가 증폭됩니다. 비동기 작업의 최종 실패는 데드레터 큐로 보관합니다.

서명 검증·재전송(replay) 방어 등 웹훅 보안 설계의 표준 권고는 OWASP 공식 문서에서 함께 확인할 수 있습니다.

이 글은 Stripe·GitHub·Slack 등 주요 서비스의 공개 웹훅 문서와 OWASP 보안 권고에 공통으로 나타나는 설계 원칙을 정리한 것입니다. 서명 알고리즘·헤더명·재전송 주기·타임스탬프 허용 범위는 제공사마다 다르므로 실제 구현 전 각 서비스의 공식 문서를 확인하시기 바랍니다. 코드는 원리 설명용 개념 예시입니다.

위로 스크롤