MeetBack 2026.08.30 7 min read

Docker Swarm VIP만으로 해결되지 않은 WebSocket 이벤트 공유

Swarm VIP만으로 공유되지 않는 JVM 로컬 SimpleBroker 이벤트를 Redis Pub/Sub으로 fan-out하는 local-first 공통 모듈과 현재 한계를 정리했다.

프로젝트 기간: 2026-08-18 ~ 2026-09-02
글 범위: Docker Swarm 다중 Replica용 Redis Pub/Sub 공통 모듈

Spring Boot 애플리케이션을 한 개 실행할 때는 Spring의 SimpleBroker만으로 채팅과 투표 같은 STOMP 이벤트를 전달할 수 있다. 하지만 Docker Swarm에서 애플리케이션 replica를 여러 개로 늘리면 문제가 달라진다.

사용자 A의 WebSocket 연결이 replica 1에 있고 사용자 B가 replica 2에 있다면, replica 1의 SimpMessagingTemplate이 로컬 SimpleBroker에 보낸 메시지는 replica 2의 사용자에게 자동으로 전달되지 않는다. Swarm VIP가 두 연결을 분산해 줘도 각 JVM의 broker 상태까지 공유하지는 않기 때문이다.

나는 기존 WebSocket과 SimpleBroker를 유지하면서 replica 사이의 이벤트만 Redis Pub/Sub으로 fan-out하는 공통 모듈을 구현했다.

Redis가 WebSocket을 대체하는 구조는 아니다

전체 흐름은 다음과 같다.

업무 기능
  → RealtimeEventPublisher
  → 현재 replica의 local SimpleBroker
  → Redis Pub/Sub
  → 다른 replica의 subscriber
  → 해당 replica의 local SimpleBroker
  → 그 replica에 연결된 WebSocket client

Redis는 브라우저와 직접 통신하지 않는다. WebSocket 연결과 STOMP destination은 기존 Spring 구성이 그대로 담당한다. Redis의 역할은 한 JVM에서 발생한 업무 event를 다른 JVM으로 전달하는 것이다.

기능 코드보다 공통 계약을 먼저 만들었다

채팅, 투표, 참여자, 강퇴 코드를 한 번에 수정하면 담당자 사이의 충돌이 커질 수 있었다. 그래서 먼저 업무 기능이 사용할 interface와 전송 계층을 독립된 공통 모듈로 만들었다.

핵심 구성은 다음과 같다.

  • RealtimeEvent: 업무 사건과 delivery 의미
  • RealtimeEnvelope: Redis에서 전달할 공통 봉투
  • RealtimeEventPublisher: 업무 코드가 호출할 진입점
  • JSON codec과 Redis message listener
  • LocalRealtimeDispatcher: 현재 JVM의 STOMP 전달
  • LocalSessionControl: 강퇴·인증 무효화 시 실제 session 제어를 연결할 SPI

업무 event와 Redis envelope를 분리한 이유는 client가 받아야 할 payload와 인프라가 필요한 metadata가 다르기 때문이다. event ID, source instance ID, delivery kind 같은 정보는 replica 사이 전파에 필요하지만 기존 frontend payload에 섞여서는 안 된다. 각 replica는 envelope를 받은 뒤 clientPayload만 기존 topic으로 전달한다.

local-first로 장애 경계를 나눴다

Publisher는 현재 JVM에 먼저 전달하고 그다음 Redis에 publish한다.

1. event를 envelope로 변환
2. 현재 JVM의 local broker에 전달
3. 성공한 event ID를 임시로 기록
4. Redis channel로 publish
5. 다른 replica가 수신해 자기 local broker로 전달

Redis 연결이 끊기더라도 현재 요청을 처리한 replica의 사용자에게 보낼 수 있는 메시지까지 무조건 실패시키고 싶지 않았다. 그래서 local 결과와 Redis publish 결과를 분리한 결과 객체를 반환하도록 했다.

이 선택이 Redis 장애에도 서비스 전체가 정상이라는 뜻은 아니다. Redis가 멈추면 다른 replica의 사용자는 그동안의 event를 놓친다. Pub/Sub은 비영속이므로 복구 뒤 누락 메시지를 재생하지도 않는다. local-first가 보장하는 것은 현재 replica의 로컬 전달 실패와 Redis 전파 실패를 같은 실패로 묶지 않는다는 점이다.

