WebSocket 완전 정복 · 3부. 심화: 확장, 인프라, 운영, 보안 · 16편 중 12편
이전 편: 11편 — 서브프로토콜과 확장
11편까지는 서버 한 대 안에서 끝나는 이야기였습니다. 이번 편은 서버를 두 대 이상으로 늘렸을 때 생기는 일입니다 — 다른 서버에 붙은 클라이언트에게 메시지를 건네는 백플레인과 그 선택지, 그리고 증설·배포·장애 때 연결을 어떻게 다루는지 봅니다.
연결 자체가 상태다
HTTP API 서버는 무상태라 늘리기만 하면 되지만, WebSocket은 연결 자체가 상태입니다. 서버 1의 A가 서버 2의 B에게 메시지를 보내려면 Pub/Sub 백플레인이 필요합니다.
text
Client A ─┐ ┌─ Client B
▼ ▼
[WS Server 1] ◀──▶ [Redis Pub/Sub] ◀──▶ [WS Server 2]Redis Pub/Sub 백플레인 구현
ts
import { WebSocketServer, WebSocket } from "ws";
import { createClient } from "redis";
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
// 이 서버 로컬의 채널 → 소켓 매핑
const rooms = new Map<string, Set<WebSocket>>();
function join(room: string, ws: WebSocket) {
if (!rooms.has(room)) {
rooms.set(room, new Set());
// 이 서버에 해당 방의 첫 구독자가 생길 때만 Redis 구독
sub.subscribe(`room:${room}`, (message) => {
for (const client of rooms.get(room) ?? []) {
if (client.readyState === WebSocket.OPEN) client.send(message);
}
});
}
rooms.get(room)!.add(ws);
}
function leave(room: string, ws: WebSocket) {
const set = rooms.get(room);
if (!set) return;
set.delete(ws);
if (set.size === 0) {
rooms.delete(room);
sub.unsubscribe(`room:${room}`);
}
}
const wss = new WebSocketServer({ port: Number(process.env.PORT ?? 8080) });
wss.on("connection", (ws) => {
const joined = new Set<string>();
ws.on("message", async (raw) => {
let msg: any;
try { msg = JSON.parse(raw.toString()); } catch { return ws.close(1007); }
if (msg.type === "join" && typeof msg.room === "string") {
join(msg.room, ws); joined.add(msg.room);
} else if (msg.type === "chat" && joined.has(msg.room)) {
await pub.publish(`room:${msg.room}`,
JSON.stringify({ type: "chat", room: msg.room, text: String(msg.text).slice(0, 2000) }));
}
});
ws.on("close", () => { for (const r of joined) leave(r, ws); });
});백플레인 선택지
- Redis Pub/Sub: 빠르고 간단, 전송 보장 없음. 재전송 필요하면 Redis Streams.
- NATS: 가볍고 빠름, JetStream으로 영속성.
- Kafka: 처리량·영속성 우수, 무겁고 지연 있음. 이벤트 로그에 적합.
그 밖에 챙길 것
- 스티키 세션: 순수 WebSocket은 연결이 한 서버에 고정되므로 보통 불필요. Socket.IO처럼 long polling으로 시작하는 경우는 필수.
- 연결 불균형: LB는 새 연결만 분배. 증설해도 기존 연결은 안 옮겨감 → 과부하 서버가 일부 연결을 1001/1012로 끊어 재분배 유도.
- 무중단 배포: SIGTERM → 새 연결 수락 중지 → 1001 전송(몇 초에 걸쳐 분산) → 클라이언트는 지터 재연결로 다른 서버에 접속.
- 프레즌스: Redis TTL 키로 기록, 하트비트마다 갱신. 서버 크래시 시 TTL 만료로 자동 정리.
더 깊이
- PM2 완전 정복 4편 — God Daemon과 클러스터 모드, 그리고 진짜 무중단 재시작: 같은 문제를 Node.js 프로세스 매니저 쪽에서 본 기록입니다. 워커가 독립 프로세스라 생기는 상태 문제(12-4)에서 Socket.IO의 스티키 세션과 Redis 어댑터가 나오고, 13장의 Graceful Shutdown이 이 편의 무중단 배포 순서를 프로세스 신호 수준에서 다룹니다.
- 9편 — 재연결 전략: 무중단 배포의 마지막 단계인 지터 재연결로 다른 서버에 접속하는 일은 클라이언트가 9편의 백오프 클래스를 갖추고 있어야 성립합니다.