[Daily morning study] Webhook과 Polling 비교 및 구현 방법
#daily morning study
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 Polling | Long Polling | Webhook |
|---|---|---|---|
| 데이터 수신 방향 | 클라이언트 → 서버 요청 | 클라이언트 → 서버 요청 | 서버 → 클라이언트 전송 |
| 실시간성 | 낮음 (폴링 간격에 의존) | 보통 | 높음 (이벤트 즉시) |
| 서버 부하 | 높음 | 보통 | 낮음 |
| 구현 복잡도 | 낮음 | 보통 | 보통 |
| 네트워크 효율 | 낮음 | 보통 | 높음 |
| 클라이언트 접근 가능 여부 | 불필요 | 불필요 | 필요 (공개 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 용도 |
|---|---|
| GitHub | PR 머지, 이슈 생성, push 이벤트 → CI/CD 트리거 |
| Stripe | 결제 완료, 환불, 구독 갱신 이벤트 알림 |
| Slack | 외부 서비스 이벤트를 채널 메시지로 수신 |
| Twilio | SMS 수신, 통화 상태 변경 알림 |
| Shopify | 주문 생성, 재고 변경, 고객 가입 이벤트 |
GitHub Actions의 on: push 트리거도 내부적으로 GitHub이 Runner에 Webhook을 보내는 구조다.
언제 무엇을 쓸까
- Short Polling: 구현 단순함이 최우선이고, 실시간성이 크게 중요하지 않을 때
- Long Polling: WebSocket/SSE가 어렵고, 준실시간이 필요할 때 (채팅 앱 초기 버전 등)
- Webhook: 서버 간 통합 (B2B), 결제/알림처럼 이벤트 기반 처리가 필요할 때
- WebSocket/SSE: 브라우저와 서버 간 실시간 양방향/단방향 통신이 필요할 때