웹소켓 100만 동시 접속을 처리하는 방법: C1M 문제 해결 가이드
"C10K 문제"는 이제 옛말입니다. 오늘날 우리는 단일 서버에서 100만 개의 동시 연결을 처리하는 C1M 문제에 직면해 있습니다.
채팅 앱, 실시간 스포츠 중계, 혹은 협업 도구와 같은 실시간 애플리케이션을 개발하다 보면 결국 벽에 부딪히게 됩니다. 서버가 다운되거나, 새로운 연결이 타임아웃되고, 레이턴시가 급증하는 현상이죠. 그리고 이 문제의 원인은 애플리케이션 코드가 아닌 경우가 많습니다. 범인은 바로 운영체제(OS) 설정입니다.
이번 포스트에서는 리눅스 서버를 튜닝하여 100만 개의 웹소켓 동시 연결을 처리하는 구체적인 방법을 알아보겠습니다. 파일 디스크립터, 임시 포트(Ephemeral Ports), 그리고 단일 노드를 넘어 확장하기 위한 아키텍처까지 깊이 있게 다뤄봅시다.
병목은 (대부분) Node.js가 아닙니다
많은 개발자분들이 Node.js(혹은 Python/Ruby)가 느려서 수백만 건의 연결을 처리할 수 없다고 생각합니다. 물론 단일 스레드의 한계는 있지만, 여러분이 가장 먼저 마주칠 병목은 거의 항상 운영체제입니다.
기본적으로 리눅스는 수백만 개의 지속적인 TCP 연결을 유지하기 위해서가 아니라, 일반적인 컴퓨팅 목적을 위해 설정되어 있기 때문이죠.
1. "Too Many Open Files" 에러
리눅스에서는 모든 것이 파일입니다. 소켓도 파일이고, 디스크의 파일도 파일입니다. 프로세스가 연결을 열 때마다 파일 디스크립터(FD)를 하나 소비합니다.
현재 제한을 확인해 보세요:
ulimit -n # 출력: 1024 (보통 이렇습니다)
만약 1,025번째 연결을 시도하면, 애플리케이션은 EMFILE: too many open files 에러를 뱉으며 죽어버릴 겁니다.
해결 방법:
시스템 전체 제한과 프로세스별 제한을 모두 늘려야 합니다.
/etc/sysctl.conf 파일을 수정합니다:
fs.file-max = 2097152
/etc/security/limits.conf 파일을 수정합니다:
* soft nofile 1048576 * hard nofile 1048576 root soft nofile 1048576 root hard nofile 1048576
sysctl -p 명령어로 변경 사항을 적용하세요. 이제 OS는 충분한 파일 디스크립터를 허용할 겁니다. 하지만 이건 시작일 뿐입니다.
2. 임시 포트 고갈(Ephemeral Port Exhaustion)의 함정
이것은 대규모 웹소켓 아키텍처에서 가장 흔한 "침묵의 살인자"입니다.
클라이언트가 서버에 연결할 때 소스 IP와 소스 포트를 사용합니다. 반대로 여러분의 서버가 백엔드 데이터베이스나 다른 서비스에 연결할 때는 서버가 클라이언트가 되어 로컬 포트를 사용하게 되죠.
사용 가능한 포트는 65,535개뿐입니다. 만약 웹소켓 서버 앞단에 로드 밸런서나 프록시가 있다면, 나가는 연결을 위한 포트가 부족해질 수 있습니다.
TCP 튜플(Tuple)
TCP 연결은 4개의 정보로 식별됩니다:
{소스 IP, 소스 포트, 목적지 IP, 목적지 포트}
로드 밸런서가 웹소켓 서버에 연결할 때, 두 서버가 특정 IP에 고정되어 있다면 사용 가능한 소스 포트 수(약 6만 개)에 의해 제한을 받게 됩니다.
해결 방법:
-
로컬 포트 범위 늘리기:
# /etc/sysctl.conf net.ipv4.ip_local_port_range = 1024 65535 -
TCP 재사용 활성화:
net.ipv4.tcp_tw_reuse = 1이 설정은 OS가
TIME_WAIT상태의 포트를 더 빨리 재사용할 수 있게 해줍니다.
3. 메모리 사용량: 소켓의 진짜 비용
100만 개의 연결은 램을 얼마나 차지할까요?
과거에는 연결 하나당 커널 메모리를 꽤 많이 차지했지만, 최신 커널은 매우 효율적입니다. 오히려 애플리케이션 메모리가 더 큰 문제입니다.
Node.js를 사용한다면, 모든 Socket 객체는 힙 메모리를 차지합니다.
- 빈 소켓: ~2-4 KB
- 메타데이터 포함 (User ID, 채널 정보 등): ~10 KB
계산해 봅시다:
1,000,000 연결 * 10 KB = 10 GB RAM.
최신 서버에서는 충분히 가능한 수치지만, 주의가 필요합니다.
최적화 팁:
소켓 객체에 전체 User 객체를 저장하지 마세요. userId만 저장하고, 상세 정보는 필요할 때 Redis에서 가져오세요.
// 나쁜 예 socket.user = { id: 1, name: "철수", email: "...", bio: "..." }; // 좋은 예 socket.userId = 1;
4. 이벤트 루프 지연 (Event Loop Lag)
Node.js는 싱글 스레드입니다. 100만 명의 유저가 연결되어 있는데, 그들 모두에게 메시지를 브로드캐스트하려고 하면 이벤트 루프가 차단됩니다.
// 절대 이렇게 하지 마세요 users.forEach(user => { user.socket.send("안녕하세요!"); });
100만 개의 패킷을 보내는 데는 시간이 걸립니다. 전송당 0.01ms가 걸린다면, 총 10초 동안 블로킹이 발생합니다. 서버는 10초 동안 먹통이 되는 거죠.
해결 방법: 배치(Batching)와 워커(Workers)
- 한 번에 모두에게 보내지 마세요. 브로드캐스트를 청크(chunk) 단위로 나누어 보내세요.
- 멀티 프로세스를 사용하세요. Node.js의
cluster모듈을 사용하거나 여러 컨테이너 인스턴스를 실행하세요.
5. 무한 확장을 위한 아키텍처
단일 서버 튜닝으로 100만 연결은 가능합니다. 하지만 1,000만, 1억 연결은요?
분산 웹소켓 아키텍처가 필요합니다.
계층형 접근 (The Layered Approach)
- 로드 밸런서 (Nginx/HAProxy): SSL을 종료하고 백엔드 노드로 연결을 분산합니다.
- 웹소켓 노드 (Node.js/Go): 활성 연결을 유지합니다. 소켓을 들고 있다는 점에서는 "Stateful"하지만, 비즈니스 로직 관점에서는 "Stateless"하게 유지해야 합니다.
- Pub/Sub 레이어 (Redis/NATS): 모든 것을 연결해 주는 접착제 역할을 합니다.
Pub/Sub 작동 방식
서버 1에 연결된 유저 A가 서버 2에 연결된 유저 B에게 메시지를 보내고 싶다면:
- 유저 A가 서버 1로 메시지를 보냅니다.
- 서버 1은 Redis 채널에 메시지를 발행(Publish)합니다:
publish('user:B', payload). - 서버 2(그리고 다른 모든 서버)는 Redis를 구독(Subscribe)하고 있습니다.
- 서버 2는 메시지를 받고, 유저 B가 로컬에 연결되어 있는지 확인합니다.
- 서버 2가 유저 B에게 메시지를 전송합니다.
이 아키텍처를 사용하면 서버를 수평적으로 무제한 확장할 수 있습니다.
6. 한계 테스트하기
프로덕션에서 터지길 기다리지 마세요. 테스트해야 합니다.
Artillery나 k6 같은 도구도 훌륭하지만, 대규모 동시성을 테스트하려면 클라이언트 머신 군단이 필요할 수 있습니다.
Tsung은 수백만 명의 동시 사용자를 시뮬레이션하는 데 탁월한 Erlang 기반의 분산 부하 테스트 도구입니다. 꼭 한번 써보세요.
결론
100만 연결을 처리하는 것은 시스템 엔지니어에게 훈장과도 같습니다. "npm install"의 안락함을 벗어나 리눅스 내부를 들여다봐야 가능한 일이죠.
요약 체크리스트:
ulimit증가 (Open Files).sysctl튜닝 (포트 범위, TCP 재사용).- 애플리케이션 메모리 최적화 (객체 대신 ID 저장).
- 수평 확장을 위한 Pub/Sub 백엔드 (Redis) 사용.
다음번에 트래픽 폭주로 서버가 죽는다면, 인스턴스 크기만 늘리지 말고 커널을 들여다보세요. 답은 보통 거기에 있습니다.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요