Socket.IO 10,000명 재연결 부하 테스트
- Socket
- thundering-herd
- load-testing

프로젝트 소개
어떤 프로젝트를 진행하고 있었는가?
Plum은 비대면 강의의 단방향 소통 문제를 해결하기 위해 만든 실시간 화상 강의 서비스다. WebRTC 기반의 mediasoup를 사용해 발표자와 참여자가 실시간으로 영상과 음성을 주고받을 수 있도록 구현했다.
테스트 배경
화상 강의 서비스의 특성에 맞춰 소켓 재연결 옵션을 최적화하는 작업에서 시작했다. 팀 리뷰 중 "재연결 간격을 더 느슨하게 잡자"는 의견을 받았다. 재연결 속도를 빠르게 하면 사용자 경험은 좋아지지만, 그만큼 서버에 동시 재연결 요청이 몰릴 수 있다.

이론적으로는 두 주장 모두 타당하다. 어느 쪽이 맞는지 판단하려면 실제 서버가 어느 정도 부하까지 버티는지 데이터로 확인해야 했다. PR에서 제안한 설정값이 유효한지 검증하고, 이를 바탕으로 최종 방향을 결정하는 것이 이 테스트의 목적이다.
| 옵션 | 설명 | 기존 설정 | PR 제안값 |
|---|---|---|---|
| reconnectionDelay | 최초 재연결 시도 지연 시간 | 1,000ms | 300ms |
| reconnectionDelayMax | 재연결 시도 간격의 최대치 | 5,000ms | 3,000ms |
| randomizationFactor | 재연결 간격의 무작위 분산 비율 | 0.5 | 0.3 |
테스트 설계
테스트 도구 선택
Artillery - 부하 생성
Node.js 기반이라 실제 프로덕션과 동일한 socket.io-client를 그대로 사용할 수 있다. reconnect_attempt 이벤트, ACK 콜백, 커스텀 히스토그램 메트릭까지 단일 시나리오 안에서 처리 가능하다. k6, JMeter, Locust는 이 조합이 불가능하다.
Toxiproxy - 네트워크 장애 시뮬레이션
TCP 프록시로, HTTP API를 통해 스크립트에서 reset_peer toxic을 주입하면, 모든 연결에 RST를 즉시 발송해 disconnect 이벤트를 정밀하게 트리거할 수 있다.
iptables DROP은 클라이언트가 타임아웃까지 대기하는 문제가 있어 제외했다.
Linux VM (UTM) - 서버 환경
somaxconn은 listen()을 호출한 소켓이 동시에 대기시킬 수 있는 pending 연결 큐의 최대 길이를 정하는 커널 파라미터다. macOS는 kern.ipc.somaxconn=128이라 WebSocket 연결이 OS 레벨에서 조기 거부되어 10,000 VU 테스트가 불가능했다.
Linux VM에서 net.core.somaxconn=4096으로 튜닝해 실제 클라우드 환경과 동일한 조건을 확보했다. Artillery와 NestJS를 분리 실행해 CPU 경쟁으로 인한 측정 오염도 방지하고자 했다.
핵심 개념
Thundering Herd
Thundering Herd란 서버 장애 후 모든 클라이언트가 동시에 재연결을 시도해 부하가 한순간에 집중되는 현상이다. 단순히 접속자가 많다는 문제가 아니라, 요청이 특정 시간대에 몰린다는 점이 핵심이다.

Jitter
이를 완화하는 것이 Jitter다. Socket.IO의 randomizationFactor는 재연결 타이밍에 무작위성을 부여해 요청을 시간 축으로 분산시킨다.

