서버가 두 대가 되는 순간 같은 방이 갈라집니다

서버 A와 B에 나뉘어 연결된 사용자를 브로커로 fan-out하고, 저장된 이벤트와 offset으로 누락까지 복구하는 구조를 배웁니다.

소켓 서버 한 대에서는 채팅방 구현이 단순합니다. 메모리에 room-7 연결 목록을 두고 모든 소켓에 메시지를 보내면 됩니다. 사용자가 늘어 서버를 두 대로 확장하면 뜻밖의 문제가 생깁니다.

민지는 서버 A에, 준호는 서버 B에 연결되었습니다. 두 서버 모두 room-7이라는 이름을 사용하지만 메모리는 서로 공유하지 않습니다.

같은 방 이름만으로는 같은 방이 되지 않습니다. 서버 사이 이벤트 경로가 필요합니다.

서버 확장 데모서버끼리 분리에서는 민지의 메시지가 A에서 멈춥니다. 브로커 + 저장으로 바꾸면 B까지 실시간으로 전달되고 오프라인 사용자는 재접속 후 저장소에서 놓친 이벤트를 읽습니다.

서버가 늘면 연결 목록도 갈라집니다

각 WebSocket 연결은 특정 서버 프로세스에 붙어 있습니다.

민지 ── socket ── 서버 A       서버 B ── socket ── 준호
                  room-7       room-7
                  [민지]        [준호]

서버 A가 자신의 room-7 목록을 순회하면 민지에게만 보냅니다. B에 준호가 있다는 사실을 모르기 때문입니다. 프로세스 메모리, 컨테이너와 가상 머신이 다르면 같은 변수 이름도 서로 다른 데이터입니다.

sticky session은 연결 라우팅만 해결합니다

Socket.IO가 HTTP long-polling을 사용할 때는 한 세션의 여러 HTTP 요청이 같은 서버로 가도록 sticky session이 필요합니다. 반면 WebSocket 전용 구성은 하나의 지속 TCP 연결이 이미 특정 서버에 붙어 있으므로 sticky session이 필수는 아닙니다. 어느 경우에도 A에서 생긴 메시지를 B에 알려 주지는 않습니다.

문제sticky session서버 간 브로커
polling 세션 요청을 같은 서버로 전달필요직접 목적 아님
WebSocket 전용 연결 유지보통 불필요직접 목적 아님
다른 서버의 사용자에게 전달해결 못 함해결
서버 장애 후 누락 복구해결 못 함Pub/Sub만으로는 부족

확장 구조에서는 연결 라우팅이벤트 전달을 별도 문제로 봐야 합니다.

브로커가 온라인 서버에 fan-out합니다

서버 A는 메시지를 받으면 로컬 사용자에게 보내는 동시에 공통 토픽에 발행합니다. 서버 B도 room-7 토픽을 구독하고 있으므로 이벤트를 받아 자신의 로컬 사용자에게 전달합니다.

민지 → 서버 A → PUBLISH room-7

               EVENT BROKER

준호 ← 서버 B ← SUBSCRIBE room-7

Redis Pub/Sub, NATS, Kafka 계열 또는 제품별 소켓 어댑터가 이 역할을 할 수 있습니다. 선택 기준은 지연, 메시지 크기, 순서, 내구성, 운영 복잡도와 이미 사용하는 인프라입니다.

실시간 fan-out과 누락 복구는 다른 역할입니다

Redis Pub/Sub은 구독자가 온라인일 때 빠르게 메시지를 퍼뜨리는 데 적합합니다. 하지만 공식 문서가 설명하듯 전달 의미는 at most once입니다. 구독이 끊긴 동안 발생한 메시지는 나중에 다시 받을 수 없습니다.

따라서 두 역할을 분리하면 이해하기 쉽습니다.

구성 요소책임
Pub/Sub 브로커지금 연결된 서버에 빠르게 fan-out
이벤트 저장소고유 ID와 순서로 이벤트 보관
클라이언트 offset마지막으로 적용한 이벤트 위치 기억
재생 API·스트림offset 이후 누락 이벤트 전달

Redis를 사용한다면 일시적 알림에는 Pub/Sub, 재생이 필요한 흐름에는 Redis Streams 같은 내구성 있는 구조를 검토할 수 있습니다.

이벤트에는 전역 ID가 필요합니다

서버 A와 B가 각자 1, 2, 3 번호를 만들면 전체 순서를 비교하기 어렵습니다. 저장소가 고유한 eventId나 offset을 부여하도록 합니다.

{
  "eventId": "event-42",
  "roomId": "room-7",
  "type": "CHAT_MESSAGE",
  "sender": "minji",
  "text": "안녕하세요"
}

클라이언트는 마지막 적용 ID를 기억하고 재접속 때 보냅니다. 브로커의 실시간 메시지와 저장소의 재생 메시지가 경계에서 겹칠 수 있으므로 eventId로 중복을 제거합니다.

‘전역’은 서비스 전체에 정수 하나를 둬야 한다는 뜻은 아닙니다. 방이나 파티션처럼 순서를 보장할 범위 안에서 ID가 유일하고 비교 가능하면 됩니다. 모든 채팅방의 순서를 하나로 만들면 서로 관계없는 방도 같은 발급기를 기다려야 합니다. 반대로 서버 A와 B가 충돌 가능한 번호를 따로 만들면 재생 경계에서 어느 것이 다음인지 알 수 없습니다.

