본문 바로가기
Backend2025년 4월 30일8분 읽기

WebSocket 스케일링 — Redis PubSub과 Sticky Session

YS
김영삼
조회 315

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

아직 댓글이 없습니다.
Ctrl+Enter로 등록