| randomizationFactor | 분산 윈도우 | 1,000명 피크 속도 |
|---|---|---|
| 0 (없음) | 0ms | ∞ (순간 스파이크) |
| 0.3 (Phase 1) | 180ms | 5,556 req/s |
| 0.5 (Phase 2) | 300ms | 3,333 req/s |
지수 백오프
재연결 실패가 반복될 때는 지수 백오프가 작동한다. 매 실패마다 대기 시간을 지수적으로 늘려 서버가 회복할 시간을 확보하고, reconnectionDelayMax에 도달하면 그 값으로 유지된다.
| 시도 | 계산 | 대기 시간 |
|---|---|---|
| 1차 | 300ms × 2⁰ | 300ms |
| 2차 | 300ms × 2¹ | 600ms |
| 3차 | 300ms × 2² | 1,200ms |
| 4차 | 300ms × 2³ | 2,400ms |
| 5차+ | (상한 도달) | 5,000ms |
각 시도에는 ± jitter가 적용되어 실제 대기 시간은 위 값 기준으로 분산된다
2단계 테스트 구성
Phase를 나눈 이유는 변수를 하나씩 분리하기 위해서다.
reconnection: false 후 수동으로 1회만 재연결 시도. 자동 재시도 없이 첫 시도 실패를 최종 실패로 간주해 최악의 임계 지점을 확인한다.Phase 1 - 현재 설정의 한계
테스트 환경
가장 극단적인 부하를 포착하기 위해 자동 재연결을 비활성화하고 1회만 수동으로 재연결하는 환경에서 테스트했다.
| 옵션 | 설명 | 값 |
|---|---|---|
| reconnectionDelay | 최초 재연결 시도 지연 | 300ms |
| reconnectionDelayMax | 재연결 간격 최대치 | 3,000ms |
| randomizationFactor | 분산 윈도우 180ms | 0.3 |
| SOCKET_TIMEOUT | Redis 세션 유지 시간 (TTL) | 5s |
재연결 성공률
| 목표 VU | 실제 연결 | 성공 | 실패 | 성공률 |
|---|---|---|---|---|
| 100 | 100 | 100 | 0 | 100% |
| 500 | 500 | 500 | 0 | 100% |
| 1,000 | 1,000 | 1,000 | 0 | 100% |
| 5,000 | 4,892 | 4,892 | 0 | 100% |
| 10,000 | 6,999 | 4,516 | 2,483 | 64.5% |
5,000 VU부터 초기 연결 단계에서 TCP Backlog 포화로 일부가 탈락한다. 재연결 성공률의 분모는 초기 연결에 성공한 실제 연결 수 기준이다.
재연결 소요 시간
| 목표 VU | p50 | p95 | p99 | max |
|---|---|---|---|---|
| 100 | 0.3s | 0.4s | 0.4s | 0.4s |
| 500 | 0.3s | 0.4s | 0.4s | 0.4s |
| 1,000 | 0.3s | 0.4s | 0.4s | 0.4s |
| 5,000 | 0.3s | 0.4s | 0.5s | 0.5s |
| 10,000 | 2.1s | 4.1s | 6.6s | 8.8s |

100~5,000 VU 구간에서는 VU 수와 무관하게 p50 기준 0.3s로 일정하다. 10,000 VU에서 p50이 2.1s로 치솟은 건 TCP Backlog 경쟁으로 인한 재전송 큐잉 때문이다.
10,000 VU 재연결 실패 원인

