[Daily morning study] Webhook과 Polling 비교 및 구현 방법

#daily morning study

Image


Polling이란

클라이언트가 서버에 주기적으로 요청을 보내서 데이터 변경 여부를 확인하는 방식이다.

Short Polling

가장 단순한 형태. 클라이언트가 일정 간격으로 서버에 HTTP 요청을 보낸다.

Client → (매 5초) → Server: "새 데이터 있어?"
Server → Client: "없음" (or "있음 + 데이터")

문제점:

  • 대부분의 응답이 “변경 없음” → 불필요한 요청 낭비
  • 서버 부하 증가 (클라이언트 수 × 폴링 주기)
  • 데이터 변경 감지에 최대 폴링 간격만큼 지연 발생

Long Polling

요청을 보내고 서버가 새 데이터가 생길 때까지 연결을 유지한 뒤 응답하는 방식.

Client → Server: "새 데이터 있어?" (연결 유지)
Server: ... 30초 기다림 ...
Server → Client: "데이터 생겼음!" (응답 후 연결 종료)
Client → Server: 즉시 다음 요청

Short Polling보다 낫지만 여전히 연결 관리 오버헤드가 있고, 서버가 동시에 유지할 수 있는 연결 수에 제한이 있다.


Webhook이란

서버에서 이벤트가 발생했을 때 클라이언트가 미리 등록해 둔 URL로 HTTP 요청을 보내는 방식. “역방향 API” 또는 “HTTP 콜백” 이라고도 불린다.

[이벤트 발생 시]
Server → (HTTP POST) → Client's Endpoint URL

클라이언트가 묻는 게 아니라, 서버가 알아서 알려준다.


Polling vs Webhook 비교

항목Short PollingLong PollingWebhook
데이터 수신 방향클라이언트 → 서버 요청클라이언트 → 서버 요청서버 → 클라이언트 전송
실시간성낮음 (폴링 간격에 의존)보통높음 (이벤트 즉시)
서버 부하높음보통낮음
구현 복잡도낮음보통보통
네트워크 효율낮음보통높음
클라이언트 접근 가능 여부불필요불필요필요 (공개 URL)

Webhook의 단점: 클라이언트 서버가 공개적으로 접근 가능한 URL을 가지고 있어야 한다. 방화벽 뒤에 있거나 로컬 환경이면 받을 수 없다.


Webhook 구현

서버 측 (이벤트 발송)

결제가 완료됐을 때 등록된 Webhook URL에 POST 요청을 보내는 예시다.

import httpx
import json

async def dispatch_webhook(webhook_url: str, event: dict):
    payload = {
        "event": "payment.completed",
        "data": event,
        "timestamp": "2026-10-02T09:00:00Z"
    }
    async with httpx.AsyncClient() as client:
        response = await client.post(
            webhook_url,
            json=payload,
            timeout=10.0
        )
        return response.status_code

클라이언트 측 (이벤트 수신)

from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/webhooks/payment")
async def handle_payment_webhook(request: Request):
    payload = await request.json()
    
    if payload["event"] == "payment.completed":
        order_id = payload["data"]["order_id"]
        # 주문 상태 업데이트 처리
        await update_order_status(order_id, "paid")
    
    return {"status": "ok"}  # 200 응답 필수

수신 측은 반드시 빠르게 200 응답을 반환해야 한다. 처리 시간이 길면 발송 측이 타임아웃으로 실패 처리하고 재시도한다.


Webhook 보안: 서명 검증

Webhook URL이 외부에 노출되면 누구나 가짜 요청을 보낼 수 있다. HMAC 서명으로 요청이 진짜 발송 서버에서 왔는지 검증한다.

서버 측 (서명 생성)

import hmac
import hashlib
import json

def sign_payload(payload: dict, secret: str) -> str:
    body = json.dumps(payload, separators=(',', ':'))
    signature = hmac.new(
        secret.encode(),
        body.encode(),
        hashlib.sha256
    ).hexdigest()
    return f"sha256={signature}"

# 발송 시 헤더에 포함
headers = {
    "X-Webhook-Signature": sign_payload(payload, WEBHOOK_SECRET)
}

클라이언트 측 (서명 검증)

import hmac
import hashlib

def verify_signature(body: bytes, signature: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(),
        body,
        hashlib.sha256
    ).hexdigest()
    # timing-safe 비교로 타이밍 공격 방어
    return hmac.compare_digest(expected, signature)

@app.post("/webhooks/payment")
async def handle_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("X-Webhook-Signature", "")
    
    if not verify_signature(body, signature, WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail="Invalid signature")
    
    payload = json.loads(body)
    # 처리 로직

hmac.compare_digest는 두 문자열을 항상 같은 시간에 비교해서 타이밍 공격을 막는다. 일반 == 비교는 앞 글자가 다를수록 더 빨리 종료되기 때문에 서명 값 추측에 악용될 수 있다.


실패 처리와 재시도 전략

Webhook 발송은 네트워크 오류나 수신 서버 장애로 실패할 수 있다. 신뢰할 수 있는 시스템을 만들려면 재시도 로직이 필요하다.

지수 백오프 재시도 (Exponential Backoff)

import asyncio

async def dispatch_with_retry(webhook_url: str, payload: dict, max_retries=5):
    for attempt in range(max_retries):
        try:
            async with httpx.AsyncClient() as client:
                response = await client.post(webhook_url, json=payload, timeout=10)
                if response.status_code < 500:
                    return  # 성공 or 클라이언트 오류 → 재시도 불필요
        except (httpx.TimeoutException, httpx.ConnectError):
            pass
        
        # 지수 백오프: 1초, 2초, 4초, 8초, 16초
        wait = 2 ** attempt
        await asyncio.sleep(wait)
    
    # 최대 재시도 초과 → Dead Letter Queue에 적재하거나 알림
    await record_failed_webhook(webhook_url, payload)

멱등성 보장

재시도로 인해 같은 이벤트가 중복 발송될 수 있다. 수신 측은 event_id를 DB에 저장해두고 중복 처리를 막는다.

@app.post("/webhooks/payment")
async def handle_webhook(request: Request):
    payload = await request.json()
    event_id = payload["event_id"]
    
    # 이미 처리된 이벤트면 무시 (멱등성)
    if await is_already_processed(event_id):
        return {"status": "already_processed"}
    
    await process_event(payload)
    await mark_as_processed(event_id)
    return {"status": "ok"}

실제 사용 사례

서비스Webhook 용도
GitHubPR 머지, 이슈 생성, push 이벤트 → CI/CD 트리거
Stripe결제 완료, 환불, 구독 갱신 이벤트 알림
Slack외부 서비스 이벤트를 채널 메시지로 수신
TwilioSMS 수신, 통화 상태 변경 알림
Shopify주문 생성, 재고 변경, 고객 가입 이벤트

GitHub Actions의 on: push 트리거도 내부적으로 GitHub이 Runner에 Webhook을 보내는 구조다.


언제 무엇을 쓸까

  • Short Polling: 구현 단순함이 최우선이고, 실시간성이 크게 중요하지 않을 때
  • Long Polling: WebSocket/SSE가 어렵고, 준실시간이 필요할 때 (채팅 앱 초기 버전 등)
  • Webhook: 서버 간 통합 (B2B), 결제/알림처럼 이벤트 기반 처리가 필요할 때
  • WebSocket/SSE: 브라우저와 서버 간 실시간 양방향/단방향 통신이 필요할 때