웹훅 완벽 가이드: 서명된 페이로드와 재시도
방금 타사 결제 제공업체를 통합했습니다. 문서에는 "결제가 성공하면 웹훅을 보냅니다"라고 나와 있습니다. 간단하죠? 하지만 곧 깨닫게 됩니다. 요청이 정말 그들로부터 온 것인지 어떻게 알 수 있을까? 그들이 보낼 때 서버가 다운되어 있으면? 같은 이벤트를 두 번 보내면?
웹훅은 강력하지만 신중한 처리가 필요합니다. 이 가이드에서는 페이로드에 서명하여 진위를 확인하고, 실패를 처리하기 위한 재시도를 구현하며, 중복 처리를 피하기 위한 멱등성을 보장하는 핵심 사항을 다룹니다.
웹훅이란?
웹훅은 HTTP 콜백입니다. 앱이 변경 사항을 API에 폴링하는 대신, 이벤트가 발생할 때 제공업체가 지정한 URL로 HTTP POST를 보냅니다. 이는 통합을 효율적이고 거의 실시간으로 만듭니다.
일반적인 예로는 결제 알림(Stripe, PayPal), CI/CD 트리거(GitHub, GitLab), 메시징 플랫폼(Slack, Discord) 등이 있습니다.
웹훅을 서명으로 보호하는 이유
웹훅 엔드포인트는 공개적으로 접근 가능합니다. 누구나 POST 요청을 보낼 수 있습니다. 검증 없이는 공격자가 이벤트를 위조할 수 있습니다. 예를 들어 미결제 주문을 결제 완료로 표시할 수 있습니다.
공유 비밀로 페이로드에 서명하면 요청이 예상 발신자로부터 왔고 페이로드가 변조되지 않았음을 확인할 수 있습니다.
HMAC 서명 작동 방식
제공업체와 귀하는 비밀 키를 공유합니다. 각 웹훅에 대해 제공업체는 해당 비밀로 페이로드의 HMAC(해시 기반 메시지 인증 코드)을 계산하고 서명을 헤더(예: X-Signature)에 포함합니다. 귀하는 측에서 HMAC을 다시 계산하고 비교합니다.
HMAC은 비밀을 가진 사람만 유효한 서명을 생성할 수 있으므로 무결성과 진위를 보장합니다.
서명된 페이로드 구현
Node.js(Express)와 Python(Flask)에서의 일반적인 구현을 살펴보겠습니다. 원리는 모든 언어에 적용됩니다.
1단계: 비밀 획득 및 저장
웹훅을 등록할 때 제공업체가 비밀을 제공합니다. 환경 변수나 비밀 관리자에 안전하게 저장하고 코드에는 절대 넣지 마세요.
2단계: 원시 본문 캡처
서명 검증에는 정확한 원시 본문 바이트가 필요합니다. 프레임워크가 검증 전에 JSON을 파싱하면 재직렬화 시 바이트가 변경되어 검증이 실패할 수 있습니다.
Express에서는 웹훅 경로에 express.raw({ type: 'application/json' })를 사용하세요. Flask에서는 파싱 전에 request.get_data()에 접근하세요.
3단계: 서명 계산 및 비교
타이밍 공격을 방지하기 위해 상수 시간 비교 함수를 사용하세요.
// Node.js (Express)
const crypto = require('crypto');
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// 이벤트 처리
const event = JSON.parse(req.body);
// ... 이벤트 처리
res.status(200).send('OK');
});
Python에서는 hmac.compare_digest를 사용하세요:
# Python (Flask)
import hmac, hashlib, os
from flask import request, abort
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Signature')
secret = os.environ['WEBHOOK_SECRET'].encode()
expected = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
# ... 이벤트 처리
return 'OK', 200
4단계: 타임스탬프 처리 (선택 사항이지만 권장)
일부 제공업체는 재생 공격을 방지하기 위해 서명에 타임스탬프를 포함합니다. 예를 들어 Stripe의 방식은 timestamp.payload에 서명합니다. 타임스탬프가 너무 오래된(예: 5분 이상) 요청은 거부해야 합니다.
재시도 구현
네트워크는 실패합니다. 서버가 다운되거나 요청이 시간 초과될 수 있습니다. 신뢰할 수 있는 웹훅 시스템은 실패한 전송을 재시도합니다.
제공업체 측
웹훅 발신자를 구축하는 경우 지수 백오프를 사용한 재시도를 구현하세요. 일반적인 패턴: 1분 후, 5분 후, 30분 후 등으로 최대 시도 횟수(예: 5회)까지 재시도합니다. 수신자가 중복 제거할 수 있도록 페이로드에 고유 이벤트 ID를 포함하세요.
수신자 측
수신자로서 다음을 수행해야 합니다:
- 수신 확인을 위해 빠르게(몇 초 이내) 2xx 상태로 응답하세요. 무거운 처리는 비동기적으로 수행하세요.
- 재시도가 필요한 오류(예: 500)에는 비2xx를 반환하세요. 하지만 주의하세요: 4xx를 반환하면 제공업체가 재시도하지 않을 수 있습니다.
- 중복 전송이 문제를 일으키지 않도록 엔드포인트를 멱등적으로 만드세요.
멱등성: 중복 이벤트 처리
재시도로 인해 중복 이벤트가 발생할 수 있습니다. 대부분의 제공업체가 포함하는 이벤트 ID를 사용하여 처리된 이벤트를 추적하세요. 처리 전에 ID가 이미 확인되었는지 확인하고, 그렇다면 건너뛰세요.
처리된 ID를 TTL과 함께 데이터베이스에 저장하거나 비즈니스 로직에 멱등성 키를 사용하세요.
로컬에서 웹훅 테스트
개발 중에는 로컬 서버가 공개적으로 접근 가능하지 않습니다. ngrok 또는 localtunnel과 같은 도구는 머신으로 터널링하는 공개 URL을 생성합니다. 또는 요청을 캡처하고 재생할 수 있는 웹훅 테스트 서비스를 사용하세요.
일반적인 함정과 모범 사례
- 검증 전에 페이로드를 신뢰하지 마세요. 항상 먼저 서명을 검증하세요.
- HTTPS를 사용하세요. 웹훅은 민감한 데이터를 전달하므로 전송 중 암호화하세요.
- 모든 것을 로깅하세요. 디버깅을 위해 원시 요청(헤더 및 본문)을 로깅하되 민감한 정보는 삭제하세요.
- 실패를 모니터링하세요. 반복적인 웹훅 실패에 대한 알림을 설정하세요.
- 이벤트 버전을 관리하세요. 수신자를 중단시키지 않고 발전할 수 있도록 페이로드에 버전을 포함하세요.
비교: 서명된 웹훅 vs. 서명되지 않은 웹훅
| 측면 | 서명됨 | 서명되지 않음 |
|---|---|---|
| 보안 | 높음: 출처 및 무결성 검증 | 낮음: 누구나 위조 가능 |
| 구현 노력 | 보통: HMAC 계산 및 비교 | 최소 |
| 권장 대상 | 모든 프로덕션 시스템 | 내부 신뢰 네트워크만 |
FAQ
웹훅 서명을 어떻게 검증하나요?
공유 비밀로 원시 요청 본문의 HMAC을 계산한 다음 상수 시간 함수를 사용하여 서명 헤더와 비교하세요. 항상 파싱된 JSON이 아닌 원시 본문을 사용하세요.
웹훅 엔드포인트는 무엇을 반환해야 하나요?
수신 확인을 위해 빠르게 2xx 상태 코드를 반환하세요. 처리에 실패하면 5xx를 반환하여 재시도를 트리거하되, 중복을 처리할 수 있도록 엔드포인트가 멱등적인지 확인하세요.
웹훅은 몇 번 재시도되나요?
제공업체에 따라 다릅니다. 대부분은 몇 시간 또는 며칠에 걸쳐 지수 백오프로 여러 번 재시도합니다. 구체적인 내용은 제공업체 문서를 확인하세요.
웹훅 전송을 디버깅할 때 서버 로그를 검사해야 하는 경우가 많습니다. Nginx를 사용하는 경우 Nginx 로그 분석기를 사용하여 액세스 로그를 파싱하고 분석하여 실패한 요청이나 비정상적인 패턴을 찾을 수 있습니다.