Advanced (업데이트: 2026. 6. 3.)

Claude Code로 프로덕션 Webhook 구현하기: 서명 검증, 멱등성, 재시도

Claude Code로 프로덕션 Webhook을 구현합니다. raw body, 서명 검증, 멱등성, 재시도, 테스트와 운영까지 다룹니다.

Claude Code로 프로덕션 Webhook 구현하기: 서명 검증, 멱등성, 재시도

Webhook은 외부 서비스에서 이벤트가 발생했을 때 HTTP 요청으로 내 애플리케이션에 알려 주는 방식입니다. 결제 완료, GitHub push, 폼 제출, 구독 변경, CRM 업데이트, SaaS 상태 변경처럼 “상대 시스템의 변화”를 내 시스템에 반영할 때 자주 사용합니다.

운영 환경에서는 Webhook이 단순한 JSON POST 엔드포인트가 아닙니다. raw body, 즉 수신한 요청 본문 원본 바이트를 보존해야 하고, 공급자별 서명을 검증해야 하며, 같은 이벤트가 여러 번 와도 비즈니스 결과가 한 번만 일어나도록 멱등성을 넣어야 합니다. 실패한 작업은 retry queue로 넘기고, 저장된 delivery를 replay할 수 있어야 합니다.

Claude Code에 “Webhook 만들어 줘”라고만 요청하면 데모 코드는 빨리 나옵니다. 하지만 JSON을 먼저 파싱해서 서명 검증이 깨지거나, 공급자 재전송으로 이메일과 권한 부여가 중복 실행될 수 있습니다. API 전체 구조는 Claude Code API 개발, Secrets 관리, 보안 베스트 프랙티스, 큐 시스템과 함께 보는 것이 좋습니다.

공급자 계약부터 정리하기

항목GitHub 예시Stripe 예시구현 포인트
EndpointPOST /webhooks/githubPOST /webhooks/stripe라우팅 분리
이벤트 IDX-GitHub-Deliveryevent.id멱등성 키
이벤트 타입X-GitHub-Eventevent.type핸들러 분기
서명 헤더X-Hub-Signature-256Stripe-Signature출처 검증
검증 대상raw bodyraw bodybody parser 순서
성공 응답빠르게 2xx빠르게 2xx큐 저장 후 응답

정확한 규칙은 공식 문서를 확인하세요. GitHub Webhooks, GitHub delivery validation, Stripe Webhooks, Stripe webhook signatures, Express express.raw, Anthropic Claude Code best practices가 기준입니다.

flowchart LR
  A["Provider<br/>GitHub / Stripe"] --> B["Webhook endpoint<br/>raw body"]
  B --> C["Signature verification"]
  C --> D["Event store"]
  D --> E["Idempotency check"]
  E --> F["Retry queue"]
  F --> G["Domain handler"]
  D --> H["Replay tool"]

Claude Code에 줄 프롬프트

Express + TypeScript로 GitHub Webhook 수신기를 구현해 주세요.

요구사항:
- POST /webhooks/github 추가
- Webhook 라우트는 express.raw({ type: "*/*" })로 raw body 보존
- JSON parse는 서명 검증 이후에 수행
- X-Hub-Signature-256을 HMAC SHA-256으로 검증
- X-GitHub-Delivery를 멱등성 키로 사용
- 수락한 이벤트는 처리 전에 event store에 저장
- 같은 delivery id는 두 번 처리하지 않음
- 수신 시 202를 빠르게 반환하고 무거운 작업은 retry queue에서 실행
- node:test로 정상 서명, 잘못된 서명, 중복 delivery 테스트
- 저장된 delivery를 재전송하는 replay script 추가
- 비밀값은 WEBHOOK_SECRET 환경 변수에서 읽기

실행 가능한 수신 코드

npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express

src/server.ts:

import crypto from "node:crypto";
import express from "express";

type EventStatus = "queued" | "processing" | "processed" | "failed";

type WebhookEvent = {
  id: string;
  provider: "github";
  type: string;
  headers: Record<string, string>;
  rawBody: Buffer;
  payload: unknown;
  receivedAt: string;
  status: EventStatus;
  attempts: number;
  lastError?: string;
};

export const app = express();
export const eventStore = new Map<string, WebhookEvent>();
export const processedEvents = new Set<string>();
export const retryQueue: string[] = [];

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";

app.use("/webhooks", express.raw({ type: "*/*", limit: "1mb" }));
app.use(express.json());

function firstHeader(value: string | string[] | undefined): string | undefined {
  return Array.isArray(value) ? value[0] : value;
}

function safeCompare(leftValue: string, rightValue: string): boolean {
  const left = Buffer.from(leftValue);
  const right = Buffer.from(rightValue);
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}

export function signGitHubBody(
  rawBody: Buffer | string,
  secret = WEBHOOK_SECRET
): string {
  return (
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
  );
}

export function verifyGitHubSignature(
  rawBody: Buffer,
  signatureHeader: string | undefined,
  secret = WEBHOOK_SECRET
): boolean {
  if (!signatureHeader?.startsWith("sha256=")) return false;
  return safeCompare(signGitHubBody(rawBody, secret), signatureHeader);
}

function headersForStorage(req: express.Request): Record<string, string> {
  const result: Record<string, string> = {};
  for (const [key, value] of Object.entries(req.headers)) {
    if (typeof value === "string") result[key] = value;
  }
  return result;
}

