웹훅(Webhook) — 역방향 HTTP 콜백

서버가 클라이언트를 호출하는 역방향 HTTP, 폴링과의 차이부터 서명 검증·재시도·멱등성·비동기 처리·SSRF 보안까지 웹훅의 전 과정을 실전 중심으로 해설합니다.

· 12 min read · PALDYN Team

지난 글에서 서버가 하나의 연결을 열어 둔 채 이벤트를 흘려보내는 SSE를 봤다. 그런데 보내는 쪽과 받는 쪽이 같은 회사의 서비스가 아니라 서로 다른 서버라면 어떨까. 결제사가 결제 완료를 우리 서버에 알리고, 깃 저장소가 push를 CI 서버에 알리고, 메신저가 새 메시지를 봇 서버에 알린다. 이때 쓰이는 것이 웹훅(Webhook)이다. 평소 우리는 클라이언트가 서버에 요청을 보낸다고 배웠지만, 웹훅은 그 방향을 뒤집는다. 이벤트가 생기면 서버가 우리 쪽 URL로 HTTP 요청을 보내는 역방향 콜백, 그래서 “reverse API”라고도 부른다.

폴링은 왜 낭비인가

웹훅이 없던 시절, “저쪽에서 무슨 일이 생겼는지” 알아내는 방법은 폴링(polling)뿐이었다. 클라이언트가 일정 주기로 GET /events를 반복하며 “새 거 있어?”라고 묻는다. 문제는 대부분의 요청이 빈 응답으로 돌아온다는 것이다. 5초마다 묻는다면 하루 1만 7천 번을 묻고, 그중 실제 이벤트가 담긴 응답은 손에 꼽는다.

폴링 vs 웹훅 시퀀스 비교

주기를 짧게 잡으면 트래픽과 서버 부하가 치솟고, 길게 잡으면 이벤트를 받기까지 지연이 커진다. 둘 다 만족시킬 수 없는 트레이드오프다. 웹훅은 이 구조를 통째로 뒤집는다. 클라이언트는 콜백 URL을 한 번 등록해 두고 가만히 있다가, 이벤트가 실제로 발생한 순간에만 서버로부터 POST를 받는다. 묻지 않으니 빈 응답이 없고, 발생 즉시 전달되니 지연도 없다.

등록과 페이로드

웹훅의 시작은 구독 등록이다. 보통 제공자의 대시보드나 API로 “이러이러한 이벤트가 생기면 이 URL을 호출해 줘”라고 콜백 URL을 등록한다. 동시에 어떤 이벤트 종류(payment.completed, push, message.created 등)를 받을지 고른다.

이후 이벤트가 발생하면 제공자는 등록된 URL로 POST 요청을 보낸다. 본문은 거의 항상 JSON이며, 무슨 일이 일어났는지를 설명하는 페이로드가 담긴다.

{
  "id": "evt_8f3a1c",
  "type": "payment.completed",
  "created": 1718500000,
  "data": {
    "order_id": "ord_771",
    "amount": 29000,
    "currency": "KRW"
  }
}

여기서 id는 이 이벤트의 고유 식별자다. 뒤에서 다룰 멱등성 처리의 핵심 열쇠이므로 기억해 두자.

서명 검증 — 이 POST가 진짜인가

웹훅의 콜백 URL은 인터넷에 열려 있다. 누구든 그 URL을 알아내면 가짜 POST를 보내 “결제가 완료됐다”고 우리 서버를 속일 수 있다. 그래서 거의 모든 웹훅 제공자는 서명(signature)을 함께 보낸다.

방식은 HMAC-SHA256이 표준이다. 제공자와 수신자는 사전에 공유 시크릿(secret)을 나눠 갖는다. 제공자는 요청 본문 전체를 이 시크릿으로 HMAC 해싱해 X-Signature: sha256=... 같은 헤더에 담아 보낸다. 수신자는 받은 본문을 같은 시크릿으로 다시 해싱해, 헤더의 값과 일치하는지 확인한다.

웹훅 전달과 서명 검증, 재시도 흐름

본문을 한 글자라도 바꾸면 해시가 완전히 달라지므로 위변조를 막고, 시크릿을 모르는 공격자는 올바른 서명을 만들 수 없으므로 스푸핑을 막는다.

import hmac, hashlib

