2026-08-08

웹훅 서명 검증(HMAC-SHA256) 구현하는 법

결제 웹훅은 외부에서 우리 서버로 들어오는 요청입니다. 누구나 같은 URL로 가짜 요청을 보낼 수 있기 때문에, 발신자가 진짜인지 반드시 검증해야 합니다.

이 글은 슈퍼빌링 웹훅을 기준으로 서명 검증을 "제대로" 구현하는 법을 다룹니다. 단순히 서명이 맞는지 틀린지 확인하는 코드 한 줄이 아니라, 실무에서 실제로 사고가 나는 지점(재직렬화 버그, 타이밍 공격, 검증 통과 이후의 신뢰 범위)까지 함께 짚습니다.

왜 서명 검증이 필요한가

웹훅 URL이 공개되면 악의적인 요청자가 invoice.payment_succeeded 같은 이벤트를 위조해서 보낼 수 있습니다. 서명 검증 없이 이 요청을 그대로 믿고 처리하면, 결제하지 않은 사용자에게 서비스를 제공하는 사고로 이어질 수 있습니다.

HMAC-SHA256 방식

슈퍼빌링은 웹훅 등록 시 발급되는 시크릿으로 요청 본문(raw body) 전체에 대한 HMAC-SHA256을 계산해 X-SuperBilling-Signature 헤더(hex 인코딩)에 담아 보냅니다.

import { verifyWebhookSignature } from "@superbilling/sdk-node";

export async function POST(req: Request) {
  const rawBody = await req.text(); // 반드시 raw body 문자열을 그대로 사용
  const signature = req.headers.get("x-superbilling-signature") ?? "";

  if (!verifyWebhookSignature(rawBody, signature, process.env.SUPERBILLING_WEBHOOK_SECRET!)) {
    return new Response("invalid signature", { status: 401 });
  }

  const event = JSON.parse(rawBody);
  // event.type: "subscription.created" | "invoice.payment_succeeded" |
  //             "invoice.payment_failed" | "subscription.canceled"
  return new Response("ok", { status: 200 });
}

raw body를 그대로 써야 하는 이유

가장 흔한 실수는 요청 본문을 한 번 JSON.parse로 파싱한 뒤, 그걸 다시 JSON.stringify로 직렬화해서 서명을 검증하는 것입니다. 이렇게 하면 서버가 보낸 원본 문자열과 키 순서·공백이 미묘하게 달라져 서명이 항상 실패합니다.

프레임워크에 따라 이 실수가 특히 잘 일어납니다. 예를 들어 Express는 기본적으로 body-parser가 요청을 먼저 JSON 객체로 파싱해버리기 때문에, 웹훅 라우트만은 파싱 전 raw body를 접근할 수 있게 별도 설정을 해줘야 합니다.

// Express 예시 — 웹훅 라우트에는 express.json()을 적용하지 않는다
app.post(
  "/webhooks/superbilling",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8"); // Buffer -> string
    // ...verifyWebhookSignature(rawBody, ...)
  },
);

로드밸런서나 API 게이트웨이가 요청 본문을 한 번 가공한 뒤 전달하는 구성이라면, 그 구간에서 body가 훼손되지 않는지도 함께 확인해야 합니다.

타이밍 공격을 막는 상수시간 비교

서명 문자열을 expected === signature처럼 일반 문자열 비교로 검증하면 안 됩니다. 일반 비교는 문자가 다른 지점에서 즉시 반환되기 때문에, 공격자가 응답 시간 차이를 반복 측정해 서명을 한 글자씩 추론할 수 있습니다(타이밍 공격).

슈퍼빌링 SDK의 verifyWebhookSignature는 Node.js의 crypto.timingSafeEqual로 항상 같은 시간이 걸리는 비교를 수행합니다.

import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhookSignature(rawBody: string, signature: string, secret: string): boolean {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const expectedBuf = Buffer.from(expected, "hex");
  const signatureBuf = Buffer.from(signature, "hex");

  if (expectedBuf.length !== signatureBuf.length) return false;
  return timingSafeEqual(expectedBuf, signatureBuf);
}

직접 웹훅 검증 로직을 구현하는 다른 서비스라면(슈퍼빌링 외에도 여러 PG·SaaS 웹훅을 받는 경우가 흔합니다), 이 상수시간 비교를 빠뜨리지 않았는지 반드시 확인하세요.

재시도와 멱등성(idempotency) 처리

슈퍼빌링은 웹훅 전송이 실패하면(2xx 이외 응답이거나 네트워크 오류) 최대 3회까지, 1초·3초 간격으로 재시도합니다. 즉 같은 이벤트를 여러 번 받는 상황이 정상적으로 발생합니다.

핸들러가 이벤트를 받을 때마다 매번 부수효과(이메일 발송, 크레딧 지급 등)를 실행하면 재시도만으로 중복 처리 사고가 납니다. event.data에 포함된 리소스 ID(subscriptionId, invoiceId 등)와 event.type 조합을 키로 삼아, 이미 처리한 조합이면 스킵하도록 DB에 처리 이력을 기록하는 방식을 권장합니다.

const processedKey = `${event.type}:${event.data.invoiceId}`;
const already = await db.query.processedWebhooks.findFirst({
  where: eq(processedWebhooks.key, processedKey),
});
if (already) return new Response("ok", { status: 200 }); // 이미 처리함

검증 통과 ≠ 신뢰해도 된다는 뜻

서명 검증은 "이 요청이 정말 슈퍼빌링에서 왔고, 전송 중 변조되지 않았다"는 것만 보증합니다. 이벤트 자체가 최신 상태인지, 이미 처리한 이벤트인지는 별개의 문제입니다. 위의 멱등성 처리가 없다면, 서명이 유효한 재시도 요청이 중복 처리로 이어질 수 있다는 뜻입니다.

또한 현재 슈퍼빌링 웹훅 페이로드에는 타임스탬프나 재전송 방지용 nonce가 포함되어 있지 않습니다. 요청이 오래전에 가로채여 나중에 재전송되는 시나리오까지 막고 싶다면, 애플리케이션 레벨에서 위와 같은 처리 이력 기반 멱등성 체크를 반드시 두는 것이 현재로선 가장 확실한 방어선입니다. 이 부분은 저희도 개선 과제로 트래킹하고 있습니다.

검증 실패 시 응답 규약

서명이 없거나 불일치하면 401을, 페이로드 자체가 JSON으로 파싱되지 않는 등 요청 형식이 잘못됐으면 400을 반환하는 식으로 구분하면 이후 로그에서 원인을 추적하기 쉽습니다. 어느 경우든 시크릿 값이나 계산된 서명 값을 응답 본문·로그에 그대로 남기지 않도록 주의하세요.

정리

  • raw body를 그대로 서명 대상으로 사용한다 (파싱 후 재직렬화 금지)
  • 상수시간 비교(timingSafeEqual)로 타이밍 공격을 막는다
  • 재시도를 전제로 멱등하게 핸들러를 설계한다
  • 서명 검증 통과가 "중복 없음"이나 "최신 이벤트"를 보장하지 않는다는 점을 인지하고 애플리케이션 레벨에서 보완한다

자세한 이벤트 목록과 등록 방법은 Quickstart 문서에서 확인할 수 있습니다.

3분만에 구독결제 연동해보기

요금제 설계부터 웹훅 등록까지, 슈퍼빌링으로 바로 시작할 수 있습니다.

무료로 시작하기

이메일만 입력하면 바로 가입·로그인이 끝나요