app.post("/webhooks/github", (req, res) => {
  const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from("");
  const signature = firstHeader(req.headers["x-hub-signature-256"]);
  const deliveryId = firstHeader(req.headers["x-github-delivery"]);
  const eventType = firstHeader(req.headers["x-github-event"]) ?? "unknown";

  if (!verifyGitHubSignature(rawBody, signature)) {
    return res.status(401).json({ error: "invalid_signature" });
  }

  if (!deliveryId) {
    return res.status(400).json({ error: "missing_delivery_id" });
  }

  const id = `github:${deliveryId}`;
  if (processedEvents.has(id) || eventStore.has(id)) {
    return res.status(202).json({ id, status: "duplicate" });
  }

  let payload: unknown;
  try {
    payload = JSON.parse(rawBody.toString("utf8"));
  } catch {
    return res.status(400).json({ error: "invalid_json" });
  }

  eventStore.set(id, {
    id,
    provider: "github",
    type: eventType,
    headers: headersForStorage(req),
    rawBody,
    payload,
    receivedAt: new Date().toISOString(),
    status: "queued",
    attempts: 0,
  });

  retryQueue.push(id);
  void processNextEvent();

  return res.status(202).json({ id, status: "queued" });
});

export async function processNextEvent(): Promise<void> {
  const id = retryQueue.shift();
  if (!id) return;

  const event = eventStore.get(id);
  if (!event || event.status === "processed") return;

  event.status = "processing";
  event.attempts += 1;

  try {
    await handleWebhookEvent(event);
    event.status = "processed";
    processedEvents.add(id);
  } catch (error) {
    event.status = "failed";
    event.lastError = error instanceof Error ? error.message : String(error);

    if (event.attempts < 5) {
      const delayMs = Math.min(30_000, 1_000 * 2 ** event.attempts);
      setTimeout(() => {
        event.status = "queued";
        retryQueue.push(id);
        void processNextEvent();
      }, delayMs);
    }
  }
}

async function handleWebhookEvent(event: WebhookEvent): Promise<void> {
  if (event.type === "push") console.log("GitHub push received", event.id);
}

if (process.env.NODE_ENV !== "test") {
  const port = Number(process.env.PORT ?? 3000);
  app.listen(port, () => {
    console.log(`Webhook server listening on http://127.0.0.1:${port}`);
  });
}
WEBHOOK_SECRET=dev-secret-change-me npx tsx src/server.ts

로컬 전송과 테스트

scripts/send-local-webhook.ts:

import crypto from "node:crypto";

const secret = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
const url =
  process.env.WEBHOOK_URL ?? "http://127.0.0.1:3000/webhooks/github";
const body = JSON.stringify({ ref: "refs/heads/main", after: "local-test" });
const signature =
  "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");

const response = await fetch(url, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-github-event": "push",
    "x-github-delivery": `local-${Date.now()}`,
    "x-hub-signature-256": signature,
  },
  body,
});

console.log(response.status, await response.text());
WEBHOOK_SECRET=dev-secret-change-me npx tsx scripts/send-local-webhook.ts

테스트는 정상 서명, 잘못된 서명, 중복 delivery를 고정합니다.

NODE_ENV=test npx tsx --test test/webhook.test.ts

실패 이벤트는 raw body, headers, status, attempts, lastError를 저장해야 합니다. replay script는 저장된 JSON을 읽고 원래 body와 headers로 다시 POST합니다. body를 다시 포맷하면 서명이 깨질 수 있으므로 그대로 보존합니다.

실제 사용 사례

결제에서는 Stripe 이벤트로 주문 확정, 실패한 청구 안내, 접근 권한 정지를 처리합니다. 개발 워크플로에서는 GitHub pushpull_request로 프리뷰 환경, 문서 생성, 내부 알림을 실행합니다. 폼과 CRM 연동에서는 외부 ID로 중복 티켓 생성을 막습니다. 자체 SaaS가 고객 시스템으로 Webhook을 보내는 경우에도 서명, 전송 로그, timeout, retry, 수동 재전송이 필요합니다.

흔한 실패

가장 흔한 문제는 JSON 파싱을 먼저 해서 서명 검증에 실패하는 것입니다. 두 번째는 2xx를 반환하기 전에 무거운 처리를 끝내려다 공급자 재시도를 유발하는 것입니다. 세 번째는 요청마다 새 UUID를 만들어 멱등성 키로 쓰는 것입니다. 네 번째는 실패 delivery를 저장하지 않고 로그만 남겨 장애 복구가 느려지는 것입니다. 마지막으로, secret이나 전체 payload를 로그에 남기면 보안 사고 대응이 더 어려워집니다.

운영 체크리스트

  • Webhook 라우트에서만 raw body를 보존한다.
  • 서명 실패는 401, JSON 오류는 400, 수락된 작업은 202를 반환한다.
  • 공급자 delivery ID를 멱등성 키로 사용한다.
  • event store에 raw body, headers, 상태, 시도 횟수, 마지막 오류를 저장한다.
  • retry queue에 최대 횟수, backoff, 최종 실패 알림이 있다.
  • replay script가 저장된 delivery로 동작한다.
  • secret은 환경 변수나 secret manager에서 읽고 rotation 기록을 남긴다.

정리

프로덕션 Webhook은 작은 엔드포인트 안에 보안, 신뢰성, 운영 복구가 들어 있는 기능입니다. Claude Code를 쓸 때 provider 계약, raw body, 서명 검증, 멱등성, queue, 테스트, replay, runbook을 처음 프롬프트에 넣으면 데모가 아니라 운영 가능한 코드에 가까워집니다.

ClaudeCodeLab의 Products에는 Claude Code 템플릿과 체크리스트가 있고, 팀 적용은 Training에서 상담할 수 있습니다. 실제로 시험해 보니 raw body와 멱등성 테스트를 먼저 고정했을 때 Claude Code의 수정 횟수가 가장 크게 줄었습니다.

#Claude Code #Webhook #API 설계 #보안 #비동기 처리
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.