sayu.day
Backend/WebSocket

확장 가능한 실시간 WebSocket 서버 아키텍처 설계

수평 확장 WebSocket 서버에서 세션 라우팅과 메시지 복구를 설계하는 실무 기준을 정리합니다.

발행 2025년 12월 27일2261

같은 주제에서 이어 읽기

GitLab CI/CD 시리즈 #1: 기초 - .gitlab-ci.yml의 구조와 Pipeline 이해

Backend/WebSocket 안에서 이어지는 글

문제 정의

WebSocket 서버를 여러 대로 늘리면 "어떤 유저가 어느 서버에 붙어 있는지"가 분산됩니다. 이 상태를 중앙에서 일관되게 관리하지 않으면 메시지 유실과 오배송이 발생합니다.

권장 구성

  • Load Balancer: 연결 분산.
  • WS 서버 N대: 실제 연결 유지.
  • Redis Hash: userId -> serverId 세션 위치 저장.
  • Redis Pub/Sub: 서버 간 실시간 이벤트 전달.
  • Redis Streams: 재연결 시 누락 메시지 복구.

데이터 모델

HSET ws:sessions {userId} {serverId}
PUBLISH ws:server:{serverId} {event-json}
XADD ws:events * userId {userId} payload {json}

핵심 흐름

  1. 연결 성공 시 ws:sessions에 세션 등록.
  2. A 유저가 B 유저에게 메시지 전송.
  3. B의 serverId를 Hash에서 조회.
  4. 해당 서버 채널로 Pub/Sub 발행.
  5. 수신 서버가 로컬 소켓으로 전달.
  6. 이벤트를 Streams에 기록해 재전송 가능 상태 유지.

서버 종료/장애 대응

  • Heartbeat + TTL: 좀비 세션 자동 정리.
  • Graceful Shutdown: 종료 전 해당 서버 세션 일괄 제거.
  • Reconnect Resume: 클라이언트가 마지막 이벤트 ID를 보내면 Streams에서 누락분 재전송.

예시 코드(개념)

async function onConnect(userId: string, serverId: string) {
  await redis.hset('ws:sessions', userId, serverId);
  await redis.expire(`ws:alive:${userId}`, 30);
}

async function routeMessage(toUserId: string, payload: object) {
  const targetServer = await redis.hget('ws:sessions', toUserId);
  if (!targetServer) return;

  await redis.publish(`ws:server:${targetServer}`, JSON.stringify({ toUserId, payload }));
  await redis.xadd('ws:events', '*', 'userId', toUserId, 'payload', JSON.stringify(payload));
}

관측 지표

  • 연결 수/활성 세션 수
  • Pub/Sub 전달 지연(p95, p99)
  • reconnect 후 누락 복구 성공률
  • 서버별 fan-out 처리량

요약

확장형 WebSocket의 본질은 소켓 핸들링이 아니라 상태 일관성입니다. 세션 위치, 라우팅, 복구 경로를 분리해 설계해야 장애 시 복구가 가능합니다.

다음 읽기

이 생각이 이어지는 방향

Backend/WebSocket 더 보기
공유

읽은 뒤의 대화

읽은 뒤의 생각을 이어갑니다

질문, 반론, 조용한 후속 메모를 이 글 아래에 남길 수 있습니다.

sayu.day는 생각과 작업의 흔적을 천천히 정리하는 개인 출판물입니다.
직접 겪고 검토한 내용, 다시 읽을 만한 아이디어, 작업하며 남긴 메모를 모읍니다.
시간이 지난 글은 현재의 판단과 다를 수 있어 업데이트 맥락을 함께 남깁니다.

© 2026 sayu.day