# WebSocket 대규모 확장 완벽 가이드: Redis Pub/Sub로 100만 동시 연결 처리하기
Table of Contents
월요일 오후 2시, 서버가 멈췄습니다
실시간 채팅 서비스의 DAU(일일 활성 사용자)가 10만을 돌파한 순간, 모든 것이 무너졌습니다. WebSocket 서버의 메모리 사용량은 5GB를 넘어서고, CPU 점유율은 100%를 찍었습니다. 새로운 사용자는 연결조차 할 수 없고, 기존 사용자들은 메시지가 전송되지 않는다며 불만을 토로하기 시작했습니다.
AWS 비용은 평소의 10배로 치솟았고, 긴급 회의가 소집되었습니다. 로그를 확인해 보니 100,000개의 동시 WebSocket 연결이 단일 서버에 몰려 있었습니다.
“이건 단순한 수평 확장(Scale-out)으로 해결되지 않습니다. WebSocket은 Stateful 연결이라…”
엔지니어의 한숨 섞인 보고에 경영진의 표정이 어두워집니다. 실시간 서비스의 스케일링은 일반적인 HTTP API 확장과는 차원이 다른 문제입니다.
이것이 바로 대규모 WebSocket 서비스가 직면하는 냉혹한 현실입니다.
이 글에서는 Redis Pub/Sub를 활용한 WebSocket 서버 확장 전략과, 프로덕션 환경에서 100만 동시 연결을 안정적으로 처리하는 실전 아키텍처를 깊이 있게 다룹니다.
WebSocket 확장이 어려운 이유
HTTP vs WebSocket: 근본적인 차이
// HTTP: Stateless - 요청마다 독립적
app.get('/api/messages', async (req, res) => {
const messages = await db.getMessages();
res.json(messages); // 응답 후 연결 종료
});
// WebSocket: Stateful - 연결이 계속 유지됨
io.on('connection', (socket) => {
// 이 연결은 클라이언트가 명시적으로 끊기 전까지 유지됨
// 메모리에 소켓 객체가 계속 존재
socket.on('message', (data) => {
io.emit('message', data); // 모든 연결된 클라이언트에게 전송
});
});
HTTP (Stateless):
- 요청-응답 사이클이 끝나면 연결이 종료됩니다.
- 서버 메모리에 상태를 저장할 필요가 없습니다.
- 로드 밸런서가 어떤 서버로 요청을 보내든 상관없습니다.
WebSocket (Stateful):
- 연결이 지속적으로 유지됩니다.
- 각 연결마다 서버 메모리를 점유합니다 (평균 연결당 1-10KB).
- 특정 서버에 연결된 클라이언트는 해당 서버하고만 통신할 수 있습니다.
10만 연결에서의 리소스 소모량
- 단일 연결당 평균 메모리: 약 5KB
- 100,000 연결: 500MB (순수 연결 유지 비용만)
- 실제 필요 메모리: 버퍼, Node.js 힙, OS 오버헤드를 포함하면 4~5GB가 필요합니다.
WebSocket 스케일링의 5가지 치명적인 문제
1. 메모리 누수와 파편화(Fragmentation)
실제 사례 (WildFly): 초기 500MB였던 메모리가 2만 연결에서 2.8GB, 4만 연결에서 4.1GB로 증가하더니, 24시간 후 OOM(Out Of Memory) Kill로 서버가 사망했습니다.
근본 원인:
// 나쁜 예: 메모리 누수 패턴
const connections = new Map(); // 절대 정리되지 않음
io.on('connection', (socket) => {
const userId = socket.handshake.query.userId;
connections.set(userId, socket); // 저장
// 문제: disconnect 시 정리하지 않음!
socket.on('disconnect', () => {
console.log('User disconnected');
// connections.delete(userId); // 이 줄이 없으면 메모리 누수 발생
});
});
올바른 구현:
반드시 disconnect 이벤트에서 참조를 해제하고, setInterval 등을 통해 좀비 연결을 주기적으로 정리해야 합니다.
2. 수평 확장 시 메시지 전파 불가
문제 상황: 서버 A에 있는 ‘철수’가 메시지를 보냈는데, 서버 B에 연결된 ‘영희’는 메시지를 받지 못합니다. 각 서버가 서로 독립적으로 동작하기 때문입니다.
해결책: Redis Pub/Sub: 모든 WebSocket 서버가 Redis를 바라보게 하고, 메시지를 Redis로 발행(Publish)하면 Redis가 모든 서버에 구독(Subscribe)된 내용을 전파합니다.
import { createClient } from 'redis';
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
// ... Redis 클라이언트 생성 ...
// Socket.IO에 Redis Adapter 연결
io.adapter(createAdapter(pubClient, subClient));
// 이제 io.emit()을 호출하면 Redis를 통해 모든 서버의 클라이언트에게 메시지가 전달됩니다.
3. Sticky Session 없는 로드 밸런싱 실패
문제: 로드 밸런서가 라운드 로빈 방식으로 요청을 분산하면, WebSocket 핸드셰이크 과정(HTTP → Upgrade)에서 요청이 다른 서버로 튀어 연결이 끊기는 현상이 발생합니다.
해결책: Nginx나 AWS ALB에서 **Sticky Session(세션 고정)**을 설정해야 합니다. 클라이언트의 IP나 쿠키를 기반으로 항상 같은 서버에 연결되도록 보장해야 합니다.
Nginx 설정 예시:
upstream websocket_backend {
ip_hash; # IP 해시 방식 사용
server 10.0.1.10:3000;
server 10.0.1.11:3000;
}
4. perMessageDeflate로 인한 메모리 폭증
문제:
Socket.IO의 기본 설정인 perMessageDeflate: true는 메시지 압축을 위해 zlib를 사용합니다. 이는 연결당 상당한 메모리 오버헤드와 GC(Garbage Collection) 부하를 유발합니다.
해결책: 프로덕션 환경, 특히 대규모 연결에서는 이 옵션을 비활성화하는 것이 좋습니다. 대신 HTTP 레벨에서의 압축(Nginx gzip 등)을 활용하세요.
const io = new Server(3000, {
perMessageDeflate: false // 비활성화 권장
});
5. 브로드캐스트 시 CPU 스파이크
문제: 10만 명에게 동시에 알림을 보내면(Broadcast), 10만 번의 직렬화와 패킷 전송이 동시에 발생하여 CPU가 100%로 치솟고 서버가 일시적으로 멈출 수 있습니다.
해결책: 배칭(Batching)과 쓰로틀링(Throttling): 메시지를 큐에 담아두고, 1000개씩 나누어(Batch) 약간의 딜레이를 두고 전송하여 CPU가 숨 쉴 틈을 주어야 합니다.
Redis Pub/Sub 아키텍처 구현
전체 시스템 구조
[클라이언트] (100만 동시 연결)
│
▼
[로드 밸런서 (Nginx/ALB)] - Sticky Session 필수
│
├──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼
[WS 서버 1] [WS 서버 2] [WS 서버 3] ...
│ │ │ │
└──────────┴─────┬────┴──────────┘
│
[Redis Cluster]
(Pub/Sub)
Node.js + Socket.IO + Redis 실전 코드
서버 코드는 socket.io-redis 어댑터를 사용하여 구현합니다. 핵심은 Redis 클라이언트를 pubClient와 subClient 두 개로 분리하여 어댑터에 주입하는 것입니다.
// ... (초기 설정 생략)
// Redis Adapter 연결
io.adapter(createAdapter(pubClient, subClient));
io.on('connection', (socket) => {
// 사용자 입장 시 Redis에 상태 저장 (Presence)
pubClient.sAdd('online:users', userId);
// 채팅 메시지 처리
socket.on('chat:message', async (data) => {
// Redis Adapter가 자동으로 다른 서버에도 전파해 줍니다.
io.to(data.roomId).emit('chat:message', data);
// 메시지 히스토리는 별도로 Redis List 등에 저장
await pubClient.lPush(`room:${data.roomId}:messages`, JSON.stringify(data));
});
// 연결 해제 처리
socket.on('disconnect', async () => {
await pubClient.sRem('online:users', userId);
});
});
프로덕션 체크리스트
배포 전 다음 사항들을 반드시 점검하세요.
- Redis 설정: 클러스터 모드나 센티넬을 구성했나요? 백업 전략은 있나요?
- 로드 밸런서: Sticky Session이 켜져 있나요? 타임아웃 설정은 충분한가요?
- 메모리 튜닝:
perMessageDeflate를 껐나요? Node.js 힙 사이즈(--max-old-space-size)를 늘렸나요? - 모니터링: 연결 수, 메시지 처리량, 에러율에 대한 알림(Alert)이 설정되었나요?
- 보안: Origin 검사(CORS)와 연결 횟수 제한(Rate Limiting)이 적용되었나요?
결론: 아키텍처가 전부입니다
WebSocket 대규모 확장은 단순히 서버 대수를 늘리는 것으로 해결되지 않습니다. Stateful한 연결의 특성을 이해하고, Redis Pub/Sub와 같은 메시지 브로커를 통해 서버 간의 상태를 동기화하는 아키텍처가 필수적입니다.
핵심 요약:
- Redis Pub/Sub: 다중 서버 간 메시지 동기화의 핵심.
- Sticky Session: 클라이언트와 서버의 연결 고리 유지.
- 메모리 관리: 불필요한 옵션 끄기 및 연결 정리 철저.
- 배치 처리: 대량 전송 시 CPU 보호.
지금 바로 Redis Adapter를 적용하고, Sticky Session 설정을 확인해 보세요. 100만 동시 연결도 충분히 감당할 수 있습니다.