Layer 1 - 초기 연결: TCP Backlog 포화
10,000개의 연결 요청이 한꺼번에 몰리며 TCP Backlog가 포화됐다. net.core.somaxconn=4096으로 튜닝했음에도 동시 재연결의 속도가 처리 속도를 초과해 3,001건이 탈락, 6,999명만 초기 연결에 성공했다.
Layer 2 - 초기 연결: 이벤트 루프 포화
단일 강의실 구조로 인한 fan-out이 원인이다. 6,999건의 join_room이 동시에 처리되며 일부 VU가 응답을 받지 못했다. 단, 이는 모든 VU를 단일 방에 집어넣은 테스트 구조에 의한 과장으로, 실제 서비스에서는 나타나지 않는다.
Layer 3 - 재연결: TCP Backlog 재포화 (핵심)
초기 연결에 성공한 6,999명이 RST 이후 jitter=0.3(윈도우 180ms) 안에서 동시에 재연결을 시도했다. 초당 38,883 req/s의 연결이 집중되며 TCP Backlog가 다시 포화, 2,483건이 추가로 거부됐다.
Radis 세션 만료 시간
여기에 Redis 세션 TTL(5s)이 너무 짧아 복구 기회를 놓친다. 1차 시도에서 거부된 VU가 지수 백오프로 재시도할 무렵엔 세션이 이미 만료된 상태였다. 누적 대기 시간을 계산해보면 4차 시도 시점에 이미 TTL을 초과할 수 있다. TCP 연결에 성공해도 join_room이 실패하는 2중 문제가 발생했다.
| 시도 | 계산 | 대기 시간 | 누적 최대 |
|---|---|---|---|
| 1차 | 300ms × 2⁰ | 300ms | 390ms |
| 2차 | 300ms × 2¹ | 600ms | 1,170ms |
| 3차 | 300ms × 2² | 1,200ms | 2,730ms |
| 4차 | 300ms × 2³ | 2,400ms | 5,850ms ← TTL 초과 |
jitter=0.3 기준 누적 최대. 4차 시도 전후로 Redis 세션(TTL 5s)이 만료되어, TCP 연결에 성공해도 join_room이 실패한다.
결론
현재 설정은 1,000 VU 이하에서는 완벽하게 동작한다. 그러나 10,000 VU 규모의 Thundering Herd 상황에서는 두 가지 문제가 맞물린다.
따라서 Phase 2에서는 이 두 가지를 최소한의 설정 변경으로 해결하고 동일 조건에서 재측정한다.
Phase 2 - 소켓 옵션 최적화 및 검증
설정 변경 및 근거
Phase 1에서 식별한 두 가지 원인을 최소한의 변경으로 해결했다. 여러 값을 동시에 바꾸면 어떤 변경이 효과를 냈는지 알 수 없기 때문에, 원인에 직접 대응하는 옵션만 조정했다.
| 항목 | 변경 전 | 변경 후 | 근거 |
|---|---|---|---|
| randomizationFactor | 0.3 | 0.5 | 분산 윈도우 180ms → 300ms, 피크 38,883 → 23,330 req/s |
| SOCKET_TIMEOUT | 5s | 30s | 지수 백오프 5차 누적 최대(~14s)의 2배 이상 확보 |
| reconnectionDelayMax | 3,000ms | 5,000ms | 2차 이상 재시도 시 요청 밀집도 완화 |
| reconnectionDelay | 300ms | 300ms | UX 유지. 부하 분산은 jitter가 담당 |
reconnectionDelay=300ms를 유지한 이유
1,000 VU 이하의 재연결은 일반적인 네트워크 불안정으로 판단했다. 이 경우 빠른 재연결로 UX를 보장하는 것이 우선이다.
반면 대규모 동시 재연결은 jitter=0.5가 시간 축으로 분산시키므로, 두 상황을 하나의 설정으로 모두 커버할 수 있다. 일반 복구는 빠르게, 전체 재연결은 jitter로 분산하는 것이 핵심 의도였다.
SOCKET_TIMEOUT=30s로 상향한 이유
세션 TTL은 클라이언트의 지수 백오프 사이클뿐 아니라 서버 복구 시간도 커버해야 한다. PM2 재시작이나 도커 컨테이너 재배포 시 발생하는 다운타임은 수 초에서 수십 초에 이를 수 있다.
30s는 지수 백오프 5차 누적 최대(~14s)의 2배 이상이면서, 일반적인 서버 재시작 시나리오까지 흡수할 수 있는 값이었다.
| 시도 | 대기 시간 | 누적 최대 |
|---|---|---|
| 1차 | 300ms | 450ms |
| 2차 | 600ms | 1,350ms |
| 3차 | 1,200ms | 3,150ms |
| 4차 | 2,400ms | 6,750ms |
| 5차 | 4,800ms | 13,950ms ← TTL 30s 이내 |
테스트 결과
1,000 VU
성공률 100%를 유지했으며, p99가 0.1s 소폭 증가했다. 분산 윈도우가 넓어진 데 따른 자연스러운 변화로, 사용자가 체감하기 어려운 수준이다.
| 항목 | Phase 1 | Phase 2 | 차이 |
|---|---|---|---|
| 재연결 성공률 | 100% | 100% | - |
| p50 | 0.30s | 0.30s | - |
| p99 | 0.40s | 0.50s | +0.10s |
| max | 0.40s | 0.50s | +0.10s |
10,000 VU

