본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

웹훅과 202 Accepted — 호출 방향을 뒤집고 작업을 분리하기

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

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"일반적인 HTTP 호출에서는 클라이언트가 요청하고 서버가 응답해. 웹훅은 이벤트를 만든 서비스가 등록된 수신 엔드포인트를 호출하면서 그 방향을 뒤집어. 오래 걸리는 작업에는 202 Accepted와 상태 URL을 사용해 요청 수락과 작업 완료를 분리할 수 있어. 두 패턴 모두 장기 연결 없이 HTTP 안에서 비동기 흐름을 구성해."

웹훅 — 이벤트가 호출을 시작한다

웹훅은 관심 있는 이벤트가 발생했을 때 서비스가 등록된 수신 URL로 보내는 HTTP 요청이야. 결제가 처리되면 Stripe가 수신 엔드포인트를 호출하고, pull request가 열리면 GitHub이 호출하며, 이메일이 반송되면 SendGrid가 관련 이벤트를 전달할 수 있어.

수신 측이 URL과 구독할 이벤트를 등록하면 보내는 서비스는 이벤트가 생길 때마다 보통 JSON 본문을 담은 POST 요청을 보내. 수신 측은 요청의 진위를 확인하고 안전하게 접수한 뒤 약속된 2xx 응답을 반환해. 보내는 쪽과 받는 쪽은 각각 재시도 정책과 중복 처리, 보안, 관측 가능성에 대한 책임을 나눠 가져.

웹훅 전달에서 어려운 세 가지

1. 전달 신뢰성. 수신 서버가 일시적으로 응답하지 않거나 느릴 수 있어. 보내는 쪽은 재시도할 상태 코드와 네트워크 오류를 정하고, 지수 백오프와 무작위 지연, 최대 시도 횟수나 보존 기간을 둬야 해. 끝내 전달하지 못한 이벤트는 전달 기록과 경고, 수동 재전송이나 실패 보관 절차로 이어져야 해. 구체적인 일정은 서비스 계약마다 다르므로 외부 제공자의 현재 정책을 그대로 확인해야 해.

2. 요청 검증. 공개된 수신 URL에는 공격자도 POST 요청을 보낼 수 있어. 제공자가 정한 방식에 따라 원문 본문과 타임스탬프 등을 공유 비밀값으로 서명하고, 수신 측은 본문을 해석하기 전에 서명과 허용 시간 범위를 검증해야 해. Stripe는 Stripe-Signature, GitHub은 X-Hub-Signature-256 헤더를 사용하지만 서명 대상과 헤더 문법은 서로 다르므로 각 제공자의 SDK나 명세를 따라야 해.

3. 멱등성과 순서. 재시도나 네트워크 경로 때문에 같은 이벤트가 두 번 이상 도착할 수 있고 서로 다른 이벤트의 순서도 바뀔 수 있어. 고유한 event_id를 원자적으로 기록해 중복 처리를 막고, 처리 자체도 멱등하게 만들어야 해. 순서가 중요하다면 객체 버전이나 시퀀스를 비교하거나 현재 상태를 다시 조회해 오래된 이벤트가 최신 상태를 덮지 않게 해야 해.

수신 측의 기본 원칙

  • 안전하게 접수한 뒤 빠르게 2xx를 반환해. 서명 검증과 최소한의 유효성 검사를 마치고 이벤트를 내구성 있는 대기열이나 저장소에 기록한 다음 제공자가 요구하는 시간 안에 2xx로 응답해. 접수하지 못했다면 성공을 가장하지 말고 제공자의 재시도 정책에 맞는 오류를 반환해야 해.
  • 제공자 방식대로 서명을 검증해. 역직렬화하기 전의 원문 본문과 제공된 타임스탬프를 사용하고, 허용 시간 범위와 키 회전도 처리해. 불일치하거나 너무 오래된 요청은 제공자 명세에 맞는 4xx로 거부해.
  • event_id와 버전으로 중복과 역순을 다뤄. 처리한 ID를 작업 결과와 함께 원자적으로 저장하고, 중복이면 부작용을 다시 만들지 않은 채 성공 응답을 반환해. TTL은 제공자의 재시도·수동 재전송 기간보다 충분히 길어야 해.
  • 공개 엔드포인트를 안전하게 운영해. 인터넷에서 접근 가능한 HTTPS 주소와 인증 가능한 서명 검증이 필요해. 로컬 개발에서는 ngrok 같은 터널을 쓸 수 있지만, IP 허용 목록이나 터널 주소만으로 요청 진위를 대신 확인해서는 안 돼.

비동기 202 패턴 — 수락과 완료를 분리하기

