← posts/b.log()

blog92@web:~$ cat posts/websocket-12-horizontal-scaling.md

NETWORK2 min read

WebSocket 완전 정복 12편 — 수평 확장: Pub/Sub 백플레인과 무중단 배포

연결 자체가 상태이기 때문에 서버를 늘리는 것만으로는 끝나지 않는 이유와 Redis Pub/Sub 백플레인 구현을 코드로 살펴보고, Redis·NATS·Kafka 선택지, 스티키 세션이 보통 불필요한 이유, 연결 불균형과 무중단 배포, 프레즌스 관리까지 다룹니다.

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편의 백오프 클래스를 갖추고 있어야 성립합니다.
COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..