jitter=0.5로 분산 윈도우를 확대하자 피크가 38,883 → 23,330 req/s로 줄어들며 TCP Backlog 포화 자체가 발생하지 않았다. 6,999건 전원이 1차 시도의 jitter 대기 시간(150~450ms) 안에서 처리되어 지연 시간 분포가 1,000 VU 수준으로 수렴했다.
최종 결론
테스트 결과 요약
세 가지 설정 변경만으로 10,000 VU에서 재연결 성공률 64.5% → 100%를 달성했다. jitter=0.5로 분산 윈도우를 확대한 것이 TCP Backlog 포화 자체를 방지했고, SOCKET_TIMEOUT=30s가 TTL 만료 리스크를 제거했다. 6,999건 전원이 1차 백오프 사이클(150~450ms) 안에서 완료됐다. 지수 백오프가 2차 재시도에 기댈 필요가 없었다.
인사이트
부하 테스트를 처음 진행해보니 예상보다 훨씬 복합적이었다. 실제 운영 환경을 가정하고, 소켓 옵션 하나하나의 의미를 실측 데이터로 확인해본 과정은 의미 있는 경험이었다.
단일 설정 하나가 시스템 전체 흐름을 바꿀 수 있다.
가장 임팩트가 컸던 변경은 jitter 확대였다. randomizationFactor를 0.3에서 0.5로 높이는 것은 코드 한 줄의 수정이었지만, 재연결 피크를 38,883 → 23,330 req/s로 낮춰 TCP Backlog 포화 자체를 차단했다. SOCKET_TIMEOUT 상향은 TTL 만료라는 2차 실패 경로를 제거했다. 두 변경 모두 단독으로는 불완전했고, 맞물려야 효과가 완성됐다.
구조적 한계와 설정으로 해결 가능한 한계를 구분해야 한다.
단일 인스턴스의 초기 수용 한계(~7,000연결)는 TCP Backlog의 구조적 한계로, 설정 조정만으로는 해결할 수 없다. 이 임계치는 방 구조와 무관하게 전체 동시 접속자 총량에 달려 있다. 서비스 규모가 이 수준에 근접하면 수평 확장이나 연결 분산 전략이 필요하다. 반면 일단 연결에 성공한 세션의 재연결은 현재 설정으로 10,000 VU에서도 100% 복구가 가능했다.
이론보다 재현 가능한 실험이 더 강한 판단 근거가 된다.
이번 테스트는 "옵션을 더 느슨하게 잡자"는 리뷰 한 줄에서 시작됐다. 이론적으로는 두 주장 모두 타당했고, 어느 쪽도 데이터 없이는 확신할 수 없었다. 실제로 테스트를 진행하며 예상하지 못한 두 가지를 발견했다. 하나는 재연결 단계에서 TCP Backlog가 다시 포화된다는 것, 다른 하나는 Redis TTL이 지수 백오프 사이클과 맞물려 복구 기회를 차단한다는 것이었다.
이론으로 추론하기 어려운 복합적인 문제였다. 막연한 추측으로 결정을 내렸다면 놓쳤을 지점들이었다. 다음에도 설정 하나를 바꿀 때, 근거를 데이터로 확인하는 습관을 이어가고 싶다.
