0. 개요
해당 글은 < 대용량 알림 개선기 (1) > 에 이어지는 글입니다
이전 개선기에서는
< FirebaseMessaging 자체의 sendAsync > +
< Spring 내 비동기 환경 @Async("alarmExecutor") > +
< Custom Thread Pool >
구조를 가져가면서 CPU 사용량을 안정화시키고 알람 처리 속도를 5분대로 낮춰보았었습니다.
하지만 요청이 한 번에 몰리면 FCM 전송 병목이 발생할 수 있다는 문제점이 있어
RabbitMQ로 메시지를 큐잉하고,
Consumer를 병렬로 띄워서 비동기 전송 처리를 통해 시스템 부하를 분산시키고 안정성을 높여보겠습니다.

1. Kafka vs RabbitMQ, 왜 RabbitMQ를 선택했는지
Kafka는 Append-Only Log 기반 스트리밍 플랫폼으로, 대규모 이벤트 로그 수집, 분석 파이프라인 구축 등에 적합하게 설계되었습니다.
- 메시지를 디스크에 저장하고 유지하며, 데이터를 소비하는 속도보다 생성 속도가 빠른 환경에 최적화되어
- 일반적으로 수백만 건의 데이터를 순차적으로 소비하는 상황에 더 강합니다.
RabbitMQ는 Message Broker 시스템으로, 실시간 처리와 빠른 큐잉 → 소비 구조에 특화되어 있습니다.
- 메시지는 기본적으로 RAM 우선 저장 (물론 디스크 백업도 가능) → 처리 속도가 매우 빠르고
- 현재와 같이 순간적으로 몰리는 알림과 같이 메시지 수명이 짧고 신속하게 전달·소비돼야 하는 상황에 유리합니다.
이와 같이 RabbitMQ는 실시간 응답성, 빠른 큐잉 및 분산 처리 등의 측면에서
순간적으로 몰리는 알림을 빠르게 소화해야 하는 현재 상황과 같은 알림 시스템에 더 적합하다고 판단했습니다!
2. RabbitMQ 메세지 큐 도입
@RabbitListener(queues = "${rabbitmq.queue.name}", concurrency = "10")
public void consumeFcmMessage(FcmTokenRequestDto dto) {
firebaseService.sendMessageTo(dto.getTargetToken(), dto.getTitle(), dto.getBody());
}
API 요청 → RabbitMQ로 메시지 전송
↓
@RabbitListener(concurrency = 10) // 10개 쓰레드 동시에 돌림
↓
@Async("alarmExecutor") // 또 다른 비동기 스레드 사용
↓
FirebaseMessaging.sendAsync() // 내부적으로 또 비동기
현재 3단 비동기 구조로
→ 메시지 10개만 들어와도
( 10개의 리스너 ) * ( 각 Async 실행 ) * ( 내부 sendAsync ) = 수십 개 쓰레드 폭주

→ CPU가 99%까지 치솟은 이유 !!
따라서 CPU 사용량을 50%대로 안정시켰던 < 대용량 알림 개선기 (1) > 에 비해
최대 사용량이 99%로 스파이크가 튀며
더 비효율적인 병목 현상이 일어나고 있음을 확인하게 되었습니다.
3. 개선안
3-1. RabbitListener 병렬성 제한
@RabbitListener(queues = "...", concurrency = "2")
- concurrency = 2 이하로 낮춰서 소비 속도 자체를 조절
- RabbitMQ에서 한꺼번에 너무 많은 메시지를 가져오지 않도록 제어
3-2. 완전히 동기 처리 (Spring @Async 제거, RabbitMQ의 소비 흐름에서만 처리하도록 단순화 )
장점으로는 스레드 낭비가 없고, 병렬 처리의 최소화로 인해 CPU 사용률이 안정적이라는 점이 있지만
단점으로는 메시지 처리 속도가 느려질 수 있으며, 처리량이 많을 경우 응답 지연이 발생할 수 있다는 점이 있습니다.
4. 3번 개선안을 통한 부하 테스트
public void sendMessage(String targetToken, String title, String body) {
try {
...
// 동기 전송
FirebaseMessaging.getInstance().send(message);
} catch (Exception e) {
log.error("Error sending message to {}: {}", targetToken, e.getMessage());
}
}


