C.W.K.
Stream
Lesson 05 of 05 · published

Webhook & Async-202 — Inverted REST 와 polling 패턴

~10 min · streaming-async, webhooks, 202, async

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"REST 가 request/response. Webhook 이 그거 뒤집음 — server 가 너의 endpoint 의 client 됨. 202 + Location 이 REST 의 '이거 시간 걸려; 여기 update 위해 poll' 말 방식. 둘 다 HTTP 안 떠나고 동기 request timeout 탈출 가능."

Webhook — 뒤집힌 호출

Webhook 이 그냥 관심 event 일어나면 service 가 고객 제공한 URL 에 만드는 HTTP POST. Stripe 가 payment 성공 시 네 endpoint ping; GitHub 가 누가 pull request 열면 네 거 ping; SendGrid 가 email bounce 시 네 거 ping.

Shape: 고객이 너한테 URL 등록; event 일어나면 그 URL 에 JSON body POST. 고객 endpoint 가 성공에 2xx 돌려줌. 나머지 다 네 문제.

Webhook 보내기 어려운 세 가지

1. 신뢰성. 고객 server 가 다운, 느림, 잘못될 수도. Sender 로서 네 일: exponential backoff 로 retry (예: 즉시, 1m, 5m, 30m, 1h, 6h, 24h 까지 3 일), 그 다음 dead-letter (log + alert). Stripe 가 3일 retry; GitHub 가 ~8시간.

2. 검증. 고객 endpoint 가 네 IP 에서 unsolicited POST 받음. 진짜 너고 attacker pretending 아닌 거 어떻게 알아? 공유 secret 으로 HMAC-SHA256 으로 각 payload 서명; header 에 signature 포함. 고객이 검증. Stripe 의 Stripe-Signature, GitHub 의 X-Hub-Signature-256.

3. Idempotency. Retry 의미가 같은 event 2-7 번 전달 가능. 각 전달이 receiver 가 dedupe 가능한 unique event_id 가짐. Receiver 일이 handler idempotent 만들기 (혹은 event_id 써서 중복 건너뜀).

Receiver 가 해야 하는 것

  • 빨리 2xx 응답. Webhook sender 가 초 내 response 기대; 느린 response 가 timeout 과 retry 야기. Payload 받고, 작업 enqueue, 즉시 200 돌려줌.
  • Signature 검증. 공유 secret 으로 raw body 의 HMAC 계산; header signature 와 비교. 불일치면 401 거부.
  • event_id 로 dedupe. 본 ID 저장 (TTL 가진 Redis 동작). 중복 보면 재처리 없이 200 돌려줌.
  • 공개. Endpoint 가 인터넷에서 도달 가능 필수. 로컬 개발이 localhost 노출 위해 ngrok 같은 거 필요.

Async-202 패턴 — Webhook 없는 REST

때로 고객 URL 없음; 그냥 느린 operation 있음. 202 Accepted 가 REST 의 답:

  1. Client 가 work POST. Server 가 enqueue, 즉시 Location: /jobs/abc 가진 202 Accepted 돌려줌.
  2. Client 가 status 가 done (혹은 failed) 까지 GET /jobs/abc poll.
  3. Job resource 가 status, progress, (완료 시) 결과나 결과 link 운반.

이게 Stripe 가 payout 처리, AWS 가 long batch operation 처리, Cloudflare 가 bulk DNS update 처리 방식. Client 가 절대 장기 HTTP 연결 보유 대기 안 함; 싼 status endpoint poll.

Webhook 과 202+Location 이 '이 작업이 한 request/response 에 안 맞음' 위한 두 REST 패턴. Webhook = 뭔가 일어나면 server 가 너한테 push. 202 = server 가 너한테 status URL poll 하라 알려줌. 둘 다 WebSocket 이나 영속 연결 없이 HTTP 안 async work 살게.

cwkPippa 의 async 현실

cwkPippa 가 council finalization 에 202 + Location 씀 — 4 brain council 이 30-90초 걸림, 어느 합리적 HTTP timeout 도 한참 넘음. Finalize POST 가 job URL 가진 202 돌려줌; frontend 가 job 이 finalized round link 가진 status: done 운반할 때까지 2s 마다 poll. cwkPippa 가 현재 webhook 안 보냄 (제 3자 소비자 없음), 근데 upstream provider 에서 RECEIVES — Anthropic 과 OpenAI 둘 다 청구 event 위해 cwkPippa 의 webhook receiver ping. Receiver 가 signature 검증, event_id 로 dedupe, 작업 enqueue, 200 빨리 돌려줌. 표준 패턴.

