-
이코에코(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
선택 이유
- 긴 레이턴시동안 안정적인 연결 필요
- Scan API 동기 엔드포인트 기준 p99이 25s까지 발생하는 경우도 존재, 장시간동안 안정적인 연결이 최우선 가치
- ChatGPT, Cursor 등 다수의 에이전트 앱에서 LLM API의 긴 레이턴시를 SSE로 해결
- 클라이언트 서버 불필요
- Vercel Function 호출 없이 브라우저 → K8s 직접 연결
- Vercel 과금 회피
- 서버 부하 최소화
- HTTP/2 멀티플렉싱으로 연결 효율적
- WebSocket 대비 메모리/CPU 절반 수준
- 기존 인프라 활용
- Redis Pub/Sub만 추가 (이미 Redis 운영 중)
- Istio 설정 변경 불필요
- 구현 복잡도
- FastAPI
StreamingResponse활용 - 브라우저
EventSourceAPI 사용 (자동 재연결)
- FastAPI
구현 개요
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
'이코에코(Eco²) > Message Queue' 카테고리의 다른 글
이코에코(Eco²) Message Queue #6: 캐릭터 보상 판정과 DB 레이어 분리, Eventual Consistency 적용 (1) (0) 2025.12.23 이코에코(Eco²) Message Queue #5: Celery Chain + Celery Events (1) (0) 2025.12.23 이코에코(Eco²) Message Queue #3: Scan 비동기 파이프라인 로드맵 (0) 2025.12.22 이코에코(Eco²) Message Queue #2: RabbitMQ 구축 (0) 2025.12.22 이코에코(Eco²) Message Queue #1: MQ 적용 가능 영역 도출 (1) 2025.12.21