이코에코(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
선택 이유
- 긴 레이턴시동안 안정적인 연결 필요
- 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 과금 | ⚠️ 위험 | ✅ 안전 | ✅ 안전 |
| 서버 부하 | ✅ 낮음 | ✅ 낮음 | ⚠️ 중간 |
| 구현 복잡도 | ✅ 낮음 | ✅ 낮음 | ⚠️ 중간 |
| 양방향 통신 | ❌ 불가 | ❌ 불가 | ✅ 가능 |