이코에코(Eco²)/Message Queue

이코에코(Eco²) Message Queue #4: SSE vs Webhook vs Websocket

mango_fr 2025. 12. 22. 15:40

┌──────────────────────────────────────────────────────────────────────────────┐
│ 타임라인                                                                       │
├──────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  0s      POST /scan/classify {"realtime": true}                              │
│          └── Response: {"task_id": "abc", "status": "processing"}            │
│                                                                              │
│  0.1s    GET /scan/abc/stream (SSE 연결)                                      │
│                                                                              │
│  0s~2.5s vision_task 처리 중 (Queue: scan.vision)                              │
│          └── SSE: {"step": "vision", "progress": 10, "status": "processing"} │
│                                                                              │
│  2.5s    vision_task 완료    (Queue: scan.vision)                             │
│          └── SSE: {"step": "vision", "progress": 33, "status": "completed"}  │
│                                                                              │
│  2.6s    rule_task 완료      (Queue: scan.rule)                               │
│          └── SSE: {"step": "rule", "progress": 50, "status": "completed"}    │
│                                                                              │
│  4.5s    answer_task 완료    (Queue: scan.answer)                             │
│          └── SSE: {"step": "answer", "progress": 100, "status": "completed", │
│                    "result": {...}}  ← 최종 결과 포함                           │
│                                                                              │
│  4.5s~   reward_task 저장 로직 진행 (character.owned, my.owned, 사용자 무관)       │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘

 

스텝별로 Task Queue를 분할해 기존 동기(5-25s)로 진행되던 Scan 로직을 비동기로 전환하며 유연성을 확보했다.

확보한 이점이 UX로 이어지도록 LLM Classification 단계를 실시간으로 전달해 프론트 UI(✅)와 연동하는 작업을 진행 중이다.

인프라 환경

  • Backend: Kubernetes + Istio Service Mesh
  • Message Broker: RabbitMQ (Celery)
  • Frontend: Vercel에 배포된 React 앱 (Next.js)

검토한 옵션들

1. 단계별 Webhook

Celery Worker ──POST──▶ Vercel (Frontend Server) ──Push──▶ Browser

장점:

  • 기존 프론트엔드 서버 리소스 활용 (vercel)
  • 구현 단순 (각 task에 1줄 추가)

단점:

  • ⚠️ Vercel 과금 위험 - Serverless Function이 Webhook을 받아 브라우저로 전달해야 함

2. Server-Sent Events (SSE)

Browser ◀════GET════ scan-api (HTTP 연결 유지)
                         ▲
                         │ Celery Events
                         ▼
                    Celery Worker

장점:

  • 클라이언트 서버 불필요 (브라우저가 직접 연결)
  • HTTP/2 멀티플렉싱으로 연결 효율적
  • 브라우저 자동 재연결 지원
  • Scan API 동기 로직 기준 p99이 25s까지 발생하는 경우도 존재, 긴 연결이 발생해도 안정성을 유지해야 함
  • ChatGPT, Cursor 등 다수의 에이전트 앱에서 LLM API의 긴 레이턴시를 SSE로 해결

단점:

  • scan-api Pod가 연결을 유지해야 함

3. WebSocket

Browser ◀═══WS═══▶ scan-api
                      ▲
                      │ Celery Events
                      ▼
                 Celery Worker

장점:

  • 양방향 통신 가능

단점:

  • HTTP/2 멀티플렉싱 불가 (각 WS = 별도 TCP)
  • Istio 설정 추가 필요 (upgradeConfigs)
  • 연결당 리소스 소모 더 높음
  • 서버 측에서 일방적으로 진행상황과 답변만 내리기에 양방향 연결 불필요

Vercel 과금 분석

Vercel 공식 가격 페이지에서 확인한 주요 항목:

Serverless Functions (Webhook 수신 시)

항목 Hobby (무료) Pro ($20/월)
Active CPU 4시간/월 $0.128/시간
Invocations 1M/월 $0.60/1M 이후
Memory (GB-hr) 360 GB-hr/월 $0.0106/GB-hr

Edge Requests

항목 Hobby Pro
포함량 1M/월 10M/월
초과 시 - $2/1M

문제 시나리오

Webhook 방식 채택 시:

1,000 DAU × 10 스캔/일 × 3 Webhook(단계별) = 30,000 요청/일
                                            = 900,000 요청/월

