[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
---
|
||||
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]]
|
||||
- [[Idempotency_Patterns]]
|
||||
- [[API_Auth_Bearer_Token_Patterns]]
|
||||
Reference in New Issue
Block a user