이 글의 목차

월요일 오후 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);
});
});

프로덕션 체크리스트

배포 전 다음 사항들을 반드시 점검하세요.

  1. Redis 설정: 클러스터 모드나 센티넬을 구성했나요? 백업 전략은 있나요?
  2. 로드 밸런서: Sticky Session이 켜져 있나요? 타임아웃 설정은 충분한가요?
  3. 메모리 튜닝: perMessageDeflate를 껐나요? Node.js 힙 사이즈(--max-old-space-size)를 늘렸나요?
  4. 모니터링: 연결 수, 메시지 처리량, 에러율에 대한 알림(Alert)이 설정되었나요?
  5. 보안: Origin 검사(CORS)와 연결 횟수 제한(Rate Limiting)이 적용되었나요?

결론: 아키텍처가 전부입니다

WebSocket 대규모 확장은 단순히 서버 대수를 늘리는 것으로 해결되지 않습니다. Stateful한 연결의 특성을 이해하고, Redis Pub/Sub와 같은 메시지 브로커를 통해 서버 간의 상태를 동기화하는 아키텍처가 필수적입니다.

핵심 요약:

  1. Redis Pub/Sub: 다중 서버 간 메시지 동기화의 핵심.
  2. Sticky Session: 클라이언트와 서버의 연결 고리 유지.
  3. 메모리 관리: 불필요한 옵션 끄기 및 연결 정리 철저.
  4. 배치 처리: 대량 전송 시 CPU 보호.

지금 바로 Redis Adapter를 적용하고, Sticky Session 설정을 확인해 보세요. 100만 동시 연결도 충분히 감당할 수 있습니다.

← 전체 글로 돌아가기