외부 웹훅 받기: 서명 검증·중복 수신·순서 보장 없음
웹훅 엔드포인트에서 반드시 챙겨야 할 서명 검증, 중복 이벤트 처리, 도착 순서를 신뢰하지 않는 설계, 빠른 응답과 백그라운드 처리를 정리했습니다.
남이 보내는 요청을 받는다는 것
결제, 배포, 메시징 서비스를 붙이다 보면 웹훅을 받아야 하는 순간이 옵니다. 상대 서버가 이벤트가 생길 때마다 우리 엔드포인트를 호출하는 구조입니다.
일반적인 API 엔드포인트와 다른 점이 있습니다. 누가 호출하는지 우리가 통제할 수 없고, 언제 몇 번 올지도 정해져 있지 않습니다. 이 전제를 놓치면 배포 직후에는 잘 돌다가 몇 주 뒤 이상한 데이터가 쌓이는 식으로 문제가 드러납니다.
받을 때 반드시 챙겨야 할 네 가지를 순서대로 정리합니다.
1. 서명 검증: 진짜 그쪽에서 온 것인가
웹훅 URL은 인터넷에 열려 있습니다. 주소만 알면 누구나 호출할 수 있으므로, 본문 내용을 그대로 믿으면 안 됩니다. "결제가 완료됐다"는 가짜 요청 하나로 상품이 나갈 수 있습니다.
그래서 제공자는 대개 서명 헤더를 함께 보냅니다. 우리가 가진 비밀 키로 본문을 해시해 그 값과 비교하는 방식입니다.
여기서 두 가지를 놓치기 쉽습니다.
원본 본문으로 검증해야 합니다. 프레임워크가 JSON을 파싱하고 다시 문자열로 만들면 공백이나 키 순서가 달라져 서명이 어긋납니다. 파싱 전의 raw body를 확보해야 합니다.
export async function POST(req) {
const raw = await req.text(); // 파싱 전 원본
const signature = req.headers.get("x-signature");
if (!verify(raw, signature, process.env.WEBHOOK_SECRET)) {
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(raw); // 검증 후에 파싱
...
}
비교는 상수 시간으로 해야 합니다. 문자열을 ===로 비교하면 앞에서부터 다른 위치에 따라 걸리는 시간이 미세하게 달라집니다. 언어가 제공하는 타이밍 안전 비교 함수를 쓰세요.
서명에 타임스탬프가 포함되는 경우가 많은데, 이때는 그 시각이 너무 오래되지 않았는지도 확인해야 합니다. 유효한 요청을 가로채 나중에 다시 보내는 것을 막기 위함입니다.
2. 중복 수신: 같은 이벤트가 여러 번 온다
대부분의 웹훅 제공자는 최소 한 번 전달을 보장합니다. 정확히 한 번이 아닙니다. 우리가 200을 돌려주지 않으면 재시도하고, 응답이 네트워크에서 유실돼도 재시도합니다. 우리 쪽 처리는 이미 끝났는데 말이죠.
그래서 같은 이벤트를 두 번 받아도 결과가 같아야 합니다. 이벤트마다 붙어 오는 고유 ID를 기록해두고, 이미 처리한 것이면 그냥 200을 돌려주면 됩니다.
CREATE TABLE webhook_events (
provider text NOT NULL,
event_id text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (provider, event_id)
);
여기서도 "조회해서 없으면 삽입"은 동시에 들어오면 뚫립니다. 기본키 제약으로 막고, 삽입이 충돌하면 이미 처리한 것으로 간주하는 순서가 안전합니다.
3. 순서 보장 없음: 나중 일이 먼저 도착한다
이게 가장 놓치기 쉬운 부분입니다. 웹훅은 발생 순서대로 도착하지 않습니다. 재시도와 네트워크 사정 때문에 "생성" 이벤트보다 "취소" 이벤트가 먼저 올 수 있습니다.
순서대로 온다고 가정하고 짜면 이런 코드가 나옵니다.
// 위험: 도착 순서를 신뢰한다
if (event.type === "subscription.canceled") {
await db.update(subs).set({ status: "canceled" }).where(eq(subs.id, event.subId));
}
취소가 먼저 도착하고 갱신이 나중에 도착하면, 취소된 구독이 다시 활성으로 돌아갑니다.
방어 방법은 두 가지입니다. 이벤트에 담긴 시각이나 버전을 함께 저장하고, 더 오래된 이벤트는 무시합니다.
await db.update(subs)
.set({ status: event.status, eventAt: event.occurredAt })
.where(and(eq(subs.id, event.subId), lt(subs.eventAt, event.occurredAt)));
또는 웹훅을 알림으로만 쓰고 실제 상태는 조회해서 확인합니다. "무언가 바뀌었다"는 신호만 받고, 상대 API로 현재 상태를 다시 물어보는 방식입니다. 본문을 신뢰하지 않아도 되고 순서도 문제가 되지 않아 가장 견고합니다. 호출이 한 번 더 드는 대가가 있습니다.
4. 빠르게 응답하고 처리는 뒤로 보낸다
제공자는 대개 짧은 시간 안에 응답을 기대합니다. 그 안에 못 받으면 실패로 보고 재시도합니다.
그런데 웹훅을 받아 메일을 보내고 이미지를 만들고 외부 API를 호출하면 시간이 걸립니다. 처리가 끝나기 전에 상대가 타임아웃으로 판단해 재시도하고, 그 재시도가 또 무거운 처리를 시작합니다.
그래서 순서를 나눕니다. 검증하고, 이벤트를 저장하고, 즉시 200을 돌려준 뒤, 실제 처리는 백그라운드로 넘깁니다.
const event = JSON.parse(raw);
await recordEvent(provider, event.id, event); // 저장까지만
await enqueue("process-webhook", { eventId: event.id });
return new Response("ok", { status: 200 }); // 즉시 응답
여기서 응답을 보내고 await 없이 처리를 이어가는 방식은 쓰면 안 됩니다. 응답 후 실행이 보장되지 않아 이벤트가 조용히 사라집니다.
오류일 때는 어떤 상태 코드를 줘야 하나요?
우리 쪽 일시적 문제(데이터베이스 장애 등)라면 5xx를 주고 재시도를 받는 것이 맞습니다. 반면 서명이 틀렸거나 본문 형식이 잘못됐다면 재시도해도 같은 결과이므로 4xx가 적절합니다. 여기서 흔한 실수는 처리 로직의 예외를 그대로 500으로 흘려보내는 것입니다. 코드 버그로 인한 실패는 재시도해도 계속 실패하면서 재시도 예산만 소모합니다. 로그를 남기고 200을 준 뒤 따로 확인하는 편이 나은 경우도 있습니다.
로컬 개발 중에는 웹훅을 어떻게 받나요?
외부에서 로컬 주소로 호출할 수 없으므로 터널링 도구로 임시 공개 주소를 만들어 연결하는 방식이 일반적입니다. 제공자가 대시보드에서 테스트 이벤트 발송과 재전송 기능을 제공하는 경우도 많으니 먼저 확인해보세요. 그리고 받은 이벤트 원문을 개발 환경에 저장해두면, 나중에 같은 내용을 반복 재생하며 디버깅할 수 있습니다.
이벤트 기록은 얼마나 남겨야 하나요?
중복 판정용 기록은 제공자의 재시도 기간보다 길게 두면 됩니다. 다만 결제처럼 분쟁 가능성이 있는 영역이라면 원문 자체를 오래 보관하는 편이 좋습니다. 나중에 "그때 정확히 무엇이 왔는가"를 확인해야 할 일이 생기고, 그때 상대 쪽 로그와 대조할 근거가 됩니다.
이상한 순서로 보내보면 안다
테스트 도구로 같은 이벤트를 두 번 보내보고, 순서를 바꿔서도 보내보세요. 지금 코드가 그 상황을 견디는지 바로 확인됩니다. 운영에서 발견하면 이미 데이터가 어긋난 뒤입니다.
외부 서비스를 연동한 프로젝트를 만들었다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 연동이 걸린 기능은 실제 트래픽이 붙어야 예외 경로가 드러나는 경우가 많습니다.
