본문 바로가기
  • Adillete
[트러블슈팅]

[오늘의 옷장] FCM 스레드 풀 고갈 2차해결 — 비정상 토큰 유입 시 전체 서비스 응답 불가

by 아딜렛 2026. 5. 22.

FCM 스레드 풀 고갈 — 비정상 토큰 유입 시 전체 서비스 응답 불가

문제: 페이크 FCM 토큰을 부하테스트에 추가했더니 Firebase SDK의 블로킹 호출이 스레드 풀을 점유해 전체 서비스 응답이 불가 상태가 됐다.

추가 증상: 브로드캐스트 완료 후 total 값이 0으로 찍히고, 실패 건수도 집계되지 않아 장애 발생 여부 자체를 확인할 수 없었다.


해결 과정

1
1차 대응 — 타임아웃 적용
SDK 레벨(.get(5, TimeUnit.SECONDS))과 Reactor 레벨(.timeout()) 이중 타임아웃을 적용해 스레드 점유는 해소, 서비스 응답은 복구됐다.
그러나 알림이 제대로 전송되지 않는 문제는 지속됐고, 실패가 얼마나 발생하는지 수치로 확인할 수 없었다.
2
Prometheus 메트릭 도입 — 이상 징후 포착
전송 시도·성공·실패 건수를 메트릭으로 수집하기 시작했다.
3
메트릭 분석 — 실패 카운트 미증가 확인
비정상 토큰으로 전송 시도가 발생하는데 실패 카운트가 오르지 않았다. 로그 분석으로 원인을 특정했다.
4
원인 특정 — 오류가 비동기 흐름 밖에서 소멸
Firebase 라이브러리 호출이 Reactor 파이프라인 밖에서 실행되고 있어 예외가 파이프라인으로 전파되지 않고 그냥 사라졌다. 실패로 처리되지 않으니 카운터도 오르지 않고, total 값도 0이 됐다.
5
재설계 — Firebase 호출을 비동기 흐름 안으로 연결
Mono.fromCallable()로 Firebase 호출을 파이프라인 안에 포함시켜 예외가 정상 경로(Mono.error())로 전파되도록 재설계했다.

도입한 Prometheus 메트릭

# 1. 현재 처리 중인 메시지 수 (게이지) broadcast_inflight_messages # 2. 전송 시도 건수 (누적 카운터) broadcast_attempt_total # 3. 성공 건수 (누적 카운터) broadcast_success_total # 4. 실패 건수 (누적 카운터) broadcast_failed_total # 5. P95 레이턴시 histogram_quantile(0.95, rate(broadcast_message_duration_seconds_bucket[5m])) # 6. MongoDB 커넥션 풀 대기 확인 mongodb_driver_pool_waitqueuesize # 7. TPS 측정 rate(broadcast_success_total[1m])

원인 상세 — 오류가 Mono 파이프라인 밖에서 소멸

문제의 핵심은 Firebase 호출이 Reactor 파이프라인 바깥에 있어 예외가 파이프라인으로 들어오지 못했다는 점이다.

문제 구조

// 파이프라인 밖에서 실행 → 예외가 Mono.error()로 변환되지 않고 소멸 return Mono.just(subscription) .flatMap(sub -> { sendToFirebase(sub); // 여기서 예외 발생해도 파이프라인이 모름 return Mono.empty(); });

해결 구조 — fromCallable로 파이프라인 안에 포함

// Callable<T>는 throws Exception이 선언되어 있어 // checked exception이 발생해도 Reactor가 Mono.error()로 자동 변환 return Mono.fromCallable(() -> { return FirebaseMessaging.getInstance() .sendAsync(message) .get(5, TimeUnit.SECONDS); // JoseException, GeneralSecurityException 등 }) .subscribeOn(Schedulers.boundedElastic()) .doOnSuccess(result -> successCounter.incrementAndGet()) .doOnError(e -> failedCounter.incrementAndGet()); // 이제 실패가 정상 집계됨

브로드캐스트 결과 집계

return Mono.when(webPushBroadcast, fcmBroadcast) // webPush 브로드캐스트 완료 AND fcm 브로드캐스트 완료 — 둘 다 끝날 때까지 대기 .thenReturn(new BroadcastStats( wpTotal.get() + fcmTotal.get(), // 전체 발송 대상 수 wpSuccess.get() + fcmSuccess.get(), // 성공 수 wpFailed.get() + fcmFailed.get(), // 실패 수 wpGone410.get() // 만료된 구독 수 )) .doOnSuccess(stats -> log.info( "BroadcastStats: total={}, success={}, failed={}", stats.total(), stats.success(), stats.failed() ));

fromCallable로 감싼 후에야 예외가 파이프라인 안으로 들어와 failedCounter가 정상적으로 증가하고, total 값도 올바르게 집계됐다.


결과

항목 개선 전 개선 후
비정상 토큰 유입 시 스레드 풀 고갈 → 전체 서비스 응답 불가 해당 요청만 타임아웃 처리, 서비스 계속 운영
실패 건수 집계 0 (파이프라인 밖에서 소멸) 정상 집계
total 값 0 실제 발송 대상 수 정상 반영
장애 감지 불가 (메트릭 없음) Prometheus로 실시간 추적
  • 정상 토큰 1개 + 비정상 토큰 1,000개 혼합 부하테스트에서 서비스 중단 없이 장애 격리 구조 검증 완료
  • 실패 건수 정상 집계 및 브로드캐스트 결과 실시간 추적 체계 수립
  • WebPush 블로킹 I/O 비동기 전환 및 만료 구독 정리로 처리 시간 82초 → 5초 (94% 단축)