각 Webhook 처리에 Vercel Function이 100ms 실행된다면:

  • Active CPU: 900,000 × 0.1초 = 25시간/월
  • 예상 추가 비용: ~$2.5/월 (Pro 기준)

단순 Webhook만으로는 비용이 크지 않지만, 백엔드 위주로 디벨롭을 진행하고 있기에 과금을 강제할 순 없었다.

또다른 문제는 Webhook을 받아서 브라우저로 전달하는 로직이다.

SSE나 WebSocket을 Vercel에서 유지하면:

  • 연결당 Active CPU 지속 소모
  • 4-5초 파이프라인 × 동시 사용자 수 = 급격한 비용 증가
  • Serverless의 cold start로 인한 지연

서버 부하 비교: SSE vs WebSocket

연결당 리소스

항목 SSE WebSocket
메모리 ~2-4KB ~4-8KB
CPU (idle) 거의 0 Ping/Pong 처리
HTTP/2 멀티플렉싱 ✅ 가능 ❌ 불가
File Descriptor 공유 가능 (HTTP/2) 연결당 1개

1,000 동시 연결 기준

┌─────────────────────────────────────────┐
│ SSE (HTTP/2)                            │
│  • Memory: ~2-4MB                       │
│  • TCP 연결: ~100개 (멀티플렉싱)          │
│  • 연결 생성 RPS: ~2,000/s              │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ WebSocket                               │
│  • Memory: ~4-8MB                       │
│  • TCP 연결: 1,000개 (각각 별도)          │
│  • 연결 생성 RPS: ~500/s (Upgrade 오버헤드) │
└─────────────────────────────────────────┘

Istio 호환성

항목 SSE WebSocket
기본 지원 ✅ HTTP ⚠️ 설정 필요
VirtualService 일반 라우팅 upgradeConfigs 추가
Load Balancer Sticky 불필요 Sticky 권장

최종 결정: SSE

선택 이유

  1. 긴 레이턴시동안 안정적인 연결 필요
    • Scan API 동기 엔드포인트 기준 p99이 25s까지 발생하는 경우도 존재, 장시간동안 안정적인 연결이 최우선 가치
    • ChatGPT, Cursor 등 다수의 에이전트 앱에서 LLM API의 긴 레이턴시를 SSE로 해결
  2. 클라이언트 서버 불필요
    • Vercel Function 호출 없이 브라우저 → K8s 직접 연결
    • Vercel 과금 회피
  3. 서버 부하 최소화
    • HTTP/2 멀티플렉싱으로 연결 효율적
    • WebSocket 대비 메모리/CPU 절반 수준
  4. 기존 인프라 활용
    • Redis Pub/Sub만 추가 (이미 Redis 운영 중)
    • Istio 설정 변경 불필요
  5. 구현 복잡도
    • FastAPI StreamingResponse 활용
    • 브라우저 EventSource API 사용 (자동 재연결)

구현 개요

Backend (FastAPI)

@router.get("/scan/{task_id}/stream")
async def stream_progress(task_id: str):
    async def event_generator():
        redis = get_redis()
        pubsub = redis.pubsub()
        await pubsub.subscribe(f"scan:{task_id}:progress")

        async for message in pubsub.listen():
            if message["type"] == "message":
                yield f"data: {message['data']}\n\n"
                if "completed" in message["data"]:
                    break

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream"
    )

Celery Worker

# vision_task 완료 시
redis.publish(f"scan:{task_id}:progress", json.dumps({
    "step": "vision",
    "progress": 33,
    "status": "completed"
}))

Frontend (React)

const eventSource = new EventSource(`/api/scan/${taskId}/stream`);
eventSource.onmessage = (e) => {
  const data = JSON.parse(e.data);
  setProgress(data.progress);
  if (data.status === 'completed') {
    eventSource.close();
  }
};

요약

기준 Webhook ✅ SSE WebSocket
Vercel 과금 ⚠️ 위험 ✅ 안전 ✅ 안전
서버 부하 ✅ 낮음 ✅ 낮음 ⚠️ 중간
구현 복잡도 ✅ 낮음 ✅ 낮음 ⚠️ 중간
양방향 통신 ❌ 불가 ❌ 불가 ✅ 가능

 


Reference

GitHub

Service