웹훅 완벽 가이드: 서명된 페이로드와 안정적인 재시도
스테이징 환경에서는 통합이 잘 동작합니다. 결제 제공자가 이벤트를 발생시키고, 엔드포인트가 이를 수신하고, 주문 상태가 paid로 바뀝니다. 그런데 프로덕션에서는 고객이 두 번 결제하고, 배포 중에 전달이 조용히 사라지며, 누군가 캡처한 요청을 재전송하여 주문을 환불 상태로 만듭니다. 세 가지 실패 모두 같은 근본 원인을 공유합니다. 웹훅을 보안이 중요한 최소 한 번 전달 채널이 아니라 단순한 HTTP POST로 취급한 것입니다.
이 가이드는 올바른 웹훅 구현의 두 가지 측면, 즉 페이로드가 진짜인지 검증하는 것과 재시도를 견디도록 전달을 만드는 것을 다룹니다. 코드는 어떤 스택에도 적용할 수 있도록 작성했습니다.
웹훅이란 무엇인가
웹훅은 한 시스템이 이벤트가 발생했을 때 다른 시스템으로 보내는 서버 간 HTTP 요청입니다. 이는 일반적인 폴링 모델을 뒤집습니다. 앱이 매분 "새로운 거 있나요?"라고 묻는 대신, 생산자가 등록한 URL로 이벤트를 푸시합니다.
이 설계에서 두 가지 속성이 따르며, 둘 다 중요합니다:
- 엔드포인트는 공개됩니다. 인터넷의 누구나 POST를 보낼 수 있으므로 모든 요청은 인증되어야 합니다.
- 전달은 최소 한 번입니다. 네트워크는 실패하고, 서버는 재시작되며, 타임아웃은 모호합니다. 발신자는 핸들러가 실행되었는지 알 수 없습니다. 그래서 재시도하며, 같은 이벤트를 여러 번 볼 수 있습니다.
아래의 모든 내용은 이 두 가지 사실의 결과입니다.
HMAC으로 페이로드 서명하기
표준 접근 방식은 공유 비밀과 원시 요청 본문에 대한 HMAC입니다. 발신자는 서명을 계산하여 헤더에 넣고, 수신자는 이를 재계산하여 비교합니다. 일치하면 페이로드가 비밀을 아는 사람에게서 왔고 전송 중에 수정되지 않았음을 의미합니다.
작동하는 구현과 깨진 구현을 구분하는 세 가지 세부 사항이 있습니다:
- 원시 바이트에 서명하세요. 검증 후에만 JSON을 파싱하세요. 본문을 다시 직렬화하면 공백과 키 순서가 바뀌어 서명이 절대 일치하지 않습니다.
- 상수 시간 비교를 사용하세요. 순진한
==는 타이밍 정보를 누출합니다. 언어에 내장된 안전한 비교를 사용하세요. - 서명된 문자열에 타임스탬프를 포함하세요.
timestamp + "." + body에 서명하면 오래된 요청을 거부할 수 있어 재전송 공격을 차단합니다.
Node.js에서 서명 검증하기
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const signature = headers['x-signature']; // e.g. "t=1712345678,v1=abc123..."
const timestamp = headers['x-timestamp'];
if (!signature || !timestamp) return false;
// 1. Reject stale requests (replay protection)
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
// 2. Recompute over the exact bytes received
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// 3. Constant-time compare
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Express에서는 웹훅 라우트에 express.raw({ type: 'application/json' })가 필요하므로 본문이 Buffer로 도착합니다. 전역 express.json() 미들웨어가 먼저 실행되면 원시 본문이 사라져 검증이 실패합니다. 매우 흔한 버그입니다.
Python에서 검증하기
import hmac, hashlib, time
def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
검증 실패 시 항상 401을 반환하고 요청 ID를 로깅하세요. 하지만 비밀이나 전체 서명은 절대 로깅하지 마세요.
실제로 작동하는 재시도 설계
재시도는 엔드포인트가 예측 가능하게 동작할 때만 유용합니다. 계약은 간단합니다. 이벤트를 내구성 있게 수락한 후 빠르게 2xx를 반환하고, 나머지는 모두 재시도되도록 하세요.
| 응답 | 발신자 동작 | 대신 할 일 |
|---|---|---|
200 / 204 | 전달 완료로 표시 | 올바름 — 대기열에 넣은 후 확인 |
400 / 401 | 보통 재시도 안 함 | 서명 또는 페이로드 처리 수정 |
429 / 503 | 백오프와 함께 재시도 | 과부하 시 반환, 일반 500 대신 |
500 / 타임아웃 | 재시도 | 느린 작업을 큐로 이동 |
두 가지 규칙이 이를 건강하게 유지합니다:
- 빠르게 확인하세요. 검증하고, 이벤트를 큐나 테이블에 저장하고,
200을 응답하세요. 실제 작업은 비동기로 수행하세요. 응답 전에 세 개의 API를 호출하는 핸들러는 타임아웃되어 중복 전달을 유발합니다. - 지터를 포함한 지수 백오프를 사용하세요. 발신자가 즉시 재시도하면 짧은 장애가 몰려드는 요청이 됩니다. 10초, 30초, 2분, 10분, 1시간과 같은 일정에 무작위 지터를 더하면 부하가 분산됩니다.
핸들러를 멱등하게 만드세요
전달이 최소 한 번이므로 같은 이벤트 ID가 두 번 도착할 수 있습니다. 핸들러가 실제로 성공했지만 타임아웃된 후에 다시 도착하는 경우입니다. 처리된 이벤트 ID를 저장하고 중복을 건너뛰세요:
INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- If 0 rows inserted, this is a duplicate: return 200 and stop.
가능하면 비즈니스 작업 자체를 멱등하게 만드세요. 카운터를 증가시키는 대신 주문 상태를 paid로 설정하고, 다운스트림 서비스 호출 시 이벤트 ID를 멱등성 키로 사용하세요.
출시 전 체크리스트
- 상수 시간 비교로 원시 바이트에 대한 HMAC을 검증하세요.
- 재전송을 막기 위해 몇 분보다 오래된 타임스탬프를 거부하세요.
- 이벤트를 저장한 후 1~2초 이내에
2xx를 반환하세요. - 작업을 수행하기 전에 이벤트 ID로 중복 제거하세요.
- 이벤트 ID, 유형, 결과를 로깅하세요. 비밀은 절대 로깅하지 마세요.
- 대시보드나 관리 도구에서 수동으로 이벤트를 재생할 수 있는 방법을 제공하세요.
- 의도적으로 깨진 서명, 중복 전달, 느린 핸들러로 테스트하세요.
실패하는 통합을 디버깅할 때 가장 많이 들여다볼 것은 원시 요청 본문입니다. 이를 JSON 포맷터에 붙여넣으면 이미 캡처한 바이트를 변경하지 않고도 중첩된 이벤트 객체를 읽기 쉽게 만들 수 있습니다.
FAQ
페이로드가 동일해 보이는데도 서명 검증이 실패하는 이유는 무엇인가요?
거의 항상 해싱 전에 본문이 파싱되고 재직렬화되었기 때문입니다. JSON 키 순서, 공백, 유니코드 이스케이프가 파싱/문자열화 과정에서 변경되어 바이트가 발신자가 서명한 것과 더 이상 일치하지 않습니다. 원시 본문을 캡처하여 해싱하세요.
이벤트 처리 전에 200을 반환해야 하나요, 후에 반환해야 하나요?
내구성 있게 저장한 후, 무거운 작업을 하기 전에 반환하세요. 먼저 처리하면 느린 다운스트림 호출이 타임아웃을 유발하고 발신자가 재시도하여 중복이 발생합니다. 이벤트를 큐에 넣고 즉시 응답한 후 비동기로 처리하세요.
HTTPS만으로 웹훅 엔드포인트를 보호할 수 있나요?
아니요. TLS는 전송 중 데이터를 보호하지만 요청을 누가 보냈는지 증명하지는 않습니다. 누구나 공개 URL에 POST할 수 있습니다. 공유 비밀을 사용한 HMAC 서명은 발신자를 인증하고, 서명된 타임스탬프는 캡처된 요청의 재전송을 방지합니다.
결론
웹훅을 신뢰할 수 없는 입력처럼 취급하세요. 원시 바이트에 대한 서명을 검증하고, 타임스탬프를 확인하고, 이벤트 ID로 중복 제거하고, 이벤트가 안전하게 저장된 후에만 확인 응답하세요. 그렇게 하면 재시도가 중복 청구의 원인이 아니라 원래 의도대로 안전망이 됩니다.