자기 echo는 best-effort로 억제했다

Redis Pub/Sub은 발행한 instance에도 메시지를 돌려줄 수 있다. local-first로 이미 전달한 event를 자기 subscriber가 다시 처리하면 같은 client가 메시지를 두 번 받을 수 있다.

이를 줄이기 위해 envelope에 eventIdsourceInstanceId를 넣고, 로컬 전달에 성공한 event ID를 짧게 기억한다. 같은 instance에서 돌아온 메시지이고 marker가 존재하면 이미 로컬 처리한 echo로 판단해 생략한다.

반대로 로컬 전달이 실패했다면 marker를 만들지 않는다. 이후 Redis 자기 echo가 돌아왔을 때 한 번 더 로컬 전달을 시도할 수 있다. Redis publish 자체가 실패하면 남은 marker를 제거한다.

이것은 일반적인 중복을 줄이는 best-effort 방식이다. 분산 환경 전체의 exactly-once나 전역 순서를 보장하는 구조는 아니다.

강퇴와 로그인 교체는 session 소유자에게 맡겼다

강퇴 event는 방에 결과를 방송한 뒤 각 replica가 자기 JVM에 있는 대상 사용자의 WebSocket session을 닫아야 한다. 새 로그인으로 tokenVersion이 바뀌었을 때도 오래된 session을 찾아 종료해야 한다.

하지만 Redis 공통 모듈이 실제 WebSocketSession registry까지 소유하면 업무 경계가 뒤섞인다. 나는 “누구의 어떤 session을 닫아 달라”는 LocalSessionControl SPI만 정의하고, 실제 session 소유 코드가 구현하도록 분리했다.

현재 main에는 이 SPI의 구체 Bean이 없다. 따라서 방송을 위한 공통 경계는 있지만 물리 session 종료까지 완료됐다고 말할 수 없다.

테스트가 증명한 범위

Redis 모듈에는 event 계약, local dispatcher 순서, STOMP destination과 payload, publisher 장애 경로, JSON 왕복, 자기 echo와 channel 검증, 활성 설정의 Bean 생성 등을 확인하는 테스트를 작성했다. 작업과 병합 과정에서 전체 테스트 통과와 diff 검사를 확인했다.

하지만 단위·context 테스트가 다음 내용을 증명하지는 않는다.

  • 실제 Redis server와 네트워크 단절
  • Spring Boot 두 instance 사이의 fan-out
  • Docker Swarm VIP를 통한 실제 client 연결
  • 물리 WebSocket session 종료
  • 채팅·투표·강퇴 기능의 실제 publisher 연동
  • 부하 상황의 메시지 순서와 중복
  • Redis 재시작 뒤 재구독
  • 전역 Presence

현재 main의 정확한 상태

공통 모듈은 main에 병합됐지만, 기존 기능 코드에는 아직 RealtimeEventPublisher.publish() 호출이 연결되지 않았다. 직접 SimpMessagingTemplate.convertAndSend()를 사용하는 지점도 남아 있다. 따라서 “Redis 기반 실시간 공통 기반을 구현했다”는 표현은 맞지만 “모든 실시간 기능이 다중 replica에서 동기화된다”는 표현은 맞지 않는다.

Presence도 아직 JVM의 HashMap에 있다. Pub/Sub으로 접속·종료 알림만 퍼뜨려서는 전역 현재 상태의 단일 진실 공급원이 되지 않는다. Redis key/set, session TTL, heartbeat, 비정상 종료 정리, 다중 탭 연결 수를 포함한 별도 설계가 필요하다.

배포 단계에서는 우선 단일 Redis container로 실제 두 replica fan-out을 확인할 계획이다. 현재 목표에는 단순한 구성이 맞지만 단일 장애점이라는 한계가 있다. Sentinel이나 다른 고가용성 구조는 실제 운영 요구와 시간 범위를 보고 결정해야 한다.

이 구현에서 가장 중요했던 것은 Redis를 넣었다는 사실보다 보장하지 않는 것을 명확히 한 점이었다. 기존 frontend 계약을 유지하고, 기능 담당자가 점진적으로 연결할 interface를 만들고, local 전달과 Redis 장애를 분리했다. 다음 단계는 실제 기능의 직접 방송을 publisher로 교체하고, 두 replica WebSocket E2E와 session control을 검증하는 것이다.