FCM 스레드 풀 고갈 — 비정상 토큰 유입 시 전체 서비스 응답 불가
문제: 페이크 FCM 토큰을 부하테스트에 추가했더니 Firebase SDK의 블로킹 호출이 스레드 풀을 점유해 전체 서비스 응답이 불가 상태가 됐다.
추가 증상: 브로드캐스트 완료 후 total 값이 0으로 찍히고, 실패 건수도 집계되지 않아 장애 발생 여부 자체를 확인할 수 없었다.
해결 과정
1
1차 대응 — 타임아웃 적용
SDK 레벨(
그러나 알림이 제대로 전송되지 않는 문제는 지속됐고, 실패가 얼마나 발생하는지 수치로 확인할 수 없었다.
SDK 레벨(
.get(5, TimeUnit.SECONDS))과 Reactor 레벨(.timeout()) 이중 타임아웃을 적용해 스레드 점유는 해소, 서비스 응답은 복구됐다.그러나 알림이 제대로 전송되지 않는 문제는 지속됐고, 실패가 얼마나 발생하는지 수치로 확인할 수 없었다.
2
Prometheus 메트릭 도입 — 이상 징후 포착
전송 시도·성공·실패 건수를 메트릭으로 수집하기 시작했다.
전송 시도·성공·실패 건수를 메트릭으로 수집하기 시작했다.
3
메트릭 분석 — 실패 카운트 미증가 확인
비정상 토큰으로 전송 시도가 발생하는데 실패 카운트가 오르지 않았다. 로그 분석으로 원인을 특정했다.
비정상 토큰으로 전송 시도가 발생하는데 실패 카운트가 오르지 않았다. 로그 분석으로 원인을 특정했다.
4
원인 특정 — 오류가 비동기 흐름 밖에서 소멸
Firebase 라이브러리 호출이 Reactor 파이프라인 밖에서 실행되고 있어 예외가 파이프라인으로 전파되지 않고 그냥 사라졌다. 실패로 처리되지 않으니 카운터도 오르지 않고,
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% 단축)
'[트러블슈팅]' 카테고리의 다른 글
| [오늘의 옷장] FCM 무한대기 문제 1차해결 (0) | 2026.05.22 |
|---|---|
| [오늘의 옷장]nl.martijndwars.webpush의 410 Gone 응답 처리 (0) | 2026.05.22 |
| [Career Coach]AI Agent로 뉴스 검색용 키워드 생성한 효과 (0) | 2026.05.22 |
| [shoppay]결제 확정 후 DB 미저장 문제 — 네트워크 단절 시 결제 유실 (0) | 2026.05.22 |
| [CareerCoach프로젝트] 포트폴리오가이드 평가 시스템 최적화 (portfolio_standard 사용이유) (0) | 2026.05.22 |