10만건 테스트 소요 시간
11:52:42 ~ 11:54:37 1분 42초

CPU 평균 사용량 : 24%
병렬을 제한한 후 10만건의 알림 처리 속도가 1분대로 내려오며 오히려 성능이 월등히 올라가고
CPU, 메모리 평균 사용량이 매우 안정화된 결과가 만족스러웠지만
CPU 최대 사용량 지표에서 반복되는 스파이크 현상이 나타났습니다.

첫 번째 케이스
- 11:48 → 1,000건 1차
- 11:49 → 1,000건 2차
- 11:50 → ❗CPU 100% 상승 시작
- 11:52:42 ~ 11:54:37 → 10만건 부하 테스트
두 번째 케이스
- 12:51 → 1,000건 1차
- 12:53 → 1,000건 2차
- 13:09 → ❗CPU 100% 상승 시작
- 13:12:16 ~ 13:13:58 → 10만건 부하 테스트

세 번째 케이스
- 13:52 → 1,000건 1차
- 13:54 → 1,000건 2차
- 14:10 → ❗CPU 73%까지 상승 시작
- 14:12 ~ 14:13 → 10만건 부하 테스트
세 번의 테스트를 진행하며 보이는 패턴 중,
RabbitMQ의 비동기 처리 특성상, RabbitMQ 큐 내부에서는 API 응답 기준으로 1분대의 빠른 처리 속도가 측정되지만
실제 알림 전송은 그 이후 RabbitMQ 리스너에서 처리되는데
이 과정에서 스레드 풀의 폭주와 Firebase 요청의 스파이크가 발생하면서 리소스 사용량이 급증하는 현상이 나타났다는 점입니다.
5. 최종 개선안
4번 부하 테스트에서 확인한 것처럼, 알림 요청이 집중될 경우 Firebase 전송 지점에서 CPU 스파이크 현상이 반복적으로 발생하고 있습니다.
따라서 전송 속도를 제어할 수 있는 구조로 개선하고자 BlockingQueue를 중간 버퍼로 두어
RabbitMQ로부터 수신한 메시지를 안정적으로 offer한 뒤,
정해진 속도로 poll하며 FCM 서버에 요청이 안정적으로 전송되도록 해보겠습니다!
클라이언트 HTTP 요청
↓
메시지를 RabbitMQ에 enqueue (즉시 응답 반환)
↓
RabbitListener가 메시지를 소비
↓
BlockingQueue에 메시지 push
↓
ExecutorService 스레드들이 BlockingQueue에서 메시지를 꺼냄
↓
RateLimiter로 속도 제어하며 Firebase로 동기 전송
✔ 5-1 @RabbitListener 설정
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(...) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
configurer.configure(factory, connectionFactory);
factory.setPrefetchCount(1);
factory.setConcurrentConsumers(1);
factory.setMaxConcurrentConsumers(2);
return factory;
}
@RabbitListener(queues = "${rabbitmq.queue.name}", containerFactory = "rabbitListenerContainerFactory")
public void consumeFcmMessage(FcmTokenRequestDto dto) {
firebaseService.enqueueMessage(dto);
}
- 하나씩만 천천히 소비하도록 해서 BlockingQueue에 적재되도록
- 소비 속도와 처리 속도를 분리해서 병목 방지
- @RabbitListener는 메시지를 Firebase로 직접 보내지 않고 내부 BlockingQueue에 넣어줌
→ 첫 번째 완충 구간
✔ 5-2 BlockingQueue + ExecutorService
private final BlockingQueue<FcmTokenRequestDto> queue = new LinkedBlockingQueue<>(5000);
private final ExecutorService executorService = Executors.newFixedThreadPool(5);
- 메시지 수신 속도 ~ 소비 속도 간의 갭을 완충하는 구조
- 5000개의 메시지까지만 담을 수 있게 제한
- 5개의 worker 스레드로 제한해서 queue에서 메시지 꺼내고 처리
✔ 5-3 RateLimiter 초당 전송 제한
private final RateLimiter rateLimiter = RateLimiter.create(80); // 초당 80건 제한
private void sendMessage(FcmTokenRequestDto dto) {
try {
if (rateLimiter.tryAcquire(10, TimeUnit.MILLISECONDS)) { // burst 완화
...
FirebaseMessaging.getInstance().send(message);
} else {
log.warn(...);
}
} catch (Exception e) {
log.error("Error sending message to {}: {}", dto.getTargetToken(), e.getMessage(), e);
}
}
- Firebase 자체 제한해서 초당 Firebase API 초당 속도 조절
- rateLimiter.tryAcquire()로 전송 타이밍 제어
✔ 5-4 전송 로직 (sendMessage)
private void sendMessage(FcmTokenRequestDto dto) {
if (rateLimiter.tryAcquire(10, TimeUnit.MILLISECONDS)) {
FirebaseMessaging.getInstance().send(message);
} else {
log.warn("Rate limit 초과 - {}", dto.getTargetToken());
}
}
현재와 같이 메시지 전송 시 동기 처리 send() 방식은
BlockingQueue + ExecutorService에서 메시지를 하나 꺼낼 때, 끝날 때까지 기다리게 되면서 처리 흐름이 직렬화되기 때문에
Firebase에 초당 80건 제한 같은 흐름을 정밀하게 맞추기가 용이해서
API 사용 한도를 지속적으로 초과하거나 트래픽이 폭주하는 상황을 방지할 수 있었습니다.
6. 5번 개선안에 대한 최종 부하 테스트
< 테스트 시나리오 >
- 21:52 ~ 21:55 / 3분 18초 / 10,000건
- 22:00 ~ 22:03 / 3분 25초 / 50,000건 (1회)
- 22:10 ~ 22:12:26 / 2분 10초 / 100,000건 (1회)
- 22:20 ~ 22:23 / 3분 27초 / 100,000건 (2회)
- 22:30 ~ 22:33 / 3분 26초 / 100,000건 (3회)

