연결 1억건이 왜 일반 서버로 안 되나?
일반 HTTP 요청과 SSE의 근본적인 차이가 여기서 드러난다.
HTTP 요청은 처리하면 서버 리소스가 해방된다. SSE는 유저가 브라우저 탭을 닫을 때까지 서버가 소켓을 유지한다. 연결 자체가 지속적으로 리소스를 점유한다.
TCP 소켓 하나가 OS 레벨에서 얼마나 먹나?
$ cat /proc/sys/net/ipv4/tcp_rmem
4096 87380 6291456 # min / default / max (수신 버퍼, 바이트)
$ cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304 # min / default / max (송신 버퍼, 바이트)
default 기준으로 소켓 하나:
- TCP 소켓 구조체: ~2KB
- 수신 버퍼 (default): ~87KB
- 송신 버퍼 (default): ~16KB
- 합계: ~100KB
버퍼를 최솟값으로 튜닝하면 ~10KB/연결까지 줄일 수 있다. SSE는 서버→클라이언트 단방향이라 수신 버퍼를 거의 안 쓰기 때문이다.
| 동시 연결 | 커넥션 메모리 (10KB 기준) |
|---|---|
| 10만 | 1GB |
| 100만 | 10GB |
| 1,000만 | 100GB |
| 1억 | 1,000GB (1TB) |
1억 연결이면 소켓 메모리만 1TB. 단일 서버는 당연히 불가능하다. 하지만 메모리만 문제가 아니다. JVM 레이어에도 문제가 있다.
스레드가 부족하면 어떤 증상이 나오나?
SseEmitter를 쓰면 servlet async로 worker thread는 즉시 반환된다. 스레드 200개가 10만 SSE 연결을 점유하지는 않는다. 하지만 JVM 힙에서 문제가 생긴다.
SseEmitter 객체 하나가 힙에 올라간다. 연결이 10만 개를 넘어가면:
[10만 SseEmitter 힙에 상주]
→ GC가 이것들을 살아있는 객체로 인식 → 수집 안 됨
→ Old Generation이 가득 차기 시작
→ Full GC 발동
→ Full GC 동안 Stop-the-World: JVM 전체가 멈춤
→ 이 시간 동안 10만 유저에게 이벤트 전달 불가
구체적인 수치:
연결 10만 개 × SseEmitter 관련 객체 ~5KB = 500MB 힙 점유
G1GC Full GC 시간: 수백ms ~ 수 초 (힙 크기와 객체 수에 비례)
Full GC 빈도: 좀비 연결이 쌓이면 분당 수 회 가능
실제 증상:
- 연결 수가 늘어날수록 GC 로그에 Full GC가 잦아진다
- Full GC 동안 모든 SSE 이벤트가 멈춘다 (Stop-the-World)
- 클라이언트는 이벤트 지연이나 연결 끊김으로 느낀다
- OOM이 발생하면 서버가 다운된다
그리고 파일 디스크립터(fd) 한도도 넘어간다:
$ ulimit -n
1024 # 프로세스당 기본 fd 한도 — 1024 연결이면 바닥남
# 운영 서버에서 반드시 늘려야 함
# /etc/security/limits.conf
* soft nofile 1000000
* hard nofile 1000000
# /etc/sysctl.conf
fs.file-max = 2000000
경험상 Spring MVC + SseEmitter는 서버당 5만~10만 동시 연결이 현실적인 한계다. 이걸 넘으려면 런타임 모델 자체를 바꿔야 한다.
이벤트 루프 모델은 왜 이걸 해결하나?
Tomcat과 Netty의 차이를 봐야 한다.
Tomcat (NIO + servlet async):
Worker Thread Pool (200개):
- subscribe() 실행 → 즉시 반환 (async)
- send() 실행 시 일시적으로 사용
NIO Poller Thread (CPU core 수):
- 소켓 이벤트 감시 (epoll)
- 데이터 write 수행
힙에 상주:
- SseEmitter × 연결 수
- 각 emitter의 관련 데이터 구조
Netty (순수 이벤트 루프):
Boss Thread (1~2개):
- 새 연결 accept
Worker Thread (CPU core 수 × 2):
- epoll로 모든 소켓의 I/O 이벤트 처리
- 스레드 수는 CPU에 고정, 연결 수와 무관
힙에 상주:
- FluxSink × 연결 수 (SseEmitter보다 가벼움)
차이가 보인다. Netty는 연결 수가 늘어도 스레드 수가 고정이다. Worker thread 64개가 100만 연결의 I/O를 처리한다. 스레드가 늘지 않으니 컨텍스트 스위칭 오버헤드도 없다.
실제 수치 비교 (32코어 서버, 256GB RAM):
| 항목 | Spring MVC + SseEmitter | Spring WebFlux + Flux |
|---|---|---|
| 서버당 안전 연결 수 | 5만~10만 | 50만~100만 |
| Worker 스레드 수 | 200 (기본) | 64 (CPU×2) |
| 연결당 힙 사용 | ~5KB | ~1KB |
| GC 부담 | 높음 (Full GC 위험) | 낮음 |
1억건 기준:
- MVC: 최소 1,000대 서버 필요
- WebFlux: 최소 100대 서버 필요
10배 차이다. 하지만 서버를 여러 대로 늘리면 새 문제가 생긴다.
서버를 여러 대 쓰면 어떤 새 문제가 생기나?
유저 A가 서버 1에 연결됐다. 이벤트는 서버 3의 Kafka consumer가 받았다.
[유저 A] ──── SSE 연결 ────> [서버 1]
[비즈니스 서비스] → Kafka → [서버 3의 Kafka consumer가 이벤트 수신]
서버 3: "유저 A한테 보내야 하는데... 서버 1에 있는데?"
이게 팬아웃(fan-out) 문제다. 해결책 없이 운영하면:
[서버 3] 로컬 레지스트리 확인: 유저 A 없음
[서버 3] 이벤트 무시
[유저 A] 이벤트 못 받음
Sticky session(유저를 특정 서버에 고정)은 어떤가? 그 서버가 재배포되면 연결이 끊기고, 부하를 균등하게 분배하기 어렵다.
서버가 서로 직접 통신하면? N대면 N×(N-1) 커넥션이 필요하다. 100대면 9,900개 커넥션이다.
Redis Pub/Sub으로 해결한다는데 어떻게?
Redis가 없으면 서버 3이 유저 A의 이벤트를 받아도 서버 1에 전달할 방법이 없다.
Redis Pub/Sub으로 해결하는 방식:
[서버 3이 이벤트 수신]
↓
[Redis PUBLISH "sse:user:A" "{이벤트}"]
↓
모든 서버가 "sse:user:A" 채널을 구독 중
↓
서버 1: 유저 A 로컬에 있음 → SSE 전송
서버 2: 유저 A 없음 → 무시
서버 3: 유저 A 없음 → 무시
@KafkaListener(topics = "sse-events", groupId = "sse-node-${NODE_ID}")
public void onKafkaEvent(String message) throws Exception {
SseEvent event = objectMapper.readValue(message, SseEvent.class);
String userId = event.getUserId();
if (registry.hasConnection(userId)) {
// 이 노드에 연결된 유저 → 직접 SSE 전송
registry.send(userId, event);
} else {
// 없으면 Redis로 라우팅
redisTemplate.convertAndSend("sse:user:" + userId, message);
}
}
// Redis에서 수신 (다른 노드가 publish한 이벤트)
public void onRedisMessage(String message, String channel) {
String userId = channel.replace("sse:user:", "");
if (!registry.hasConnection(userId)) return;
registry.send(userId, fromJson(message));
}
방법 1 (Pub/Sub 브로드캐스트) vs 방법 2 (노드 라우팅 테이블):
방법 1은 모든 서버가 모든 이벤트를 받아서 자기 유저 것만 처리한다. 단순하지만 서버 수가 많을수록 불필요한 메시지 처리가 늘어난다.
방법 2는 Redis Hash로 userId → 서버ID 매핑을 관리한다. 이벤트를 타겟 서버에만 보낸다. 효율적이지만 매핑 관리가 복잡하다.
// 연결 시 Redis에 등록
public void register(String userId, FluxSink<?> sink) {
connections.put(userId, sink);
redisTemplate.opsForHash().put("sse:node-map", userId, nodeId);
}
// 라우팅
public void route(SseEvent event) {
String targetNodeId = (String) redisTemplate.opsForHash()
.get("sse:node-map", event.getUserId());
if (nodeId.equals(targetNodeId)) {
registry.send(event.getUserId(), event);
} else {
redisTemplate.convertAndSend("sse:node:" + targetNodeId, toJson(event));
}
}
연결 수 100만 이하면 방법 1, 그 이상이면 방법 2.
Redis가 병목이 되면 어떻게 하나?
Redis Pub/Sub의 구조적 한계:
- Fire-and-forget: 구독자가 없거나 Redis가 잠깐 끊기면 메시지 유실. 서버 재시작 중 이벤트 누락.
- 처리량 한계: Redis 싱글 스레드 특성상 초당 수백만 메시지가 한계. 1억 유저에서 이벤트 빈도가 높아지면 부딪힌다.
- 단일 장애점: Redis 장애 = 이벤트 라우팅 전체 중단.
대안 비교:
| 방식 | 처리량 | 메시지 보장 | 장애 격리 | 복잡도 |
|---|---|---|---|---|
| Redis Pub/Sub | 높음 | 없음 | 낮음 | 낮음 |
| Redis Streams | 높음 | 있음 | 낮음 | 중간 |
| Kafka (노드 간) | 매우 높음 | 있음 | 높음 | 높음 |
| gRPC 직접 | 매우 높음 | 없음 | 높음 | 높음 |
규모가 커지면 Kafka + Redis 조합이 표준이다. Kafka가 신뢰성을, Redis가 속도를 담당한다:
비즈니스 서비스
↓
Kafka (sse-events 토픽) ← 내구성, 재처리, 처리량 보장
↓
각 SSE 노드의 Kafka Consumer
↓
로컬에 유저 있음? → 직접 SSE 전송
없음? → Redis Pub/Sub으로 노드 간 라우팅
↓
타겟 노드 → SSE 전송
Redis 자체가 병목이 되면? Cluster로 샤딩한다. userId를 키로 Redis 노드를 분산하면 선형으로 처리량이 늘어난다. Spring Data Redis Cluster는 키 슬롯을 자동으로 배분한다.
그러면 1억 연결을 처리하려면 실제로 서버가 몇 대 필요한가?
실제로 1억건이면 서버 몇 대가 필요한가?
가정:
- Spring WebFlux + Netty 기반 SSE 게이트웨이
- 서버 스펙: AWS r7g.16xlarge (64 vCPU, 512 GiB RAM) — r7g 계열의 최대 크기다(그 위는 metal)
- 소켓 버퍼 최솟값 튜닝 적용
- 커넥션당 메모리: ~12KB (소켓 10KB + FluxSink + 메타데이터)
서버당 메모리 배분:
전체 RAM: 512GB
JVM 힙 (40%): 205GB
OS + 기타: 50GB
GC 여유 (50%): 102GB ← 힙의 절반은 GC 작업 공간
실제 가용: 102GB
서버당 최대 커넥션:
102GB / 12KB ≈ 850만 연결
현실적 안전 한계 (70% 사용):
850만 × 0.7 ≈ 600만 연결/서버
1억 연결 처리:
필요 서버: 100,000,000 / 6,000,000 ≈ 17대
여유분 고려: 20~25대
이론적으로 20~25대 서버로 1억 동시 SSE 연결. 하지만 이것만 보면 안 된다:
| 항목 | 추가 고려사항 |
|---|---|
| GC | ZGC 필수. G1GC는 이 규모에서 수 초 pause 가능 |
| 이벤트 처리량 | 초당 1,000만 이벤트면 Kafka도 그에 맞아야 함 |
| 네트워크 | 서버당 ~600만 소켓. 높은 대역폭의 NIC와 여러 ENA 큐가 필요 |
| 비용 | r7g.16xlarge 온디맨드는 대략 시간당 ~$3.4 (리전·시점에 따라 다름) × 25대 × 730h ≈ 월 약 $62,000 |
규모별 현실적인 서버 수 (WebFlux 기준):
| 동시 연결 | 서버 수 | 참고 |
|---|---|---|
| 10만 | 1대 | MVC + SseEmitter로 충분 |
| 100만 | 2~3대 | WebFlux 적용 시점 |
| 1,000만 | 10~20대 | 게이트웨이 분리 필수 |
| 1억 | 50~100대 | KakaoTalk, LINE 수준 |
위에서 메모리·연결 수만으로 계산한 2025대와 이 표의 50100대가 다른 이유를 짚어둔다. 2025대는 소켓 메모리에 묶인 이론적 하한이다. 실제 운영 대수는 연결 수만이 아니라 이벤트 처리량(서버당 초당 write 수), NIC 대역폭, 장애 대비 여유(N+2), 리전·AZ 분산 같은 제약에서 결정된다. 메모리상으로는 한 대에 600만 연결을 담을 수 있어도, 그만큼의 이벤트를 동시에 밀어내려면 CPU·네트워크가 먼저 한계에 닿는다. 그래서 현실 운영 대수는 이론 하한보다 24배 늘어 50~100대 수준이 된다.
1억 동시 접속은 글로벌 메신저 수준이다. 대부분의 서비스는 100만도 안 된다.
좀비 연결은 왜 생기고 어떻게 정리하나?
좀비 연결이 생기는 상황들:
1. 모바일 배터리 방전 → TCP FIN 패킷 없음, 서버는 모름
2. WiFi → LTE 전환 → 기존 소켓 무효화, 서버는 모름
3. NAT 타임아웃 (30분~2시간) → 중간 라우터가 세션 삭제, 서버는 모름
4. L4 LB가 연결 끊음 → 백엔드는 모름
이게 없으면 어떤 일이 생기나:
좀비 연결 1만개 × SseEmitter 5KB = 50MB 힙 점유
GC가 살아있는 객체로 인식 → 수집 안 됨
시간이 지날수록 누적 → OOM → 서버 다운
이벤트를 보내려 할 때 IOException이 나야 비로소 죽은 연결인 걸 안다. heartbeat가 “좀비 감지기” 역할을 하는 이유다.
OS 레벨: TCP Keepalive 튜닝
# /etc/sysctl.conf
net.ipv4.tcp_keepalive_time = 60 # 기본 7200초 → 60초 (idle 60초 후 probe 시작)
net.ipv4.tcp_keepalive_intvl = 10 # 10초 간격으로 keepalive probe
net.ipv4.tcp_keepalive_probes = 3 # 3번 무응답 → OS가 연결 종료
# 결과: 최대 90초(60 + 10×3) 후 OS가 죽은 연결 정리
애플리케이션 레벨: Heartbeat + 강제 정리
@Scheduled(fixedDelay = 30_000)
public void sendHeartbeat() {
int removed = 0;
for (Map.Entry<String, SseEmitter> entry : registry.getAll().entrySet()) {
try {
entry.getValue().send(SseEmitter.event().comment("heartbeat"));
} catch (IOException e) {
entry.getValue().completeWithError(e);
registry.remove(entry.getKey());
removed++;
}
}
if (removed > 0) {
log.info("좀비 연결 {}개 정리, 현재 활성 {}개", removed, registry.size());
}
}
// 1시간 이상 된 연결은 강제 재연결 (좀비 누적 예방)
@Scheduled(cron = "0 0 * * * *")
public void pruneStaleConnections() {
Instant threshold = Instant.now().minus(Duration.ofHours(1));
registry.getAll().forEach((userId, conn) -> {
if (conn.getConnectedAt().isBefore(threshold)) {
conn.complete(); // 클라이언트가 자동 재연결
registry.remove(userId);
}
});
}
1억 연결에서 heartbeat 비용 계산:
1억 연결 × (1/30초) = 초당 333만 SSE comment 전송. Kafka나 HTTP 요청이 아니라 단순 소켓 write다. Netty 이벤트 루프로 처리하면 충분하다.
Netflix, Slack은 왜 SSE를 안 쓰나?
아래는 공식 발표라기보다 일반적으로 알려진 사례와 합리적 추정에 가깝다. 각 회사의 실제 구성은 시기·기능에 따라 다르고 혼용되기도 한다. 기술 선택의 논리를 이해하는 예시로 읽는 게 맞다.
Netflix가 SSE를 주로 쓰지 않는다고 알려진 이유(추정):
-
네이티브 앱 중심: Netflix 사용자 대부분이 iOS, Android, Smart TV 앱으로 접속한다. 브라우저
EventSource가 필요 없다. 네이티브에서는 Long Polling이 구현이 더 단순하다. -
이벤트 빈도 낮음: 새 콘텐츠 추천, 다운로드 완료 — 몇 분에 한 번 수준이다. 이 빈도라면 영구 연결 유지 비용(메모리 × 서버 수)이 Long Polling보다 이득이 없다.
-
CDN과 충돌: Netflix는 CDN 엣지에서 콘텐츠를 캐싱한다. Long Polling 응답은 조건부 캐싱 여지가 있다. SSE는 연결 특성상 캐싱이 안 된다.
Slack이 WebSocket을 쓴다고 알려진 이유(추정):
-
양방향이 필수: 타이핑 중… 표시, 읽음 확인, 리액션 실시간 업데이트 — 클라이언트→서버 방향 데이터도 많다. SSE는 단방향이라 이 기능들을 HTTP POST로 별도 구현해야 한다.
-
레이턴시: 채팅에서 100ms도 차이가 느껴진다. WebSocket은 HTTP 헤더 오버헤드가 없다.
-
메시지 순서: WebSocket은 단일 TCP 스트림이라 순서가 자동 보장된다. SSE에서 재연결이 발생하면 seq 번호를 붙여서 클라이언트가 순서를 맞춰야 한다.
트레이드오프 정리:
| 상황 | 적합한 기술 |
|---|---|
| 단방향, 낮은 빈도, 네이티브 앱 | Long Polling |
| 단방향, 높은 빈도, 웹 앱 | SSE |
| 양방향, 낮은 레이턴시, 채팅/게임 | WebSocket |
| 양방향, 고처리량, 바이너리 데이터 | WebSocket (Binary) |
SSE가 나쁜 기술이 아니다. 맞는 케이스가 있다.
결국 언제 SSE를 선택해야 하나?
SSE를 선택할 때:
- 서버→클라이언트 단방향 알림이다
- 웹 브라우저가 주 클라이언트다
- 기존 Spring Security, CORS, 인증 필터를 그대로 쓰고 싶다
- WebSocket 운영 경험이 없다
- 동시 연결 10만 이하 (MVC + SseEmitter로 시작해서 충분하다)
Long Polling을 선택할 때:
- 이벤트 빈도가 낮다 (몇 분에 한 번)
- 모바일 네이티브 앱이 주 클라이언트다
- 구현 복잡도를 최소화하고 싶다
WebSocket을 선택할 때:
- 양방향 통신이 필요하다
- 채팅, 게임, 실시간 협업이다
- 레이턴시가 중요하다
단계별 진화 경로:
1단계: Spring MVC + SseEmitter
→ 동시 연결 5만 이하면 여기서 끝
→ 구현 단순, 기존 스택 그대로
→ 단일 서버, Redis 불필요
2단계: Spring WebFlux + Flux<ServerSentEvent>
→ 동시 연결 100만 이하
→ 리액티브 패러다임으로 전환 필요
→ 메모리 효율 10배 개선
3단계: WebFlux Gateway + Kafka + Redis 분리
→ 동시 연결 1,000만 이하
→ SSE 게이트웨이를 별도 서비스로 분리
→ 비즈니스 로직과 완전히 독립
4단계: JVM 한계 도달 시
→ Erlang/Elixir (Phoenix), Go, Rust 검토
→ 또는 Managed WebSocket 서비스 (Pusher, Ably)
B2B SaaS, 커머스 대부분은 1단계나 2단계에서 해결된다. 3단계부터는 코드보다 아키텍처 설계와 인프라 비용이 핵심이다.
최적화
TCP 버퍼 최솟값 설정 — SSE 게이트웨이 전용 서버에서
# /etc/sysctl.conf
net.ipv4.tcp_rmem = 4096 4096 4194304 # 수신 버퍼 default를 87KB → 4KB
net.ipv4.tcp_wmem = 4096 4096 4194304
이 설정을 적용하면 연결당 메모리가 100KB → 10KB로 줄어든다. 서버당 처리 가능한 연결 수가 10배 늘어난다.
주의: 파일 다운로드 같이 throughput이 중요한 서비스와 같은 서버를 쓰면 충돌한다. SSE 게이트웨이는 반드시 전용 서버로 분리해야 이 튜닝이 의미 있다.
ZGC 적용 (연결 수 100만 이상 시)
JAVA_OPTS="-XX:+UseZGC \
-XX:ZCollectionInterval=5 \
-Xmx200g \
-XX:+UnlockDiagnosticVMOptions \
-XX:+ZProactive"
G1GC는 힙이 수백 GB가 되면 Full GC 시간이 수 초에 달한다. ZGC는 대부분의 GC 작업을 concurrent하게 수행해 pause를 10ms 이하로 유지한다.
Redis 연결 풀 설정
노드 수가 많아질수록 Redis 연결 수 관리가 중요하다.
spring:
data:
redis:
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
튜닝
측정해야 할 것들
1. 서버당 활성 연결 수 → 용량 계획
2. 이벤트 end-to-end 지연 → Kafka offset lag + SSE 전송 시간
3. 좀비 연결 비율 → heartbeat 실패 수 / 전체 연결 수
4. Redis Pub/Sub 메시지 처리 지연
5. GC pause 시간 → ZGC 사용 시 10ms 이하가 목표
6. Kafka consumer lag → 이 수치가 올라가면 이벤트 지연 커짐
@PostConstruct
public void registerMetrics() {
Gauge.builder("sse.connections.active", registry, SseRegistry::size)
.register(meterRegistry);
Gauge.builder("sse.connections.zombie.ratio",
() -> (double) zombieCount.get() / Math.max(registry.size(), 1))
.register(meterRegistry);
}
Kafka Consumer Group 설계
소규모 (노드 수 50 미만): 노드별 독립 consumer group. 모든 노드가 모든 이벤트를 받고, 자기 유저 것만 처리.
@KafkaListener(
topics = "sse-events",
groupId = "sse-gateway-${NODE_ID}", // 노드마다 다른 group ID
concurrency = "4"
)
이 방식은 토픽 메시지가 모든 노드로 복제되므로 Kafka 처리량이 노드 수에 비례해 늘어난다. 노드가 많아지면 방법 2 (노드 라우팅 테이블)로 전환해야 한다.
대규모 (노드 수 50 이상): 단일 consumer group + Redis 노드 라우팅. 메시지를 한 번만 소비하고 Redis로 타겟 노드에 전달.
Kafka partition 수
Kafka partition 수 >= SSE 노드 수 × consumer concurrency
SSE 노드 10대, 각 노드에 consumer concurrency 4면 파티션을 최소 40개 이상으로.
운영상 주의사항
1. 재배포 시 재연결 폭풍 (Reconnect Storm)
서버를 재배포하면 모든 SSE 연결이 동시에 끊긴다. 브라우저 기본 재연결 간격 3초 → 모든 유저가 3초 후 동시에 재연결 요청.
연결 10만 명이면 3초 후 10만 요청이 한꺼번에 들어온다. DB 조회, 인증 처리까지 더하면 서버가 버틸 수 없다.
대응:
// 서버 종료 전 클라이언트에게 jitter 적용된 재연결 간격 전달
@PreDestroy
public void gracefulShutdown() {
registry.getAll().forEach((userId, emitter) -> {
try {
emitter.send(SseEmitter.event()
.comment("server-shutdown")
.reconnectTime(5000 + (long)(Math.random() * 25000)));
// 5~30초 사이 랜덤 → 30초에 걸쳐 재연결 분산
} catch (IOException ignored) {}
});
try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
}
인프라 레벨:
- Rolling 배포: 전체 서버 동시 재시작 금지, 순차 재배포
- L4 LB connection draining: 새 연결 차단 → 기존 연결 점진적 종료
2. 이벤트 유실 감지 — Redis fire-and-forget의 함정
Redis Pub/Sub은 구독자가 없거나 연결이 잠깐 끊기면 메시지가 유실된다. 서버 재시작, Redis 재시작, 네트워크 순단 — 이때 발행된 이벤트는 사라진다.
감지 방법:
{
"seq": 12345,
"userId": "user-A",
"type": "notification",
"data": { }
}
클라이언트가 seq 12343 다음에 12346이 오면 12344, 12345를 별도 API로 요청한다. 이 복잡도가 허용 안 되면 Redis Streams (메시지 저장 + consumer group 지원)으로 전환을 고려한다.
3. 연결 수 급증 알람 — 클라이언트 버그 조기 감지
연결 수가 갑자기 급증하면 클라이언트 버그(재연결 루프)일 가능성이 높다. 지수 backoff 없이 무한 재시도하는 클라이언트가 있으면 서버 부하가 폭증한다.
@Scheduled(fixedDelay = 60_000)
public void checkConnectionHealth() {
int count = registry.size();
if (count > maxCapacity * 0.8) {
alertService.warn(String.format(
"SSE 연결 수 위험: %d / %d (%.0f%%) — 클라이언트 재연결 루프 여부 확인",
count, maxCapacity, (double) count / maxCapacity * 100));
}
}
클라이언트 재연결에는 지수 backoff + 최대 간격 제한이 필수다:
let retryDelay = 1000;
const maxDelay = 30000;
function connect() {
const es = new EventSource('/events');
es.onerror = () => {
es.close();
setTimeout(() => {
retryDelay = Math.min(retryDelay * 2, maxDelay);
connect();
}, retryDelay + Math.random() * 1000); // jitter 추가
};
es.onopen = () => { retryDelay = 1000; }; // 성공하면 초기화
}
4. SSE 게이트웨이 전용 서버 분리
SSE 게이트웨이(연결 유지 전담)와 비즈니스 로직 서버를 분리하는 것이 운영 안정성에 결정적이다.
이유:
- TCP 버퍼 튜닝이 다른 서비스에 영향을 주지 않음
- SSE 게이트웨이만 독립적으로 스케일아웃 가능
- 비즈니스 서버 재배포 시 SSE 연결이 끊기지 않음
- 장애 격리: 게이트웨이 문제가 비즈니스 로직에 영향 없음
[클라이언트]
↓ SSE 연결 (장기 지속)
[SSE 게이트웨이 — 연결 유지, Kafka consumer, Redis 라우팅]
↓ Kafka (이벤트 발행)
[비즈니스 서비스 — 짧은 HTTP 요청/응답]
1억 연결은 극단적인 숫자지만, 이 스케일을 기준으로 생각하면 10만, 100만 단계에서 어떤 결정을 해야 하는지가 선명해진다. MVC에서 WebFlux로, 단일 서버에서 분산으로 — 각 단계의 병목을 이해하면 불필요하게 일찍 복잡한 아키텍처를 도입하는 실수를 피할 수 있다.