콜백 URL 없이 오래 걸리는 작업을 시작해야 할 때는 202 Accepted를 사용할 수 있어. 202는 요청을 처리 대상으로 받아들였다는 비확정 응답이며, 작업이 이미 시작됐거나 반드시 성공한다는 보장은 아니야. 흔히 다음과 같은 상태 리소스 패턴과 함께 사용해:

  1. 클라이언트가 작업 시작 요청을 보내면 서버가 내구성 있는 대기열에 기록하고 Location: /jobs/abc202 Accepted를 반환해. 시작 요청을 재시도할 수 있다면 멱등성 키로 중복 작업 생성을 막아.
  2. 클라이언트는 상태가 done 또는 failed가 될 때까지 GET /jobs/abc를 조회해. Retry-After가 있으면 따르고, 없더라도 백오프와 무작위 지연으로 과도한 조회를 피해.
  3. 작업 리소스는 현재 상태와 진행률, 오류 정보를 제공하고 완료되면 결과나 결과 링크를 돌려줘.

보고서 생성, 미디어 변환, 대량 갱신처럼 요청 시간 안에 끝나기 어려운 작업에 이 패턴을 쓸 수 있어. 클라이언트는 원래 HTTP 연결을 계속 붙잡지 않아도 되지만, 202 자체가 나중에 결과를 밀어 주지는 않으므로 상태 조회나 별도의 콜백 방식을 명시적으로 설계해야 해.

웹훅과 202+Location은 한 번의 요청과 응답에 완료를 가두지 않는 두 가지 HTTP 패턴이야. 웹훅은 이벤트를 만든 서비스가 등록된 엔드포인트를 호출하고, 202 패턴은 요청한 클라이언트가 상태 리소스를 조회하도록 안내해. 어느 쪽이든 WebSocket 없이 구성할 수 있지만 재시도, 중복, 순서, 인증, 실패 상태를 애플리케이션 수준에서 분명히 정의해야 해.

cwkPippa의 비동기 작업

cwkPippa는 여러 AI 브레인이 참여하는 Council 마무리처럼 한 번의 요청 시간 안에 끝나기 어려운 작업에 202 + Location 패턴을 사용해. 마무리 요청을 POST로 보내면 작업 URL을 담은 202 응답이 돌아오고, 프런트엔드는 해당 작업에서 status: done과 완료된 라운드 링크를 받을 때까지 상태를 조회해. 현재 제3자 소비자에게 웹훅을 보내는 기능은 없지만, 외부 제공자의 웹훅을 받는 기능을 추가한다면 원문 서명 검증, 원자적 중복 제거, 내구성 있는 대기열, 빠른 2xx 응답을 같은 경계로 묶어야 해.

Code

교육용 웹훅 수신 흐름 — 실제 제공자 서명 검증에는 공식 SDK나 명세 필요·python
# Server 쪽 — HMAC signature 검증 가진 webhook 받기
import hashlib, hmac
from fastapi import FastAPI, Request, Header, HTTPException, status

app = FastAPI()
WEBHOOK_SECRET = b'shared-secret-from-env'
_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}
교육용 202 패턴 — 작업 생성, Location 응답, 상태 조회·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
클라이언트 — 완료 또는 실패까지 Location 상태 리소스 조회·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로 두 가지 흐름을 만들어 봐. 먼저 POST /webhooks/test 수신 엔드포인트에서 원문 본문과 타임스탬프를 공유 비밀값으로 만든 HMAC-SHA256 서명으로 검증하고, event_id를 원자적으로 기록해 중복을 걸러낸 뒤 빠르게 2xx를 반환해. 같은 이벤트를 두 번 보내고, 서로 다른 두 이벤트를 역순으로 보내도 최종 상태가 올바른지 시험해. 다음으로 POST /jobs가 202, Location, job_id를 반환하고 GET /jobs/{id}가 queued, running, done, failed 상태를 알려 주는 비동기 작업 API를 만들어. 같은 멱등성 키로 시작 요청을 두 번 보내도 작업이 하나만 생기는지 확인해.
Hint
교육용 HMAC은 원문 바이트와 타임스탬프를 정해진 형식으로 묶어 계산하고 hmac.compare_digest로 비교해. 운영에서는 제공자 SDK와 재전송 허용 시간, 키 회전 규칙을 따라야 해. event_id 확인과 기록은 한 원자적 연산으로 묶고, 실제 작업에 들어가기 전에 내구성 있게 저장해. 작업 상태도 프로세스 메모리가 아니라 재시작 뒤 남는 저장소에 두고, 클라이언트는 Retry-After가 있으면 따르거나 백오프를 적용해 조회해.

Progress

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

댓글 0

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

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