-
이코에코(Eco²) Streams & Scaling for SSE #6: Event Router + Pub/Sub (Fan-out Layer)이코에코(Eco²)/Event Streams & Scaling 2025. 12. 27. 19:15

TL;DR
SSE Gateway, 단일 Consumer에서 분산 Fan-out까지에서 Consistent Hash + StatefulSet으로 non-HA 연결 라우팅을 해결했지만, 수평확장을 적용할 경우 이벤트가 Dest Pod로 도착하는 것이 보장되지 않았습니다.
Worker의 해싱과 Istio의 해싱이 일치하지 않아, Pod 수가 동적인 HA 환경에선 라우팅이 불가능했고, 해싱 불일치를 해결하기 위해선 Istio의 Consistent Hashing에 호응하도록 Woker 측 추가 구현 부담이 가해졌습니다. 이는 해시 기반 라우팅이 SSE HA를 위한 해법이 아님을 시사했고 결국 Fan-out 계층(Event Router+Pub/Sub)의 분리로 이어졌습니다.
1. 지난 포스팅 정리
1 연결당 XREAD 50 VU → CPU 85%, 완료율 62% 2 단일 Consumer CPU 15%, 수평확장 불가 3 StatefulSet + Consistent Hash 해싱 정합성 불일치
상세 구현과 성능 지표는 #98 포스팅 참조.
2. 본질적 문제: "연결 라우팅" ≠ "이벤트 라우팅"
2.1 Istio가 보장하는 것
Istio Consistent Hash가 보장하는 것:
- ✅ 같은 job_id 요청 → 같은 Pod로 연결
- ❌ 해당 job_id의 이벤트가 그 Pod의 shard로 도착 (보장 안 됨)
┌─────────────────────────────────────────────────────────────────────────┐ │ Istio는 연결만 고정, 이벤트 라우팅은 비어있다 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────┐ │ │ │ Istio │ │ │ │ Consistent Hash │ │ │ │ │ │ │ Client ─────────▶ │ hash("job-123") → Pod-1 선택 │ │ │ (job-123) │ │ │ │ └───────────────────────┬──────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ Pod-1 │ │ │ │ shard_id=1 │ │ │ │ │ │ │ │ XREAD │ │ │ │ scan:events:1 │ │ └─────────────┘ │ │ ▲ │ │ │ ❌ 이벤트 없음! │ │ │ │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ │ │ │ ┌─────────────┐ │ │ │ Worker │ │ │ │ │ │ │ │ MD5("job-123") │ │ │ % 4 = 3 │ │ │ │ │ │ │ │ XADD │ │ │ │ scan:events:3 ◀── 여기에 씀! │ │ └─────────────┘ │ │ │ │ ⚠️ Worker의 해싱(MD5 % shard_count)과 │ │ Istio의 해싱(Maglev/Ring Hash)이 일치하지 않음 │ │ │ │ 결과: Client는 Pod-1에 연결됐지만, │ │ 이벤트는 shard-3에 있어서 영원히 수신 못 함 │ │ │ └─────────────────────────────────────────────────────────────────────────┘2.2 해싱 알고리즘 불일치
컴포넌트 해싱 방식 결과 Worker (Python) MD5(job_id) % shard_count정수 0~3 Istio (Envoy) Maglev/Ring Hash 가용 Pod 중 선택
3. HPA/KEDA와의 철학 충돌
StatefulSet + shard 고정 모델의 근본적 문제:
┌─────────────────────────────────────────────────────────────────────────┐ │ HPA/KEDA 스케일링 시 문제 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ Before: 4 Pods (shard 0, 1, 2, 3) │ │ │ │ sse-gateway-0 ──▶ scan:events:0 │ │ sse-gateway-1 ──▶ scan:events:1 │ │ sse-gateway-2 ──▶ scan:events:2 │ │ sse-gateway-3 ──▶ scan:events:3 │ │ │ │ ═══════════════════════════════════════════════════════════════════ │ │ │ │ After: HPA가 6 Pods로 확장 │ │ │ │ sse-gateway-0 ──▶ scan:events:0 │ │ sse-gateway-1 ──▶ scan:events:1 │ │ sse-gateway-2 ──▶ scan:events:2 │ │ sse-gateway-3 ──▶ scan:events:3 │ │ sse-gateway-4 ──▶ scan:events:??? ◀── shard_count=4면 어디 읽어야? │ │ sse-gateway-5 ──▶ scan:events:??? ◀── 새 Pod는 놀게 됨 │ │ │ │ ⚠️ shard_count를 동적으로 바꾸면? │ │ → 기존 해시 결과가 바뀜 → 기존 연결이 잘못된 Pod로 감 │ │ │ │ 결론: Pod 수 변동 = shard 재할당 필요 = 정합성 문제 │ │ │ └─────────────────────────────────────────────────────────────────────────┘
4. 근본 원인: 연결과 이벤트 소비가 결합됨
┌────────────────┐ │ SSE Pod │ │ │ │ 1. 연결 수신 │ ◀── Istio가 결정 │ + │ │ 2. 이벤트 소비│ ◀── 자신의 shard만 읽음 │ + │ │ 3. 클라이언트 │ │ 전달 │ └────────────────┘ 문제: 1번과 2번이 같은 곳에서 일어나야 함 → 외부(Istio)와 내부(shard)의 매핑을 강제해야 함 → Pod 수 변경 시 매핑 재조정 필요 → HPA/KEDA 사용 불가
5. 해결책: 라우팅(연결)과 소비(이벤트) 분리
핵심 인사이트: SSE Pod는 이벤트를 직접 읽지 않는다.
┌─────────────────────────────────────────────────────────────────────────┐ │ 분리된 모델: Fan-out 계층 도입 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │ │ SSE Pod 0 │ │ SSE Pod 1 │ │ SSE Pod 2 │ │ │ │ 연결 수신만 │ │ 연결 수신만 │ │ 연결 수신만 │ │ │ │ 이벤트 구독 │ │ 이벤트 구독 │ │ 이벤트 구독 │ │ │ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │ │ │ SUBSCRIBE │ SUBSCRIBE │ SUBSCRIBE │ │ ▼ ▼ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ Redis Pub/Sub │ │ │ │ sse:events:job-123 sse:events:job-456 sse:events:job-789 │ │ │ └────────────────────────────────▲────────────────────────────────┘ │ │ │ PUBLISH │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ Event Router │ │ │ │ XREADGROUP (Consumer Group) │ │ │ │ 모든 shard 소비 → job_id별 채널로 발행 │ │ │ └────────────────────────────────▲────────────────────────────────┘ │ │ │ XADD │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ Redis Streams │ │ │ │ scan:events:0 scan:events:1 scan:events:2 scan:events:3 │ │ │ └─────────────────────────────────────────────────────────────────┘ │ │ ▲ Worker │ │ │ │ ✅ SSE Pod는 어느 shard를 읽는지 몰라도 됨 │ │ ✅ Event Router가 모든 이벤트를 수집 → 올바른 채널로 분배 │ │ ✅ Pod 수와 shard 수가 독립적 → HPA/KEDA 자유롭게 사용 가능 │ │ │ └─────────────────────────────────────────────────────────────────────────┘
6. 결론
연결 라우팅 ✅ 일관된 Pod 선택 ✅ 아무 Pod나 OK 이벤트 도달 보장 ❌ 해싱 불일치 ✅ Pub/Sub로 분배 HPA/KEDA ❌ shard 재할당 필요 ✅ Pod 수 무관 운영 복잡도 높음 (Envoy Filter, StatefulSet) 낮음 (Deployment)
연결 라우팅(Istio)과 이벤트 소비(Streams)를 분리하면 SSE Pod는 Stateless가 되고, HPA/KEDA로 자유롭게 확장할 수 있게 된다.이어지는 포스팅에선 Event Router의 역할과 개발 과정, Fan-out과 Streams로 구성된 Event-Bus 계층의 개발 과정을 기록할 예정이다.
'이코에코(Eco²) > Event Streams & Scaling' 카테고리의 다른 글