최대 CPU 사용률 0.347 / 34.7% 정도로
가장 걱정했던 너무 큰 스파이크 없이 안정적이고 일정한 전송 속도와 자원 사용률을 확인했습니다.
사실 30~40 퍼센트도 완벽히 안정적이라고 보기는 어렵지만 t2.mciro의 스펙을 감안해서 봐주시면 감사하겠습니다ㅎㅎㅎ


CPU 평균 사용량과 Memory 사용량도 안정적인 추이를 보이고 있습니다.

| GC 유형 | G1 Evacuation Pause (Minor GC) |
| 평균 시간 (Mean) | 0.305 ms |
| 최대 시간 (Max) | 2.6 ms |
| 최소 시간 (Min) | 0 ms |
| 총 GC 시간 (Total) | 73.6 ms (전체 테스트 시간 동안) |
해당 GC Count 지표에서도 GC로 인한 Stop-the-world 지연이 거의 없었고 괜찮은 처리 속도를 유지했기 때문에
안정적이라고 판단했습니다.
7. 마무리
이번 개선기에서는 RabbitMQ를 도입해보며 단순히 RabbitMQ 큐에 메시지를 넣는 것이 핵심이 아니라
큐에 쌓인 메시지를 얼마나 안정적이고 효율적으로 소비할 수 있는지가 중요하다는 점을 깨달았습니다..!
처음에 3단 비동기 처리 + 과도한 스레드 사용으로 CPU 사용률이 99%까지 치솟는 병목 현상이 당황스러웠지만
여러번의 테스트와 개선을 통해 BlockingQueue + 동기 전송 + RateLimiter 조합으로
다행히 안정적인 흐름으로 10만건의 알림들을 제어할 수 있게 되었습니다.
