실시간 채팅 시스템 및 알림의 멀티 인스턴스 문제, Redis Pub/Sub으로 해결하기
Communication 서비스 · WebSocket + STOMP + Redis
1. 들어가며 — 문제는 서버가 늘어나면서 시작됐다
Communication 서비스는 의료진과 환자 간 실시간 채팅을 WebSocket + STOMP로 처리한다. 개발 초기, 서버가 1대일 때는 모든 게 정상이었다. 메시지를 보내면 상대방이 받았고, 읽음 처리도 즉각 반영됐다.
문제는 서버를 2대 이상으로 늘리면서 발생했다. 서로 다른 서버에 연결된 사용자 사이에서 메시지가 전달되지 않는 구조적인 문제가 생겼다.
원인은 Spring의 SimpleBroker에 있었다. SimpleBroker는 JVM 메모리 안에서만 동작하는 내장 메시지 브로커다. 서버 1의 메모리 상태와 서버 2의 메모리 상태는 완전히 독립적이므로, 서버 1에서 발행한 메시지가 서버 2에 연결된 클라이언트에게 전달될 방법이 없었다.
2. 왜 Redis Pub/Sub을 선택했나
멀티 인스턴스 동기화를 위한 방법은 여러 가지가 있다. Kafka, RabbitMQ 같은 메시지 큐 도입도 옵션이었지만, 채팅 메시지 동기화라는 목적에는 오버스펙이었다.
| 항목 | Redis Pub/Sub | Kafka |
|---|---|---|
| 설정 복잡도 | 낮음 (이미 Redis 사용 중) | 높음 (별도 클러스터 필요) |
| 메시지 보관 | 없음 (실시간 전달만) | 있음 (디스크 저장) |
| 적합한 용도 | 실시간 브로드캐스트 | 이벤트 스트리밍, 재처리 |
| 우리 요구사항 부합 | ✓ 적합 | △ 과도한 복잡성 |
채팅 메시지는 '지금 이 순간' 전달이 목적이다. 브로커 차원의 재처리나 보관이 필요 없었다. 이미 서비스에서 Redis를 사용하고 있었기 때문에 추가 인프라 없이 도입 가능하다는 점도 결정적이었다.
3. 어떻게 연결했나 — 구조와 흐름
3-1. 전체 구조
Redis Pub/Sub은 모든 서버 인스턴스를 하나의 메시지 버스로 연결한다. 어느 서버에서 메시지를 발행하더라도, 구독 중인 모든 서버 인스턴스가 동시에 수신하고 각자 자신에게 연결된 클라이언트에게 전달한다.
3-2. 메시지 전송 6단계
메시지 하나가 전달되기까지 내부적으로 6단계가 순서대로 실행된다. DB 저장이 Redis 발행보다 반드시 먼저 이루어지는 것이 핵심 설계 원칙이다. Redis는 메시지를 보관하지 않기 때문에, 저장 전에 발행하면 장애 시 메시지가 영구 유실된다.
| 단계 | 처리 내용 |
|---|---|
| ① DB 저장 | PostgreSQL에 메시지 INSERT (Redis 발행보다 반드시 먼저) |
| ② 채팅방 갱신 | 마지막 메시지 업데이트 (목록 미리보기용, 50자 truncated) |
| ③ Redis 발행 | chat/{roomId} 채널로 브로드캐스트 |
| ④ 자동응답 | 환자 발신 시 5분 후 자동응답 예약 / 의료진 발신 시 취소 |
| ⑤ 비회원 경고 | 환자가 미가입 회원이면 시스템 경고 메시지 자동 발송 |
| ⑥ 알림 발송 | 상대방에게 푸시 알림 전달 |
4. 멀티 인스턴스 환경, 설계 단계에서 고민한 것들
Redis Pub/Sub 구조를 도입하면서 단순히 '메시지를 모든 서버에 뿌린다'는 것 외에, 멀티 인스턴스 환경 특성에서 비롯된 세 가지를 추가로 고려해야 했다.
① SseEmitter는 JVM 메모리에만 존재한다
SSE 알림용 SseEmitter는 DB가 아닌 각 서버 인스턴스의 ConcurrentHashMap에만 저장된다. SseEmitter는 TCP 연결 자체를 추상화한 객체라 직렬화해서 DB에 넣을 수 없기 때문이다.
이 구조에서 Redis 브로드캐스트를 받으면 서버 3대가 동시에 동일한 메시지를 수신한다. 이때 해당 userId의 Emitter를 자기 메모리에 가진 인스턴스만 실제로 SSE를 전송하고, 나머지는 Optional.empty()로 조용히 무시하도록 설계했다.
② 서버 재시작 시 Emitter 전체 소멸
Emitter가 JVM 메모리에만 존재하기 때문에, 서버가 재시작되면 연결 중이던 모든 Emitter가 사라진다. 재시작 후에는 클라이언트가 /connect를 다시 호출해야 알림을 받을 수 있다.
이 문제는 브라우저 SSE 자동 재연결 동작으로 완화해준다. 브라우저는 SSE 연결이 끊기면 자동으로 재연결을 시도하므로, 서버 재시작 후에도 클라이언트 측 별도 처리 없이 자연스럽게 재연결된다.
③ 실시간 전달 실패를 대비한 DB 저장 선행
Redis Pub/Sub은 메시지를 보관하지 않는다. SSE push가 실패하더라도 사용자가 알림을 놓치지 않도록, Redis 발행 전에 반드시 PostgreSQL에 먼저 저장하도록 순서를 고정했다. 클라이언트는 재접속 후 REST API로 미확인 알림을 조회해 누락분을 회복할 수 있다.
5. 마치며
SimpleBroker의 단일 JVM 한계라는 문제를 Redis Pub/Sub으로 해결하는 것 자체는 어렵지 않았다. 진짜 설계 난이도는 멀티 인스턴스 환경에서 발생하는 부수적인 문제들을 미리 파악하고 대응하는 데 있었다.
- Emitter의 메모리 특성 이해: 어느 인스턴스가 Emitter를 들고 있는지 모르기 때문에, 전체 브로드캐스트 후 각자 조용히 무시하는 구조가 자연스러운 해답이었다.
- 실시간 전달의 신뢰성 한계 인정: push 실패를 완전히 막을 수는 없으므로, DB 저장 선행과 REST 조회 보완을 통해 결과적 일관성을 보장하는 방향으로 설계했다.
Justin