발행 순서와 저장 순서를 정합니다

다음 두 순서 모두 실패 틈이 있습니다.

브로커에 먼저 발행 → 저장 전에 서버 종료
저장소에 먼저 기록 → 발행 전에 서버 종료

중요 이벤트는 데이터베이스 트랜잭션과 outbox를 사용하거나, 저장된 이벤트를 소비하는 별도 발행자가 브로커로 전달하게 설계할 수 있습니다. 타이핑 표시처럼 사라져도 되는 이벤트는 저장하지 않고 Pub/Sub만 사용하는 편이 단순합니다.

모든 이벤트에 똑같은 내구성을 적용하지 말고 성격에 따라 나눕니다.

  • 일시적: 타이핑, 커서 위치, 현재 접속자 heartbeat
  • 복구 필요: 채팅 메시지, 작업 상태, 알림 내역
  • 업무 기록: 결제, 주문, 권한 변경 — 더 강한 트랜잭션과 감사 필요

운영에서 확인할 항목

  • 서버 인스턴스마다 고유 ID를 부여합니다.
  • 자기 자신이 발행한 메시지를 두 번 보내지 않도록 규칙을 정합니다.
  • room 구독·해제 누수를 관찰합니다.
  • 브로커 단절 시 로컬 연결을 유지할지 실패 처리할지 정합니다.
  • 이벤트 지연, 재생량과 구독자 수를 측정합니다.
  • 배포 중 서로 다른 프로토콜 버전이 공존하도록 호환성을 둡니다.
  • 발행 전에 사용자의 방 접근 권한을 확인하고, 재생 API에도 같은 권한 검사를 둡니다.
  • 한 방의 폭주가 다른 방과 브로커 전체를 막지 않도록 메시지·큐 상한을 둡니다.

브로커에 연결됐다는 사실은 권한을 증명하지 않습니다. 토픽 이름을 추측한 사용자가 다른 방의 이벤트를 구독하지 못하도록 인증과 방 membership 검사를 각 진입점에서 적용해야 합니다.

서버 두 대에 나눠 붙여 보세요

나쁜 결과와 안전한 결과를 비교하세요

한 채팅방을 서버 A와 B에 걸쳐 연결합니다

보는 순서서버 분리 선택A에서 발행B 수신 확인

  1. 1분산 연결
  2. 2발행
  3. 3단절
  4. 4결론
실험 시작을 누르면 첫 장면부터 움직입니다방이 둘로 분리
민지Server A 연결
준호Server B 연결
INSTANCE A소켓 서버 Aroom-7 · 민지
공유 경로연결 없음각 서버 메모리만 사용
INSTANCE B소켓 서버 Broom-7 · 준호

room-7A ┆ 끊어진 방 ┆ BLOCAL ONLY

서버끼리 분리 시나리오입니다. 실험 시작을 눌러 네 장면을 따라가세요.

시작 전

화면에서 코드로 옮길 때

이 편의 원리 세 가지

01연결 목록은 로컬 상태입니다.

서버 A의 메모리는 서버 B에 연결된 사용자를 알지 못합니다.

02브로커는 지금 퍼뜨립니다.

온라인 서버 인스턴스에 토픽 이벤트를 fan-out합니다.

03저장소는 나중을 책임집니다.

끊긴 사용자가 마지막 offset 이후 이벤트를 다시 읽게 합니다.

구현 체크리스트

최소 네 조각으로 시작하세요.

데모의 시각 요소를 실제 프로토콜 필드와 런타임 동작으로 옮기면 됩니다.

  1. 1공통 room ID모든 서버가 같은 규칙으로 만드는 토픽 이름
  2. 2Pub/Sub 브로커현재 살아 있는 서버에 빠르게 이벤트 전달
  3. 3내구성 저장소고유 event ID와 순서를 유지하는 로그
  4. 4중복 허용 소비자재생 이벤트가 겹쳐도 한 번만 화면에 반영
이 데모는 네트워크 동작을 단계별로 보여주는 개념 시뮬레이션입니다.

Redis Pub/Sub과 같은 실시간 브로커만으로는 오프라인 구독자의 누락을 복구하지 못합니다. 저장과 재생은 별도로 설계하세요.

데모만 따로 보려면 전체 화면으로 열기.

  1. 서버끼리 분리에서 민지와 준호가 서로 다른 서버에 연결됐는지 봅니다.
  2. 민지의 메시지가 A에만 보이고 B에는 이벤트가 없는지 확인합니다.
  3. 브로커 + 저장으로 바꾸고 A → broker → B 경로를 따라갑니다.
  4. 두 사용자 모두 수신했는지 확인합니다.
  5. 준호가 오프라인이었던 경우 저장소에서 event-41 이후를 재생하는지 봅니다.

브로커와 저장소는 다른 일을 합니다

  • 소켓 연결 목록은 각 서버 프로세스의 로컬 상태입니다.
  • 서버가 여러 대면 공통 브로커로 이벤트를 fan-out해야 합니다.
  • Pub/Sub의 실시간 전달과 저장소의 누락 복구는 역할이 다릅니다.
  • 이벤트 ID와 클라이언트 offset으로 재생 경계의 중복을 제거합니다.

마지막 편에서는 서버가 아니라 네트워크 시간 차이를 다룹니다. 실시간 위치와 지연 대응에서 늦게 온 과거 좌표를 왜 버려야 하는지 확인합니다.

확인한 자료


관련 글