Code

Webhook receiver: HMAC 검증, id 로 dedupe, enqueue, 2xx 빨리 돌려줌·python
# Server 쪽 — HMAC signature 검증 가진 webhook 받기
import hashlib, hmac
from fastapi import FastAPI, Request, Header, HTTPException, status

app = FastAPI()
WEBHOOK_SECRET = b'env-에서-온-공유-secret'
_seen_event_ids: set[str] = set()  # production 엔 TTL 가진 Redis

@app.post('/webhooks/stripe')
async def stripe_webhook(
    request: Request,
    stripe_signature: str = Header(None, alias='Stripe-Signature'),
):
    raw_body = await request.body()

    # 1. Signature 검증
    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(stripe_signature.split('=')[-1], expected):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='잘못된 signature')

    payload = await request.json()
    event_id = payload.get('id')

    # 2. event_id 로 dedupe (retry 가 같은 id 전달)
    if event_id in _seen_event_ids:
        return {'received': True, 'duplicate': True}
    _seen_event_ids.add(event_id)

    # 3. 작업 enqueue; 2xx 빨리 돌려줌
    enqueue_payment_processed(payload['data']['object'])
    return {'received': True}
Async 202 패턴: enqueue, Location 돌려줌, status endpoint 노출·python
# 202 + Location — long-running operation 위한 async REST 패턴
import asyncio, uuid
from fastapi import FastAPI, BackgroundTasks, Response, status, HTTPException

app = FastAPI()
_JOBS: dict[str, dict] = {}

async def do_long_work(job_id: str, payload: dict):
    _JOBS[job_id]['status'] = 'running'
    await asyncio.sleep(30)  # 실제 작업 상상
    _JOBS[job_id]['status'] = 'done'
    _JOBS[job_id]['result'] = {'processed': True, **payload}

@app.post('/jobs', status_code=status.HTTP_202_ACCEPTED)
async def start_job(payload: dict, response: Response, bg: BackgroundTasks):
    job_id = str(uuid.uuid4())
    _JOBS[job_id] = {'status': 'queued', 'created_at': 'now'}
    bg.add_task(do_long_work, job_id, payload)
    response.headers['Location'] = f'/jobs/{job_id}'
    return {'job_id': job_id, 'status': 'queued'}

@app.get('/jobs/{job_id}')
async def get_job(job_id: str):
    job = _JOBS.get(job_id)
    if not job:
        raise HTTPException(status.HTTP_404_NOT_FOUND)
    return job
Client: status 가 terminal 상태 도달할 때까지 Location URL poll·python
# Client — 완료까지 202 status URL poll
import httpx, time

resp = httpx.post('https://api.example.com/jobs', json={'do': 'something'})
assert resp.status_code == 202
job_url = resp.headers['Location']
print(f'job 시작: {job_url}')

# 완료나 실패까지 poll
while True:
    poll = httpx.get(f'https://api.example.com{job_url}')
    status_val = poll.json()['status']
    print(f'status: {status_val}')
    if status_val == 'done':
        print('result:', poll.json()['result'])
        break
    if status_val == 'failed':
        raise RuntimeError(poll.json().get('error'))
    time.sleep(2)  # 정중한 polling interval

External links

Exercise

FastAPI 에 두 가지 만들어: (1) POST /webhooks/test 에 공유 secret 에 대해 HMAC-SHA256 signature 검증하고, payload 에서 event_id 로 dedupe 하고, 200 빨리 돌려주는 webhook receiver; (2) POST /jobs 가 202 + Location + job_id 돌려주고, GET /jobs/{id} 가 status: queued / running / done 보고하는 async job endpoint. (1) 테스트: 같은 event_id 로 같은 webhook 두 번 보내 — 두 번째가 'duplicate' 돌려줘야. (2) 테스트: job 시작하고 완료까지 poll.
Hint
HMAC signature: hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest(). hmac.compare_digest (constant-time) 로 검증. Dedupe 엔 in-memory set 이 연습에 괜찮; production 은 TTL 가진 Redis. Async job 이 BackgroundTasks 씀; status endpoint 가 그냥 dict lookup. 2s sleep 으로 status URL polling 이 canonical client 패턴 — 절대 장기 HTTP 연결 보유 대기 안 하는 거 봐.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.