웹훅 완벽 가이드: 서명된 페이로드와 재시도
타사 결제 제공업체를 방금 통합했고, 결제가 성공하면 웹훅을 통해 앱에 알림을 보낸다고 합니다. 하지만 요청이 실제로 그 제공업체에서 온 것인지, 공격자로부터 온 것인지 어떻게 알 수 있을까요? 그리고 웹훅이 발생했을 때 서버가 잠시 다운되어 있다면 어떻게 될까요? 적절한 보호 장치 없이는 가짜 이벤트를 처리하거나 중요한 업데이트를 놓칠 위험이 있습니다. 이 글에서는 서명된 페이로드로 웹훅을 보호하고 강력한 재시도 메커니즘을 구현하는 방법을 설명합니다.
웹훅이란?
웹훅은 사용자가 정의한 HTTP 콜백입니다. 소스 시스템에서 이벤트(예: 결제 완료)가 발생하면 사용자가 제공한 URL로 HTTP POST 요청을 보냅니다. 업데이트를 반복적으로 요청하는 폴링과 달리, 웹훅은 거의 실시간으로 데이터를 푸시합니다. CI/CD 트리거, 채팅 메시지, 결제 상태 변경 등 이벤트 알림에 널리 사용됩니다.
웹훅을 서명으로 보호해야 하는 이유
웹훅 엔드포인트는 공개적으로 접근 가능하므로 누구나 POST 요청을 보낼 수 있습니다. 검증 없이는 공격자가 이벤트를 위조하여 주문을 결제 완료로 표시하는 등의 권한 없는 작업을 수행할 수 있습니다. 공유 비밀 키로 페이로드에 서명하면 진위 여부와 무결성을 확인할 수 있습니다.
서명된 페이로드의 작동 방식
발신자와 수신자는 비밀 키를 공유합니다. 각 웹훅에 대해 발신자는 요청 본문에 대한 암호화 서명(보통 HMAC)을 계산하여 헤더에 포함합니다. 수신자는 동일한 비밀 키를 사용하여 서명을 다시 계산하고 비교합니다. 일치하면 요청이 진짜입니다.
올바른 알고리즘 선택
HMAC-SHA256이 사실상 표준입니다. 빠르고 안전하며 모든 주요 언어에서 사용할 수 있습니다. 취약한 MD5나 SHA1은 피하세요.
단계별: 서명된 페이로드 검증
- 서명 추출 헤더(예:
X-Signature)에서 서명을 추출합니다. - 원시 요청 본문 가져오기 문자열 또는 바이트 배열로 가져옵니다. 먼저 파싱하지 마세요. 어떤 변환도 서명을 변경합니다.
- HMAC 계산 비밀 키와 원시 본문을 사용하여 HMAC을 계산합니다.
- 비교 계산된 서명과 수신된 서명을 상수 시간 비교를 사용하여 비교하여 타이밍 공격을 방지합니다.
다음은 Express를 사용한 Node.js 예제입니다:
const crypto = require('crypto');
const express = require('express');
const app = express();
// 웹훅 라우트에는 원시 본문 파서 사용
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) {
return res.status(401).send('Invalid signature');
}
// 이벤트 처리
const event = JSON.parse(req.body);
// ... 이벤트 처리
res.status(200).send('OK');
});
흔한 함정: 검증 전에 JSON 본문 파서를 사용하는 것입니다. Express의 express.json()은 본문을 파싱하므로 원시 바이트가 손실됩니다. 웹훅 라우트에는 항상 express.raw()를 사용하거나 원시 본문을 별도로 캡처하세요.
재시도 전략 설계
서명이 있어도 웹훅은 실패할 수 있습니다. 서버가 다운되었거나 요청이 시간 초과될 수 있습니다. 신뢰할 수 있는 발신자는 실패한 전송을 재시도합니다. 수신자로서 재시도를 우아하게 처리해야 합니다.
발신자 측 재시도
발신자를 구축하는 경우 지터를 포함한 지수 백오프를 구현하세요. 예를 들어 1분 후, 5분 후, 30분 후에 재시도하고 최대 시도 횟수(예: 5회)까지 수행합니다. 수신자가 중복 제거할 수 있도록 페이로드에 고유 이벤트 ID를 포함하세요.
수신자 측 멱등성
재시도로 인해 중복 전송이 발생할 수 있으므로 엔드포인트는 멱등성을 가져야 합니다. 이벤트 ID를 사용하여 처리된 이벤트를 추적하세요. 고유 제약 조건이 있는 데이터베이스에 저장하거나 TTL이 있는 Redis와 같은 캐시를 사용하세요. 이벤트 ID가 이미 본 것이라면 즉시 200 OK를 반환하고 재처리하지 마세요.
데이터베이스 고유 인덱스를 사용한 예:
async function handleWebhook(event) {
try {
await db.query('INSERT INTO processed_events (event_id) VALUES ($1)', [event.id]);
} catch (err) {
if (err.code === '23505') { // 고유 위반
return; // 이미 처리됨
}
throw err;
}
// 이벤트 처리
}
웹훅 엔드포인트 모범 사례
- 빠르게 응답: 몇 초 이내에 2xx 상태를 반환하세요. 무거운 처리는 백그라운드 작업으로 오프로드하세요.
- 타임스탬프 검증: 재전송 공격을 방지하기 위해 서명에 타임스탬프를 포함하세요. 몇 분 이상 된 요청은 거부하세요.
- HTTPS 사용: 전송 중 페이로드를 보호하기 위해 항상 TLS를 사용하세요.
- 모든 것을 로깅: 디버깅을 위해 헤더와 본문을 포함한 수신 웹훅을 로깅하세요.
- 실패 모니터링: 반복적인 실패나 서명 불일치에 대한 알림을 설정하세요.
비교: 서명된 웹훅 vs 서명되지 않은 웹훅
| 측면 | 서명된 웹훅 | 서명되지 않은 웹훅 |
|---|---|---|
| 보안 | 높음 – 발신자와 무결성 검증 | 낮음 – 누구나 요청 위조 가능 |
| 구현 복잡성 | 보통 – HMAC 및 비밀 관리 필요 | 낮음 – POST만 수락 |
| 사용 사례 | 결제, 민감한 데이터 | 중요하지 않은 알림 |
FAQ
Python에서 웹훅 서명을 어떻게 검증하나요?
hmac 모듈과 SHA256을 사용하세요. 계산된 다이제스트를 헤더와 비교할 때 상수 시간 비교를 위해 hmac.compare_digest를 사용하세요.
웹훅 엔드포인트가 중복 이벤트를 받으면 어떻게 해야 하나요?
이벤트 ID를 처리된 ID 저장소와 대조하세요. 중복이면 200 OK를 반환하고 처리를 건너뛰세요. 이렇게 하면 멱등성이 보장됩니다.
웹훅 발신자는 몇 번 재시도해야 하나요?
정해진 횟수는 없지만 지수 백오프(예: 1분, 5분, 30분)를 사용하여 3~5회 재시도하는 것이 일반적입니다. 그 후에는 전송 실패로 표시하고 알림을 보내세요.
웹훅 엔드포인트를 테스트할 준비가 되셨나요? 개발 중에 페이로드를 예쁘게 출력하고 유효성을 검사하려면 JSON Formatter를 사용하세요.