ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 이코에코(Eco²) Message Queue #4: SSE vs Webhook vs Websocket
    이코에코(Eco²)/Message Queue 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

    댓글

ABOUT ME

🎓 부산대학교 정보컴퓨터공학과 학사: 2017.03 - 2023.08
☁️ Rakuten Symphony Jr. Cloud Engineer: 2024.12.09 - 2025.08.31
🏆 2025 AI 새싹톤 우수상 수상: 2025.10.30 - 2025.12.02
🌏 이코에코(Eco²) 백엔드/인프라 고도화 중: 2025.12 - Present

Designed by Mango