Files
2nd/10_Wiki/Topics/Coding/Backend_Webhook_Patterns.md
T
Antigravity Agent f8b21af4be Wiki cleanup: error-doc removal, dedup merge, link normalization
10_Wiki/Topics 대규모 정리:
- 오류 캡처/미완성 stub 문서 227개 제거
- 교차폴더 중복 43클러스터 병합 (63파일 → redirect)
- 링크명 정규화: 깨진 링크 수정·redirect 직결·개념 매핑 ~2,400건
- 카테고리 MOC 6개 신규 생성
- Graph 섹션 미해결 related-keyword 링크 10,058건 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:52:15 +09:00

4.6 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
backend-webhook-patterns Webhook — Signing / Retry / Replay 방어 Coding draft B conceptual 2026-05-09 2026-05-09
backend
webhook
hmac
idempotency
vibe-coding
language applicable_to
TS / Node
Backend
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 — 서명 검증

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 + 비동기

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

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

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 보존

// 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.

🔗 관련 문서