WebSocket 스케일링 문제
단일 서버에서는 WebSocket이 잘 동작하지만, 여러 서버로 스케일 아웃하면 문제가 발생합니다. 클라이언트 A가 서버 1에, 클라이언트 B가 서버 2에 연결되면 서로 메시지를 교환할 수 없습니다. 이를 해결하는 두 가지 핵심 패턴이 Redis PubSub과 Sticky Session입니다.
문제 시나리오
// 단일 서버: 정상 동작
// Server 1의 메모리에 모든 연결이 존재
const connections = new Map(); // userId -> WebSocket
// 다중 서버: 문제 발생
// Server 1: connections = { userA: ws1 }
// Server 2: connections = { userB: ws2 }
// userA -> userB 메시지 전달 불가!
Redis PubSub 브로커 패턴
Redis를 메시지 브로커로 사용하여 모든 서버 간 메시지를 중계합니다. 각 서버는 Redis 채널을 구독하고, 메시지가 발행되면 해당 서버에 연결된 클라이언트에게 전달합니다.
import { WebSocketServer } from 'ws';
import { createClient } from 'redis';
const wss = new WebSocketServer({ port: 8080 });
const publisher = createClient({ url: 'redis://redis:6379' });
const subscriber = publisher.duplicate();
await publisher.connect();
await subscriber.connect();
// 로컬 연결 관리
const localConnections = new Map(); // roomId -> Set<WebSocket>
// Redis 구독: 다른 서버에서 발행한 메시지 수신
await subscriber.subscribe('chat:*', (message, channel) => {
const roomId = channel.split(':')[1];
const clients = localConnections.get(roomId) || new Set();
for (const ws of clients) {
if (ws.readyState === 1) { // OPEN
ws.send(message);
}
}
});
wss.on('connection', (ws, req) => {
const roomId = new URL(req.url, 'http://localhost').searchParams.get('room');
// 로컬 연결 등록
if (!localConnections.has(roomId)) {
localConnections.set(roomId, new Set());
}
localConnections.get(roomId).add(ws);
ws.on('message', async (data) => {
// Redis로 발행 -> 모든 서버가 수신
await publisher.publish(`chat:${roomId}`, data.toString());
});
ws.on('close', () => {
localConnections.get(roomId)?.delete(ws);
});
});
Socket.IO + Redis Adapter
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const io = new Server(3000, {
cors: { origin: '*' },
transports: ['websocket', 'polling'],
});
// Redis 어댑터 설정 (자동으로 PubSub 처리)
const pubClient = createClient({ url: 'redis://redis:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
// 이제 Room 기능이 다중 서버에서 자동으로 동작
io.on('connection', (socket) => {
socket.on('join-room', (roomId) => {
socket.join(roomId);
});
socket.on('chat-message', (roomId, message) => {
// 모든 서버의 해당 Room 클라이언트에게 전달
io.to(roomId).emit('new-message', message);
});
});
Sticky Session 설정
로드 밸런서가 같은 클라이언트를 항상 같은 서버로 보내는 방식입니다. Socket.IO의 polling 전송에서는 필수입니다.
# Nginx Sticky Session 설정
upstream websocket_servers {
ip_hash; # IP 기반 Sticky Session
server app1:3000;
server app2:3000;
server app3:3000;
}
# 또는 Cookie 기반
upstream websocket_servers {
server app1:3000;
server app2:3000;
sticky cookie srv_id expires=1h path=/;
}
server {
listen 80;
location /socket.io/ {
proxy_pass http://websocket_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
스케일링 패턴 비교
| 패턴 | 장점 | 단점 | 적합한 경우 |
|---|---|---|---|
| Redis PubSub | 서버 무상태, 유연한 스케일링 | Redis 의존, 메시지 손실 가능 | 채팅, 알림 |
| Redis Streams | 메시지 영속화, Consumer Group | 구현 복잡 | 메시지 유실 불가 시 |
| Sticky Session | 간단한 구현 | 불균등 분배, 서버 장애 시 연결 유실 | 소규모, polling |
| NATS / Kafka | 높은 처리량, 내구성 | 인프라 복잡 | 대규모 이벤트 스트리밍 |
- Socket.IO를 사용한다면 Redis Adapter가 가장 간편한 솔루션입니다
- 네이티브 WebSocket을 사용할 때는 Redis PubSub 직접 구현이 필요합니다
- 연결 수가 10만 이상이면 Redis Cluster와 수평 파티셔닝을 고려하세요
댓글 0