MeetBack 2026.08.30 7 min read

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

Swarm의 JVM별 SimpleBroker 사이를 연결할 Redis Pub/Sub 모듈을 구현했다. 로컬 우선 전달과 중복 억제, 테스트 범위, 아직 연결하지 못한 기능을 정리했다.

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

Spring Boot를 하나만 실행할 때는 SimpleBroker로 채팅과 투표의 STOMP 이벤트를 전달할 수 있었다. Swarm에서 App 복제본을 늘리려면 다른 JVM에 연결된 사용자에게도 이벤트를 보내야 했다.

사용자 A가 복제본 1에, B가 복제본 2에 연결돼 있다고 가정했다. 1번의 SimpMessagingTemplate이 로컬 SimpleBroker에 보낸 메시지는 2번 사용자에게 전달되지 않는다. Swarm VIP는 연결을 분산하지만 JVM별 브로커 상태는 공유하지 않기 때문이다.

기존 WebSocket과 SimpleBroker를 유지한 채 복제본 사이에 이벤트를 전달하는 Redis Pub/Sub 공통 모듈을 구현했다.

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

전체 흐름은 다음과 같다.

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

브라우저와의 WebSocket 연결, STOMP 목적지는 기존 Spring 구성이 담당한다. Redis는 한 JVM에서 발생한 업무 이벤트를 다른 JVM으로 전달하는 데 사용했다.

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

채팅, 투표, 참여자, 강퇴 코드를 한꺼번에 바꾸면 기능 담당자들의 작업이 충돌할 수 있었다. 업무 코드에서 호출할 인터페이스와 전송 계층을 먼저 공통 모듈로 분리했다.

모듈은 아래 구성으로 나눴다.

  • RealtimeEvent: 업무 이벤트와 전달 방식
  • RealtimeEnvelope: Redis로 보낼 공통 전송 형식
  • RealtimeEventPublisher: 업무 코드의 호출 지점
  • JSON codec과 Redis 메시지 리스너
  • LocalRealtimeDispatcher: 현재 JVM의 STOMP 전달
  • LocalSessionControl: 강퇴·인증 무효화 때 실제 세션 제어와 연결할 SPI

클라이언트가 받을 내용과 복제본 간 전달에 필요한 정보가 달라 이벤트와 전송 형식을 나눴다. 이벤트 ID, 발신 인스턴스 ID, 전달 유형은 Redis 전파에 필요하지만 기존 프론트엔드 응답에 넣을 값은 아니었다. 각 복제본은 전송 형식을 해석한 뒤 clientPayload만 기존 토픽에 전달하도록 했다.

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

현재 JVM에 먼저 전달하고 Redis로 발행하는 순서로 구현했다.

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

Redis 연결이 끊겨도 현재 복제본에 연결된 사용자에게는 메시지를 보낼 수 있도록 했다. 반환 객체에는 로컬 전달 결과와 Redis 발행 결과를 나눠 담았다.

Redis가 멈추면 다른 복제본의 사용자는 이벤트를 놓칠 수 있다. Pub/Sub은 메시지를 저장하지 않으므로 복구 후 누락분을 재생하지도 않는다. local-first에서는 현재 JVM의 전달과 Redis 전파가 각각 성공했는지 구분할 수 있는 범위까지 제공했다.

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

Redis로 발행한 메시지는 발신 인스턴스에도 돌아올 수 있다. 이미 로컬로 전달한 메시지를 구독자가 다시 처리하면 같은 사용자에게 두 번 전달된다.

전송 정보에 eventId와 sourceInstanceId를 넣고 로컬 전달에 성공한 ID를 잠깐 보관했다. 발신 인스턴스가 자신이고 성공 기록이 남아 있으면 이미 전달한 메시지로 판단해 생략했다.

로컬 전달에 실패했다면 성공 표식을 남기지 않았다. Redis에서 자기 메시지를 다시 받았을 때 로컬 전달을 한 번 더 시도할 수 있게 했다. Redis 발행 자체가 실패하면 남은 표식을 제거했다.

이 처리는 흔한 중복을 줄이는 best-effort 방식이다. 분산 환경 전체에서 exactly-once나 전역 메시지 순서를 보장하지는 않는다.

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

강퇴 결과를 방에 알린 뒤에는 각 JVM이 자신에게 연결된 대상 사용자의 WebSocket 세션을 닫아야 한다. 새 로그인으로 tokenVersion이 달라졌을 때도 이전 세션을 찾아 종료해야 했다.

Redis 모듈에서 WebSocketSession 목록까지 관리하면 실제 연결을 가진 코드와 책임이 겹쳤다. LocalSessionControl SPI에는 종료할 사용자와 세션을 전달하는 역할만 두고, 연결을 소유한 코드가 구현하도록 나눴다.

이 글의 구현 시점에는 main에 해당 SPI의 구체 Bean이 없었다. 방송용 공통 모듈은 만들었지만 실제 세션 종료까지 연결된 상태는 아니었다.

테스트가 증명한 범위

이벤트 규약, 로컬 dispatcher의 호출 순서, STOMP 목적지와 전달 데이터, publisher 실패 경로를 테스트했다. JSON 왕복 변환, 자기 메시지 재수신, 채널 검증과 활성 설정의 Bean 생성도 확인했다. 작업과 병합 과정에서 전체 테스트와 diff 검사를 통과했다.

단위·컨텍스트 테스트 외에 실제 환경에서 더 확인해야 할 항목은 아래와 같았다.

  • 실제 Redis 서버와의 연결 단절
  • Spring Boot 두 인스턴스 사이 이벤트 전달
  • Swarm VIP를 통과하는 실제 클라이언트 연결
  • WebSocket 세션의 물리적 종료
  • 채팅·투표·강퇴 코드와 publisher 연동
  • 부하 상황의 메시지 순서와 중복
  • Redis 재시작 후 재구독
  • 전체 인스턴스의 Presence 상태

현재 main의 정확한 상태

공통 모듈은 main에 병합했다. 다만 기존 기능 코드에는 RealtimeEventPublisher.publish() 호출이 아직 연결되지 않았고, SimpMessagingTemplate.convertAndSend()로 직접 전송하는 곳도 남아 있었다. 따라서 구현 완료 범위는 공통 모듈까지이며 모든 실시간 기능의 복제본 간 동기화를 완료한 것은 아니었다.

Presence는 JVM의 HashMap에 남아 있었다. 접속·종료 알림을 전달하는 것만으로는 각 서버의 현재 상태가 계속 일치한다고 보기 어렵다. Redis key/set, 세션 TTL, heartbeat, 비정상 종료 정리와 다중 탭 연결 수를 다루는 설계가 별도로 필요했다.

다음 배포 단계에서는 Redis 컨테이너 하나를 두고 App 복제본 두 개 사이의 전달을 시험할 계획이었다. 이 구성이 단일 장애점이라는 점은 남는다. Sentinel 등 HA 구성은 실제 운영 요구와 작업 범위를 보고 추가 검토하기로 했다.

기존 프론트엔드 응답 형식을 유지하면서 기능 담당자가 순서대로 연결할 인터페이스를 마련했다. 로컬 전달과 Redis 발행 결과도 구분했다. 이어서 할 일은 직접 방송하는 코드를 publisher로 옮기고 두 복제본의 WebSocket 동작과 세션 종료를 실제로 시험하는 것이었다.

SEARCH JOURNAL

기록 검색

글 제목, 요약, 태그에서 찾습니다.

검색을 준비하고 있어요.

검색어를 입력해 주세요.

    Ctrl/⌘ K 검색 Esc 닫기 ↑ ↓ 결과 이동