소켓 응용 4/5 · 서버 확장 — 두 서버 사이 메시지 잇기
서버 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 → broker → B 경로를 따라갑니다.
- 두 사용자 모두 수신했는지 확인합니다.
- 준호가 오프라인이었던 경우 저장소에서
event-41 이후를 재생하는지 봅니다.
요약
- 소켓 연결 목록은 각 서버 프로세스의 로컬 상태입니다.
- 서버가 여러 대면 공통 브로커로 이벤트를 fan-out해야 합니다.
- Pub/Sub의 실시간 전달과 저장소의 누락 복구는 역할이 다릅니다.
- 이벤트 ID와 클라이언트 offset으로 재생 경계의 중복을 제거합니다.
마지막 편에서는 서버가 아니라 네트워크 시간 차이를 다룹니다. 실시간 위치와 지연 대응에서 늦게 온 과거 좌표를 왜 버려야 하는지 확인합니다.
자료: Redis Pub/Sub messaging, Redis Pub/Sub use case, Socket.IO — Using multiple nodes

