단일 서버로 1,000만 WebSocket 연결 처리하기: 우리가 흔히 오해하는 확장 전략
모바일 앱 알림처럼 "실시간"이 중요한 서비스는 보통 WebSocket을 사용합니다.
연결 수가 수십만, 수백만을 넘어가면 대부분의 팀이 자동으로 떠올리는 해법은 하나입니다. "쿠버네티스로 수평 확장하면 되지 않을까?"
하지만 한 엔지니어 팀은 이 직관과 정반대 방향으로 갔습니다. 수십 대의 서버를 줄이고, 단 한 대의 강력한 서버 위에서 1,000만 개의 WebSocket 연결을 처리하도록 아키텍처를 완전히 재설계했습니다.
이 글에서는 그 여정에서 드러난 핵심 아이디어와, 우리가 보통 확장에 대해 잘못 가정하는 부분을 정리해보겠습니다.
단일 서버로 1,000만 WebSocket을 처리하는 게 '마법'이 아니라, CPU 사이클이 어디에 쓰이는지 정확히 이해하는 일이라는 사실도 함께 보게 될 겁니다.
1,000만 WebSocket 연결, 진짜 어려운 건 "연결 수"가 아니다
이 팀이 만든 시스템은 모바일 앱의 실시간 알림 인프라였습니다.
모든 사용자는 앱을 실행하면 서버와 WebSocket을 하나 열어둔 채로 대기합니다. 푸시 알림, 이벤트, 메시지 등 업데이트가 오면 이 연결을 통해 곧바로 전달됩니다.
피크 타임에는 동시에 열린 WebSocket 연결 수가 1,000만 개에 이르렀습니다.
흥미로운 건, 이 연결들이 미친 듯한 트래픽을 쏟아내는 게 아니라는 점입니다. 대부분의 연결은 거의 놀고 있고, 평균적으로 한 연결당 초당 2~3개의 메시지만 주고받을 뿐입니다.
즉, 이 시스템의 진짜 문제는 "초당 요청 수"보다 어마어마한 수의 유지되는 연결 자체에 있었습니다.
수평 확장의 함정: 잘 동작하지만, 값비싸고 불안정했다
처음 아키텍처는 우리가 상상하는 전형적인 모습이었습니다.
Go로 작성된 WebSocket 서버 여러 대를 띄우고, 그 앞에 로드 밸런서를 두어 연결을 여러 노드에 분산합니다.
대략 50대의 서버로 연결을 나눠 처리했고, 겉보기에는 잘 동작했습니다.
문제는 그다음입니다.
매달 인프라 비용만 약 4만7천 달러가 들었고, 가용성은 약 99.5% 수준에 머물렀습니다. 표면상 나쁘지 않은 숫자처럼 보이지만, 실서비스 입장에선 꽤 고통스러웠습니다.
서버 한 대를 재시작할 때마다 수십만 개의 WebSocket 연결이 끊기고, 재연결 폭주(reconnection storm)가 발생했습니다.
배포 때마다 특정 노드의 연결을 빼고 새 버전을 넣는 과정에서, 슬랙 알림은 쏟아졌고 운영자는 긴장 속에서 로그를 지켜봐야 했습니다.
수평 확장은 '확실한 해법'처럼 보이지만, 연결형 실시간 서비스에는 생각보다 큰 숨은 비용과 리스크를 안겨줍니다.
"서버를 더 늘리기 전에" 먼저 물어야 할 질문
이 팀이 6개월 동안 쿠버네티스 기반의 분산 아키텍처를 공들여 만들고 난 뒤 깨달은 건 의외로 단순했습니다.
"우리는 지금 진짜로 CPU를 다 쓰고 있나? 아니면 그냥 서버를 많이 쓰고 있을 뿐인가?"
대부분의 시스템에서, 특히 WebSocket처럼 대부분의 연결이 idle에 가까운 경우, CPU는 실제 요청 처리보다 다음과 같은 데 허비되기 쉽습니다.
불필요한 컨텍스트 스위칭
비효율적인 스레드/프로세스 모델
네트워크 스택에서의 오버헤드
커널과 유저 공간 간의 반복적인 복사와 전환
이 글 제목에 등장하는 핵심 힌트는 여기입니다.
"우리는 연결당 스레드 모델을 버리고, 코어를 NUMA 노드에 핀으로 고정하고, 커널 네트워크 스택을 우회했다."
즉, 분산 시스템을 더 복잡하게 만들기보다, 단일 서버의 효율을 극단까지 끌어올리는 방향으로 사고를 전환한 것입니다.
연결당 스레드 모델을 버려야 하는 이유
많은 WebSocket 서버 구현은 "연결 하나 = 스레드 하나" 혹은 그것과 유사한 모델로 시작합니다.
처음에는 이 방식이 이해하기 쉽고 디버깅도 편한데, 동시 연결 수가 수십만을 넘는 순간부터 문제가 발생합니다.
스레드 수가 기하급수적으로 늘어나면서, 실제 비즈니스 로직보다 스케줄링과 컨텍스트 스위칭 비용이 CPU를 잡아먹게 됩니다.
이 팀은 이 직관적인 모델을 과감히 버리고, 이벤트 기반 비동기 I/O 모델로 전환했습니다.
리눅스라면 epoll, 최신 커널에서는 io_uring 같은 메커니즘을 활용해, 소수의 워커 스레드가 수백만 개의 소켓을 동시에 관리하는 구조로 재구성한 셈입니다.
핵심은 간단합니다.
"연결 수만큼 스레드를 만들지 않는다."
"스레드를 CPU 코어 수에 가깝게 유지한다."
이 두 가지만 제대로 지켜도, 연결형 트래픽에서 CPU 효율은 극적으로 개선됩니다.
NUMA 구조 이해하기: 코어가 모두 동일하지 않다
대형 서버(특히 2소켓 이상)는 대부분 NUMA(Non-Uniform Memory Access) 구조를 가집니다.
간단히 말하면, CPU 소켓마다 "자기 집 같은 메모리"가 따로 있어서, 가까운 메모리는 빠르게, 먼 메모리는 느리게 접근하는 구조입니다.
보통 우리는 이 차이를 무시하고 코드를 짜지만, 극단적인 연결 수와 트래픽을 다루는 서버에서는 이 차이가 곧 성능의 바닥을 결정합니다.
이 팀은 워커 스레드를 그냥 띄우는 대신,
특정 코어에 스레드를 고정(pinning) 하고
그 코어가 속한 NUMA 노드의 메모리를 주로 사용하도록 구성
하여, CPU가 "멀리 있는 메모리"를 괜히 왔다 갔다 하지 않도록 설계했습니다.
이렇게 하면 캐시 미스와 메모리 지연이 줄어들고, 고부하 상황에서 레이턴시 분포가 훨씬 안정적으로 유지됩니다.
동시 연결 수가 수백만~수천만 단위로 올라가면, 이런 하드웨어 레벨의 최적화가 추가 서버 수십 대를 대체하는 효과를 내기도 합니다.
커널 네트워크 스택 우회: 왜 이런 극단까지 가는가
일반적인 애플리케이션은 운영체제의 네트워크 스택을 그대로 사용합니다.
소켓 생성, send, recv 호출, 커널 버퍼, 인터럽트… 대부분의 상황에서 충분히 빠르고, 구현도 간편합니다.
하지만 극단적인 연결 수 + 낮은 레이턴시 + 높은 안정성이 동시에 필요할 때, 이 일반적인 경로가 성능 병목이 되기 쉽습니다.
이 팀은 결국 커널 네트워크 스택을 우회(bypass) 하는 방향으로 갔습니다.
사용자 공간에서 직접 NIC를 제어하는 라이브러리(e.g. DPDK, XDP 계열)를 활용하고
패킷 처리의 대부분을 유저 공간에서 수행하며
커널과 유저 공간 간의 컨텍스트 스위칭과 복사를 최소화
하는 방식으로, 네트워크 경로를 최대한 짧게 만든 것입니다.
물론 이 접근은 구현 난이도가 높고, 디버깅도 쉽지 않습니다.
하지만 이미 수십 대의 서버를 돌리며 매달 수만 달러를 쓰고, 여전히 배포 때마다 장애 위험에 떨고 있다면, 이런 투자는 갑자기 매우 합리적인 선택이 됩니다.
"수평 확장"이 항상 답은 아니다
이 이야기의 가장 중요한 포인트는 철학에 가깝습니다.
대부분의 엔지니어는 "트래픽이 많아진다 → 서버를 늘린다"라는 공식을 몸에 익히고 있습니다.
쿠버네티스, 오토스케일링, 마이크로서비스… 이런 도구들은 분명 강력합니다.
하지만 문제의 본질이 'CPU 부족'이 아니라 '비효율적인 사용'일 때, 단순한 수평 확장은 인프라 비용과 운영 복잡도만 늘리고 근본적인 고통은 그대로 두는 경우가 많습니다.
이 팀은 6개월 동안 아름다운 분산 시스템을 구축한 뒤에야,
"우리는 애초에 단일 서버 성능을 제대로 뽑아내는 방법을 먼저 고민했어야 했다"
는 사실을 깨닫습니다.
엔지니어링에서 가장 비싼 건 잘못된 전제를 기반으로 쌓아 올린 복잡성입니다.
실제 서비스 운영에서 마주치는 문제들: 재연결 폭주와 배포 공포
연결형 시스템에서 "노드 수가 많을수록 위험도 분산된다"고 생각하기 쉽지만, 현실은 꼭 그렇지 않습니다.
서버가 50대라면, 그만큼 장애와 재시작이 발생할 수 있는 지점도 50개라는 뜻입니다.
서버 한 대의 프로세스가 죽거나 재시작되면, 해당 노드에 붙어 있던 수십만 연결이 한꺼번에 끊기고,
클라이언트는 일제히 재연결을 시도합니다.
이를 reconnection storm라 부르는데, 이 폭주 상황은 다른 정상 노드까지 압도해 전체 장애로 번지기 쉽습니다.
게다가 롤링 배포 과정에서 연결을 "부드럽게" 옮기는 것도 WebSocket에서는 쉽지 않습니다.
HTTP 요청은 짧게 끝나지만, WebSocket은 오래 살고 있는 상태(state)입니다.
서버 대수가 많을수록, 이런 문제의 표면적 복잡도는 기하급수적으로 커집니다.
반대로, 단일 혹은 소수의 강력한 서버로 통합하면,
장애 지점을 줄이고
OS와 하드웨어 수준에서 튜닝할 수 있는 여지가 커지고
배포 전략을 단순하게 설계할 수 있습니다.
물론 "한 대가 죽으면 끝 아닌가?"라는 불안이 있지만, 고가용성 설계(액티브-스탠바이, 리플리카, L4 로드밸런서 레벨의 페일오버 등)를 잘 얹으면,
"많은 서버 = 안전"이라는 직관이 얼마나 단순화된 믿음인지 드러나게 됩니다.
이런 아키텍처가 어울리는 서비스와 그렇지 않은 서비스
단일 서버로 1,000만 WebSocket을 처리하는 접근이 모든 팀에 정답은 아닙니다.
어울리는 경우는 대략 이렇습니다.
연결 수는 많지만, 개별 연결당 트래픽이 매우 낮은 경우
트래픽 패턴이 비교적 예측 가능하고, 피크가 특정 시간대에 몰리는 경우
인프라 비용과 운영 복잡도를 크게 줄이고 싶은 경우
고성능 네트워크, NUMA 서버 등 하드웨어 튜닝 여지가 있는 경우
반대로, 다음과 같은 경우에는 전통적인 수평 확장이 더 적합할 수 있습니다.
각 연결에서 실제 데이터 전송량이 매우 많은 경우
지역별로 지연 시간이 매우 중요한 글로벌 서비스
조직 구조상 마이크로서비스와 분산 아키텍처가 이미 표준인 경우
중요한 건 도구 자체가 아니라, 내 서비스의 병목이 정확히 어디에 있는지 먼저 파악하는 것입니다.
시사점: 서버 수를 늘리기 전에, CPU와 가정을 의심하라
이 글이 던지는 메시지는 의외로 단순합니다.
"단일 서버로 1,000만 WebSocket 연결을 처리할 수 있다"는 사실 자체보다, 우리가 너무 쉽게 "수평 확장"으로 도망가고 있다는 현실입니다.
실시간 시스템, 특히 WebSocket 기반 서비스를 설계하고 있다면 다음 질문을 먼저 던져보면 좋습니다.
우리는 지금 어디에서 CPU 사이클을 낭비하고 있는가?
스레드/코어/NUMA/네트워크 스택에 대해 얼마나 얕은 가정을 하고 있는가?
서버를 늘리기 전에, 단일 노드 성능을 2배, 5배 올릴 여지는 없을까?
아키텍처를 멋지게 분산시키는 것도 엔지니어의 즐거움이지만, 때로는 한 대의 서버를 끝까지 쥐어짜 보는 편이 훨씬 싸고, 빠르고, 안정적인 길일 때가 있습니다.
"모든 것이 로컬호스트에서는 잘 돌아간다"는 말처럼, 진짜 문제는 확장 그 자체가 아니라, 확장에 대해 우리가 자동으로 믿어버린 전제들일지도 모릅니다.
이 노트는 요약·비평·학습 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
