[1탄] 멀티인스턴스 환경을 고려한 구독 알림 구조 개선
- [1탄] 멀티인스턴스 환경을 고려한 구독 알림 구조 개선
- [2탄] Consumer가 죽으면 메시지는 어디 있을까
- [3탄] SSE 알림, 왜 가끔 안 올까 — Redis Pub/Sub으로 멀티 인스턴스 브로드캐스트 구현
개요
이전 글에서 Spring Event와 SSE를 이용해 구독 알림 시스템을 구현했다. 당시에는 단일 애플리케이션 환경을 전제로 한 구조로 구현하였는데, 이번 글에서 멀티 인스턴스 환경에서도 동작할 수 있도록 알림 이벤트 처리 구조를 개선한 과정을 정리하려고한다.
문제점
1. Spring Event는 단일 JVM 내부에서 동작하는 이벤트 시스템이다.
- 이벤트가 애플리케이션 내부에서만 전달되기 때문에 서버가 여러 인스턴스로 늘어나는 환경에서는 이벤트를 공유할 수 없다.
2. 이벤트가 메모리 기반으로 처리된다.
- 리스너 처리 중 예외가 발생하면 메시지가 유실될 수 있다.
위의 문제를 해결하기 위해 이벤트를 외부 메시지 시스템에 저장하고 처리하는 구조로 변경하기 위해 메시지 브로커를 도입하기로 결정했다.
메시지 브로커 비교
메시지 브로커로는 Kafka, RabbitMQ, Redis Streams 등을 후보로 두고 비교했다. 프로젝트의 규모와 현재 인프라 환경을 고려했을 때, 이번 알림 시스템에는 Redis Streams가 가장 적합하다고 판단해 선택했다.
| 기술 | 메시지 모델 | 메시지 저장 방식 | 멀티 인스턴스 처리 | 운영 복잡도 | 이번 프로젝트에서의 판단 |
|---|---|---|---|---|---|
| Kafka | 로그 기반 스트림 | 디스크 로그에 장기간 저장 | Consumer Group 기반 분산 처리 | 높음 | 알림 이벤트 규모 대비 운영 부담이 큼 |
| RabbitMQ | 전통적인 메시지 큐 | 큐 기반 저장 (ACK 후 삭제) | Consumer 분산 처리 가능 | 중간 | 이벤트 로그 기반 구조와는 맞지 않음 |
| Redis Streams | 로그 기반 스트림 | Redis Stream 로그 저장 | Consumer Group 지원 | 낮음 | 기존 Redis 인프라 활용 가능 |
Before 구조
1
2
3
4
5
6
7
8
9
10
11
사용자 구독 요청
│
▼
구독 서비스 (구독 DB 저장 및 스프링 이벤트 발행)
│
▼
구독 이벤트 리스너(알림 DB 저장 및 알림 이벤트 발행)
│
▼
알림 이벤트 리스너 (SSE 전송)
- JVM 내부에서 이벤트가 동작하기 때문에, 멀티 환경에서 이벤트 공유 X
- 리스너에서 오류 발생 시 이벤트 사라짐
After 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
사용자 구독 요청
│
▼
구독 서비스 (구독 DB 저장 및 스프링 이벤트 발행)
│
▼
구독 이벤트 리스너(Redis Stream에 이벤트 기록)
│
▼
Reids Consumer (알림 DB 저장 및 이벤트 발행)
│
▼
알림 이벤트 리스너 (SSE 전송)
- Redis Stream을 통해 모든 인스턴스가 동일한 이벤트를 소비할 수 있어 멀티 인스턴스 환경에서도 안정적으로 알림 처리 가능
기존 구조는 이벤트가 애플리케이션 내부에서만 전달되는 구조였고, 변경된 구조는 이벤트를 Redis Stream에 기록하고 여러 인스턴스가 이를 소비하는 구조로 변경되었다.
Spring Event를 함께 사용한 이유
After 구조를 보면 Redis Streams를 도입하고도 Spring Event를 사용한다.
그 이유는 트랜잭션 커밋 이후에 이벤트를 처리하기 위해서이다.
예를 들어 아래와 같은 상황이 발생할 수 있다.
- 구독 DB 저장
- Redis Streams 이벤트 기록
- 트랜잭션 실패 (롤백)
이 경우 DB에는 구독 정보가 없지만, 알림 이벤트는 이미 발행된 상태가 된다.
이 문제를 방지하기 위해 @TransactionalEventListener를 사용해
트랜잭션이 커밋된 이후에만 이벤트를 발행하도록 구성했다.
1
2
3
4
5
6
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(SubscribedEvent event) {
log.info("구독 이벤트 수신 → Redis Stream 발행 - subscriberId={}, targetId={}",
event.subscriberId(), event.targetId());
streamPublisher.publish(event.subscriberId(), event.targetId());
}
마무리
이번 글에서는 단일 JVM 환경을 전제로 구현했던 구독 알림 시스템이 멀티 인스턴스 환경에서 왜 문제가 되는지, 그리고 Redis Streams 도입으로 어떻게 구조를 개선했는지 정리했다.
다음 글에서는 Redis Streams 운영 관점에서의 신뢰성 설계, 즉 Consumer 처리 실패 시 재처리 전략과 Dead Letter 처리를 다룰 예정이다.