V2 WebSocket 메시지 버스와 Redis 도입 설계 - 100-hours-a-week/KTB4-3rd-CLOUD GitHub Wiki
V2 WebSocket 메시지 버스와 Redis 도입 설계
- V1은 Spring WebSocket Simple Broker와 단일 backend 프로세스를 유지한다. 이 구조에서는 Redis 메시지 버스가 필요하지 않다.
- V2에서 Spring REST와 Spring WebSocket을 별도 프로세스·ECS Service로 분리할 때 Redis Pub/Sub를 채팅 이벤트의 서버 간 fan-out 경로로 도입한다.
- 채팅 메시지와 채팅방 이력의 원본은 MySQL이다. Redis Pub/Sub는 실시간 전달용이며 저장소나 전달 보장 큐로 사용하지 않는다.
- 일반 채팅 메시지와 REST 요청에서 생성되는 시스템 메시지를 같은 이벤트 계약과 Redis 채널로 전달한다.
Redis를 도입하는 이유는 연결된 WebSocket을 Redis로 옮기기 위해서가 아니다. 각 WebSocket 인스턴스가 보유한 로컬 연결에 다른 인스턴스 또는 REST 프로세스에서 발생한 채팅 이벤트를 전달하기 위해서다.
V1에서는 REST와 WebSocket이 하나의 Spring Boot 프로세스에서 동작한다. V1 혼합 부하 측정에서 WebSocket 1,642개 연결과 REST 요청을 함께 인가했을 때 조회 p95는 11.0ms에서 167.2ms로, p99는 33.4ms에서 914.8ms로 증가했고 조회 실패율은 4.54%였다. V2에서는 두 작업의 CPU·메모리·GC 영향을 분리하기 위해 REST와 WebSocket을 별도 실행 단위로 둔다. 측정 조건과 상세 결과는 V2 백엔드 애플리케이션 인프라 설계를 참고한다.
서비스를 분리하면 채팅 이벤트를 만든 프로세스와 수신 사용자의 WebSocket 연결을 보유한 프로세스가 달라질 수 있다. Spring Simple Broker는 구독 정보를 각 프로세스의 메모리에 보유하므로 프로세스 사이의 메시지 전달을 해결하지 못한다. 따라서 REST·WebSocket 분리와 WebSocket 다중 인스턴스 확장 시 공용 fan-out 경로가 필요하다.
이 문서는 다음 결정을 정리한다.
- REST와 WebSocket 실행 단위의 책임
- Redis를 도입하는 시점과 Pub/Sub 선택 근거
- MySQL, Redis, 각 WebSocket 인스턴스의 메시지 흐름
- Redis가 제공하지 않는 전달 보장과 운영 기준
현재 구현과 분리 시 차이
| 항목 | 현재 코드 | 분리 후 필요한 구조 |
|---|---|---|
| WebSocket 연결 | /api/wss STOMP endpoint |
WebSocket 전용 ECS Service로 라우팅 |
| 애플리케이션 경로 | /pub, 구독 경로 /sub |
클라이언트 경로와 STOMP 계약 유지 |
| 로컬 fan-out | enableSimpleBroker("/sub") |
각 WebSocket 프로세스 안에서는 계속 로컬 fan-out에 사용 |
| 일반 메시지 | ChatMessageSendService가 MySQL 트랜잭션으로 저장하고 ChatStompController가 해당 프로세스의 SimpMessagingTemplate으로 전송 |
저장 커밋 이후 공통 채팅 이벤트를 Redis에 발행하고 모든 WebSocket 프로세스가 로컬 구독자에게 전송 |
| 시스템 메시지 | ChatSystemMessageService가 DB 저장 뒤 ChatMessageCreatedEvent를 발행하고 ChatMessageBroadcaster가 해당 프로세스의 Simple Broker로 전달 |
REST와 WebSocket 어느 쪽에서 생성되더라도 같은 Redis fan-out 채널로 전달 |
| 채팅 이력 | MySQL messages와 채팅 REST API |
MySQL을 계속 기준 데이터로 사용 |
현재는 단일 프로세스의 Simple Broker를 설정한다. 일반 메시지는 ChatStompController에서 직접 로컬 브로커로 보내고, 시스템 메시지는 ChatMessageCreatedEvent 경로를 거친다. 분리 전에 두 메시지 흐름을 공통 이벤트 경로로 합쳐야 한다.
V2 인프라 설계는 별도 Spring Boot 프로세스와 ECS Service를 제안하지만, 현재 BE의 build.gradle은 Spring MVC와 WebSocket을 한 애플리케이션에 함께 포함하고 V1 Compose에는 backend 서비스가 하나다. ECS Service만 두 개로 나누면 두 인스턴스에 REST·WebSocket 기능이 그대로 함께 뜰 수 있다. 실제 자원 격리를 하려면 같은 저장소에서 REST와 WebSocket 실행 역할을 분리해 각 컨테이너가 필요한 endpoint만 등록하도록 해야 한다. 초기에는 Spring profile/조건부 설정으로 경계를 둘 수 있고, 역할별 조건이 복잡해지면 REST·WebSocket 실행 모듈을 나누되 채팅 도메인과 저장 모델은 공유한다.
3. 결정 사항
3.1 단계별 도입
| 단계 | 메시지 구조 | Redis 판단 | 근거 |
|---|---|---|---|
| V1 단일 backend 프로세스 | Spring Simple Broker → 같은 프로세스의 WebSocket 세션 | 도입하지 않음 | 다른 프로세스에 전달할 필요가 없고 운영 요소와 비용을 늘릴 근거가 부족하다. |
| V2 REST·WebSocket 분리 또는 WebSocket 2개 이상 실행 | MySQL 커밋 → Redis Pub/Sub → 각 WebSocket 프로세스의 로컬 Simple Broker | 도입 | REST에서 만든 시스템 메시지와 WebSocket에서 만든 일반 메시지를 어느 WebSocket 인스턴스의 구독자에게도 전달해야 한다. |
| 전달 재생·소비 확인이 요구되는 후속 단계 | MySQL outbox와 내구성 있는 메시징 경로 또는 STOMP 외부 브로커 | 요구사항과 부하를 확인한 뒤 검토 | Redis Pub/Sub만으로 메시지 보존·재생·소비 확인을 보장할 수 없다. |
3.2 Redis의 용도
Redis Pub/Sub는 애플리케이션 인스턴스 간 채팅 이벤트 전달에만 사용한다.
- Redis 채널 이름은 환경별 prefix를 포함한다. 예:
moyeota:prod:chat:fanout:v1,moyeota:dev:chat:fanout:v1. - REST와 WebSocket 프로세스는 DB에 채팅 메시지를 저장한 뒤 채널에 이벤트를 발행한다.
- 모든 WebSocket 프로세스는 공통 채널을 구독하고, 수신한 이벤트의
roomId를/sub/chat/{roomId}로 매핑해 자기 인스턴스의 Simple Broker로 전달한다. - 한 WebSocket 인스턴스에 연결된 사용자에게만 로컬 브로커가 최종 전송한다. Redis가 브라우저 연결이나 STOMP 구독 상태를 관리하지 않는다.
- 초기에는 공통 fan-out 채널 하나를 사용해 동적 구독 관리와 채널 생명주기를 단순하게 둔다. WebSocket 인스턴스 수나 메시지량이 늘어 노드별 불필요한 수신이 병목이 되면 방별 채널 또는 STOMP broker relay를 다시 비교한다.
Redis Pub/Sub는 메시지를 구독자에게 한 번 전달하거나 전달하지 못할 수 있는 at-most-once 방식이다. 구독자가 끊겨 있거나 처리에 실패하면 해당 Pub/Sub 메시지는 재생되지 않는다. 따라서 채팅 본문과 순서는 MySQL에서 복구하고, 클라이언트 재접속 시 기존 메시지 목록 API를 다시 조회한다. Redis Pub/Sub 전달 의미
3.3 비교한 선택지
| 선택지 | 장점 | 제약 | 판단 |
|---|---|---|---|
| Spring Simple Broker만 사용 | 현재 구현을 유지하고 추가 인프라가 없다. | 구독과 전송이 한 프로세스 안에 머문다. 여러 WebSocket 프로세스 간 fan-out이 되지 않는다. | V1 유지. V2 분리 구조에는 단독 사용 불가 |
| Redis Pub/Sub + 애플리케이션 fan-out | Spring Data Redis의 listener로 현재 /sub/chat/{roomId} 계약을 유지하면서 프로세스 간 전달을 구현할 수 있다. 구성 요소가 비교적 작고 일반 메시지와 시스템 메시지 경로를 하나로 모을 수 있다. |
Spring STOMP의 Redis 내장 broker relay가 아니므로 발행·수신 어댑터, 오류 처리, 메시지 계약을 애플리케이션에서 구현해야 한다. 보존·ACK·재생이 없다. | V2 기본 선택 |
| Redis Streams | 스트림에 이벤트를 보존하고 소비 확인 및 재처리를 설계할 수 있다. | WebSocket 노드 각각이 같은 이벤트를 받아야 하는 fan-out에서는 노드별 consumer group과 소비자 수명 관리가 필요하다. 재접속 클라이언트의 채팅 이력은 이미 MySQL에 있다. | 실시간 fan-out만을 위해 초기 도입하지 않음 |
| RabbitMQ 등 STOMP broker relay | Spring이 각 앱과 외부 STOMP broker를 연결하고 broker가 여러 앱 인스턴스의 구독 전달을 맡는다. STOMP 기능과 외부 broker 기능을 활용할 수 있다. | 별도 broker 운영·모니터링과 STOMP 연동 구성이 추가된다. 전달 보장과 저장 여부는 broker의 설정과 사용 방식으로 별도 결정해야 한다. | STOMP ACK/receipt, broker 측 destination 관리 등 요구가 생기면 재평가 |
Spring 문서는 Simple Broker가 clustering에 적합하지 않다고 설명하며, 다중 애플리케이션 인스턴스 간 전송에 외부 broker relay를 제시한다. 이 설계는 외부 STOMP broker 대신 Redis Pub/Sub를 선택하므로 각 WebSocket 인스턴스의 Redis listener와 로컬 STOMP fan-out을 연결하는 어댑터를 구현해야 한다. Spring 외부 broker 문서, Spring Data Redis listener 문서
4. 목표 구조와 메시지 흐름
사용자
├─ REST /api/** ──> ALB 경로 라우팅 ──> Spring REST ECS Service ──┐
│ ├─> MySQL
│ 메시지 커밋 후 발행 ───────┤
│ └─> Redis Pub/Sub
└─ STOMP /api/wss ─> ALB WebSocket target ─> Spring WebSocket ECS Service
│ ▲
├─> MySQL │ 구독
└─> Redis Pub/Sub ─────┘
│
모든 WebSocket 프로세스의 Redis listener가 이벤트를 수신
└─ roomId → 로컬 Simple Broker → 해당 노드의 클라이언트
ALB는 /api/wss Upgrade 연결을 WebSocket target group으로 보내고, 일반 /api/** 요청은 REST target group으로 보낸다. ALB와 Nginx는 연결 전달을 담당하고 JWT 검증, 채팅방 참여 권한, 메시지 저장과 fan-out은 Spring 애플리케이션이 담당한다. 단일 AZ 및 target 배치는 V2 백엔드 애플리케이션 인프라 설계의 네트워크 제약과 함께 결정한다.
일반 메시지와 시스템 메시지는 다음 순서로 처리한다.
- STOMP CONNECT에서 JWT를 확인하고, SUBSCRIBE에서 사용자의 활성 채팅방 참여 여부를 확인한다. Redis 사용은 이 인증·인가 절차를 대체하지 않는다.
- SEND를 받은 WebSocket 프로세스는 기존 채팅 도메인 서비스에 메시지 저장을 요청한다. 택시팟 참여·운행 등의 REST 처리는 REST 프로세스에서 시스템 메시지를 저장할 수 있다.
- MySQL 트랜잭션에서 메시지,
last_message_id, 필요한 읽음 위치를 반영하고 커밋한다. 기존clientMessageId멱등 처리를 유지한다. - 커밋 이후 두 경로 모두 공통
chat.message.created이벤트를 Redis fan-out 채널에 발행한다. 기존 시스템 메시지 이벤트 흐름을 일반 메시지에도 적용하고, 컨트롤러에서 직접 로컬 전송하는 경로는 제거해 이중 전송을 막는다. - 각 WebSocket 프로세스가 이벤트를 수신해 로컬 Simple Broker에
/sub/chat/{roomId}목적지로 전달한다. 해당 방을 구독 중인 로컬 세션만 이벤트를 받는다. - 클라이언트는 메시지
id를 기준으로 중복을 무시하고, 표시 순서는 서버 이력 API의 순서와 커서를 기준으로 맞춘다. 재접속하면 REST 메시지 목록 API로 놓친 메시지를 복구한다.
5. 이벤트 계약
초기 계약은 한 Redis 채널과 버전이 있는 JSON payload로 둔다. 클라이언트가 보내는 destination을 Redis 채널명으로 그대로 사용하지 않는다.
{
"event_type": "chat.message.created",
"schema_version": 1,
"event_id": "1452",
"room_id": 501,
"message": {
"id": 1452,
"type": "TEXT",
"content": "3분 뒤 도착합니다",
"createdAt": "2026-09-05T07:41:12"
}
}
event_id는 저장된message.id에서 만들고 동일 메시지 재발행에도 변하지 않게 한다. 이 값은 서버 로그·관측과 중복 제거의 상관키로 사용한다.message는 현재 WebSocket 응답 DTO인MessageItem을 기반으로 한다. 수신 노드가 메시지마다 MySQL을 다시 조회하지 않도록 fan-out에 필요한 데이터만 포함한다.room_id는 인증된 구독 destination과 서버가 구성하는 fan-out destination을 연결한다. Redis listener가 임의 destination을 실행하지 않는다.- JSON 계약을 바꾸면
schema_version을 올리고 구·신 WebSocket task가 함께 실행되는 배포 구간의 호환성을 확인한다. - Pub/Sub 수신 순서를 DB의 저장 순서로 간주하지 않는다. 클라이언트는 메시지 식별자와 이력 조회 결과로 중복·역순을 정리한다.
6. 전달 보장과 장애 처리
| 상황 | 기대 동작 | 복구 기준 |
|---|---|---|
| MySQL 저장 실패 | 이벤트를 발행하지 않는다. | 클라이언트 재시도와 기존 오류 계약 적용 |
| MySQL 커밋 성공, Redis 발행 실패 | 메시지는 이력에 보존되지만 일부 연결 사용자에게 실시간 표시가 누락될 수 있다. | 발행 실패 지표·로그를 남기고, 사용자는 재접속 또는 이력 재조회로 복구 |
| Redis 발행 성공, 일부 WebSocket listener 단절 | 단절된 listener가 이벤트를 놓칠 수 있다. | listener 재연결 후 정상 수신. 기존 연결은 이력 API로 누락분 복구 |
| 클라이언트 재전송 또는 동일 이벤트 재발행 | 같은 message.id 이벤트가 중복 도착할 수 있다. |
이벤트 ID와 메시지 ID를 기준으로 한 번만 표시 |
| WebSocket task 교체·장애 | 해당 task의 열린 연결은 끊기고 클라이언트는 재접속한다. | CONNECT 재인증, 방 재구독, MySQL 이력 재조회 |
| Redis 전체 장애 | DB 쓰기는 지속될 수 있으나 실시간 fan-out은 사용할 수 없다. | Redis 재연결·재구독 상태를 관측하고 이력 재조회를 안내. 저장 성공과 실시간 전달 성공을 혼동하지 않도록 전송 ACK 정책을 정한다. |
DB 커밋과 Redis 발행은 하나의 원자적 트랜잭션이 아니다. 초기 구조에서는 채팅 본문이 MySQL에 남고 실시간 누락은 이력 조회로 복구된다는 제품 동작을 전제로 Pub/Sub를 사용한다. DB 커밋 직후 프로세스가 종료되어 이벤트 자체가 발행되지 않는 경우까지 줄여야 하면 MySQL transactional outbox와 재시도 publisher를 추가한다. 단, outbox를 두어도 구독 중단 중의 Pub/Sub 메시지 재생이 생기는 것은 아니므로 구독자별 재생이나 확정적 소비 보장이 필요해지면 내구성 있는 broker/stream을 별도로 선택한다.
현재 일반 STOMP SEND handler는 DB 저장 후 브로드캐스트만 하고 명시적인 저장 ACK destination은 정의하지 않는다. Redis 전환 시에는 DB 저장 완료와 상대방 실시간 수신 완료를 구분하는 응답 계약을 확인한다. Pub/Sub 발행 실패만으로 이미 커밋된 메시지를 저장 실패로 응답하지 않도록 한다.
7. 최종 판단
V1은 단일 프로세스와 Simple Broker를 유지한다. V2에서 REST와 WebSocket을 별도 Spring Boot 실행 단위로 나누는 시점에 관리형 Redis Pub/Sub를 공통 채팅 fan-out 경로로 추가한다. 각 앱은 메시지를 MySQL에 커밋한 뒤 이벤트를 발행하고, 모든 WebSocket task는 이벤트를 받아 로컬 Simple Broker를 통해 자기 연결에 전달한다. MySQL은 메시지 원본과 재접속 복구 기준으로 남는다.
Redis Pub/Sub는 수신 장애 중 이벤트 재생을 보장하지 않는다. 따라서 초기에 이를 내구성 메시징으로 설명하지 않고, 메시지 이력 조회로 복구할 수 있는 실시간 알림 경로로 운영한다. 사용자 요구가 저장 ACK 이후의 반드시 전달, 소비자별 재처리, 오프라인 재생으로 바뀌면 transactional outbox와 durable broker/stream을 포함해 구조를 다시 결정한다.