--- id: backend-webhook-patterns title: Webhook — Signing / Retry / Replay 방어 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [backend, webhook, hmac, idempotency, vibe-coding] tech_stack: { language: "TS / Node", applicable_to: ["Backend"] } applied_in: [] aliases: [HMAC signing, webhook secret, replay attack, dead-letter, exponential backoff] --- # Webhook Patterns > 외부 시스템이 우리 endpoint 호출. **HMAC 서명 검증 + idempotency + 비동기 처리** 3종이 표준. Stripe / GitHub / Slack 모두 이 패턴. ## 📖 핵심 개념 - Sender = HMAC(secret, body) 헤더 첨부. - Receiver = 검증 → 200 빠르게 → 백그라운드 처리. - Replay: 같은 이벤트 재전송 → idempotency key 로 차단. - Retry: 5xx 시 sender 가 exponential backoff. ## 💻 코드 패턴 ### Receiver — 서명 검증 ```ts import crypto from 'node:crypto'; function verifyWebhook(req: Request, secret: string): boolean { const sig = req.headers.get('x-signature') ?? ''; const ts = req.headers.get('x-timestamp') ?? ''; // Replay 방어: 5분 이상 지난 timestamp 거부 if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const body = await req.text(); // 원본 body — JSON.parse 후 stringify 금지! const expected = crypto .createHmac('sha256', secret) .update(`${ts}.${body}`) .digest('hex'); return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); } ``` ### Receiver — 빠른 ack + 비동기 ```ts app.post('/webhooks/stripe', async (req, res) => { if (!verifyStripeSignature(req)) return res.status(401).end(); const event = req.body as StripeEvent; // idempotency if (await db.webhookEvents.exists(event.id)) { return res.status(200).end(); // 이미 받음 } await db.webhookEvents.insert({ id: event.id, type: event.type, raw: event, status: 'pending' }); // 200 즉시 — 처리 큐에 투입 res.status(200).end(); queue.add('process-webhook', { eventId: event.id }); }); ``` ### 처리 worker ```ts queue.process('process-webhook', async (job) => { const { eventId } = job.data; const ev = await db.webhookEvents.get(eventId); if (ev.status === 'done') return; try { await handle(ev); await db.webhookEvents.update(eventId, { status: 'done' }); } catch (e) { await db.webhookEvents.update(eventId, { status: 'failed', error: String(e) }); throw e; // 큐가 retry } }); ``` ### Sender — 송신 with retry ```ts async function sendWebhook(url: string, secret: string, payload: unknown) { const ts = Math.floor(Date.now() / 1000); const body = JSON.stringify(payload); const sig = crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex'); const attempts = [0, 5_000, 30_000, 5 * 60_000, 30 * 60_000]; for (let i = 0; i < attempts.length; i++) { if (i > 0) await sleep(attempts[i]); try { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-signature': sig, 'x-timestamp': String(ts), }, body, }); if (res.ok) return; if (res.status >= 400 && res.status < 500) return; // 4xx 는 재시도 무의미 } catch { /* 재시도 */ } } await deadLetter.add(payload); // DLQ } ``` ### Inbound 요청 raw body 보존 ```ts // Express app.use('/webhooks', express.raw({ type: 'application/json' })); // 그 후 hook 안에서 JSON.parse(req.body.toString()) // JSON parse 가 body 변형 시 서명 깨짐 ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | Outbound 가끔 | 내장 fetch + retry | | Outbound 대량 / SLA | 전용 서비스 (Hookdeck, Svix) | | Inbound 단순 | 검증 + idempotency 직접 | | Inbound 복잡 / 여러 sender | Svix 처럼 receiver-as-service | | 다중 환경 (dev/prod) | tunnel (ngrok, cloudflared) for dev | ## ❌ 안티패턴 - **JSON.parse 후 stringify 비교**: 키 순서 / whitespace 가 다르면 서명 깨짐. raw body 사용. - **`==` 로 서명 비교**: timing attack. `timingSafeEqual`. - **Idempotency 없음**: 같은 이벤트 N번 처리 → 중복 청구. - **동기 처리 + 200**: 30초 timeout 위험. 큐로 던지고 200. - **Replay 방어 없음**: 1년 전 webhook 캡처 후 재전송 가능. - **5xx 받으면 영구 재시도**: 결국 dead-letter / 알림. - **Endpoint URL 노출**: rate limit / IP allowlist. ## 🤖 LLM 활용 힌트 - HMAC + timestamp + raw body + idempotency + 200 즉시 + 큐 처리 6종. - Sender: exponential backoff + DLQ. ## 🔗 관련 문서 - [[Backend_Cron_Patterns]]