def verify(body: bytes, header_sig: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    # 타이밍 공격 방지를 위해 반드시 상수 시간 비교
    return hmac.compare_digest(expected, header_sig)

두 가지를 빠뜨리기 쉽다. 첫째, 비교는 ==가 아니라 hmac.compare_digest 같은 상수 시간 비교여야 타이밍 공격을 막는다. 둘째, 서명은 파싱 이전의 원본 바이트(raw body)로 계산해야 한다. JSON으로 파싱했다가 다시 직렬화하면 공백·키 순서가 달라져 해시가 어긋난다.

재시도와 멱등성 — at-least-once의 숙명

웹훅은 네트워크 너머의 다른 서버를 호출하는 일이다. 수신측이 잠시 죽어 있거나, 타임아웃이 나거나, 5xx를 반환할 수 있다. 그래서 제공자는 2xx 응답을 받지 못하면 재시도한다. 보통 지수 백오프(exponential backoff)로 간격을 늘려 가며 수 시간에서 수 일에 걸쳐 다시 보낸다.

수신측은 2xx를 반환해 “잘 받았다”는 ack를 보낸다. 핵심은 이것이 at-least-once(최소 한 번) 전달이라는 점이다. 즉 같은 이벤트가 두 번 이상 도착할 수 있다. ack가 네트워크상에서 유실되면, 제공자는 우리가 못 받은 줄 알고 똑같은 이벤트를 다시 보낸다.

따라서 수신 처리는 반드시 멱등(idempotent)해야 한다. 같은 이벤트를 두 번 처리해도 결과가 한 번 처리한 것과 같아야 한다. 방법은 단순하다. 페이로드의 id를 키로 “이미 처리한 이벤트인지”를 기록해 두고, 중복이면 즉시 무시한다.

def handle(event):
    if seen.add_if_absent(event["id"]):   # 원자적으로 추가 시도
        process(event)                    # 처음 본 이벤트만 처리
    return 200                            # 어떤 경우든 ack는 200

타임아웃과 비동기 처리

제공자는 콜백에 보통 짧은 타임아웃(수 초)을 건다. 그 안에 2xx를 못 받으면 실패로 간주하고 재시도 큐에 넣는다. 그런데 수신 핸들러에서 무거운 작업(이메일 발송, 외부 API 호출, DB 집계)을 동기로 처리하면 타임아웃을 넘기기 쉽다. 그러면 작업은 성공했는데 ack가 늦어 제공자가 재시도하고, 결국 중복 처리가 폭증한다.

정석은 받고 → 검증하고 → 큐에 적재하고 → 즉시 200 반환이다. 실제 무거운 처리는 워커가 비동기로 큐에서 꺼내 수행한다. 핸들러는 “접수 확인”만 빠르게 하고, 처리의 신뢰성은 큐와 멱등성에 맡긴다.

SSRF와 보안 함정

콜백 URL을 사용자가 자유롭게 등록할 수 있게 만들면 SSRF(Server-Side Request Forgery) 위험이 생긴다. 공격자가 http://169.254.169.254/(클라우드 메타데이터)나 http://localhost:6379/(내부 Redis) 같은 내부 주소를 콜백으로 등록하면, 제공자 서버가 그 내부 자원을 대신 두드리게 된다. 방어책으로는 등록 시 사설 IP 대역·루프백·메타데이터 주소를 차단하고, 리다이렉트를 따라가지 않으며, https만 허용하는 것이 기본이다. 수신측에서는 서명 검증을 통과하지 못한 요청을 처리 전에 즉시 버려야 한다.

SSE·WebSocket과 무엇이 다른가

지난 글들에서 본 SSE와 WebSocket도 “서버가 클라이언트에게 데이터를 보낸다”는 점은 같다. 결정적 차이는 연결의 성격이다. SSE와 WebSocket은 클라이언트가 먼저 연 하나의 지속 연결 안에서 서버가 데이터를 흘려보낸다. 즉 서버→클라이언트지만 기존 연결 내부의 흐름이다. 반면 웹훅은 이벤트가 생길 때마다 제공자가 수신측을 향해 완전히 새로운 HTTP 요청을 맺어 POST를 보낸다. 그래서 양쪽 모두 공개된 HTTP 엔드포인트를 가진 서버 대 서버 통신에 적합하다. 브라우저처럼 공개 주소가 없는 클라이언트에는 SSE·WebSocket이, 상시 운영되는 백엔드끼리는 웹훅이 어울린다.

표준화 시도도 있다. WebSub(옛 PubSubHubbub)는 발행자·구독자·허브(hub)를 정의해 웹훅 패턴을 표준 프로토콜로 묶은 것으로, 구독 등록과 콘텐츠 배포를 규약화한다. 다만 실무에서는 각 제공자가 자체 형식의 웹훅을 쓰는 경우가 압도적으로 많다.

정리

  • 웹훅은 방향을 뒤집은 HTTP 콜백이다. 클라이언트가 묻는 폴링 대신, 서버가 이벤트 발생 시 POST를 보낸다.
  • 콜백 URL은 공개돼 있으므로 HMAC-SHA256 서명으로 진위를 검증한다. 원본 바이트로, 상수 시간 비교로.
  • 전달은 at-least-once다. event id로 멱등 처리를 보장하지 않으면 중복이 사고로 이어진다.
  • 핸들러는 검증 후 큐에 적재하고 즉시 2xx. 무거운 일은 워커가 비동기로.
  • 콜백 URL 등록에는 SSRF 방어가 필수다.

HTTP가 무엇인가라는 첫 질문에서 출발해 메서드와 상태 코드, 헤더와 캐시, 쿠키와 세션, HTTP/2와 HTTP/3, 스트리밍과 SSE를 지나 마침내 방향을 뒤집은 웹훅까지 왔다. 그 모든 주제를 관통한 한 가지가 있다면, HTTP는 단순한 요청·응답 규약을 넘어 웹 위의 거의 모든 통신이 합의하는 공통 언어라는 점이다. 그동안 함께 읽어 주신 모든 분께 감사드린다.


지난 글: Server-Sent Events — 서버에서 흘려보내는 이벤트


읽어주셔서 감사합니다. 😊