본문 바로가기
AWS

전환 뒤에 켜진 알람들 — 새 문제가 아니라 원래 있던 것이 보인 것

by 꼼냥냥 2026. 9. 21.
반응형

새 구조로 넘긴 직후부터 오류 알림이 꾸준히 왔다. 전환 전 11일간 같은 오류가 0건이었으니 전환이 만든 것으로 보였고, 실제로 그렇게 보고 하루를 썼다. 결론부터 쓰면 둘 다 전환이 만든 것이 아니라 전환이 드러낸 것이었다. 하나는 3년간 감시 밖이었던 서비스, 하나는 옛 구조에서 다른 이름으로 기록돼 아무도 안 보던 장애다. 전환 전 "0건"은 정상의 근거가 아니다. 오래 조용한 것은 건강한 게 아니라 안 보이는 것일 수 있다.

사건 1 — 푸시 오류: 감시가 없었을 뿐이다

알림은 이렇게 왔다

com.google.firebase.messaging.FirebaseMessaging$3 - https://fcm.googleapis.com/...

서비스명 자리에 클래스명이 있고, 오류 자리에 URL만 있다. 상태코드도 예외 클래스도 없다.

인프라부터 의심했고, 인프라는 무죄였다

전환으로 바뀐 건 네트워크 경로와 실행 환경이니 거기부터 팠다.

  • jar 안의 서비스 계정으로 직접 OAuth 토큰 발급 → 200
  • validate_only 호출 → 정상
  • 실제 푸시 발송 → 단말 수신까지 확인
  • 아웃바운드 19ms, NTP 동기화 정상
  • 옛 jar과 코드가 바이트 단위로 동일 (FcmAPI.class 11,590 · FcmService.class 19,507 · firebase-admin 9.3.0 · 자격증명 json 동일)

인프라는 아무 문제가 없었다.

그런데 "전환 전 0건"은 왜였나

옛 서버가 조용했던 이유가 따로 있었다.

run.sh 에 -javaagent 는 붙어 있었다
-Dscouter.config → conf/agent.host/scouter.conf
                   ↑ 이 파일이 없다 (java agent 가 아니라 host agent 쪽 경로)

에이전트는 로드됐지만 설정을 못 읽어 수집기에 연결 시도조차 하지 않았다. 오류도 로그도 없이 3년간. 옛 릴레이가 받은 알림 862건이 전부 다른 두 서비스 발이고 이 서비스 발은 0건인 것이 증거였다.

-javaagent가 붙어 있다고 감시되는 것이 아니다. 수집기에 그 객체가 실제로 보이는지로 확인한다. 설정 파일 로딩 규칙이 컴포넌트마다 다르다는 것을 5편에서 적어 뒀는데, 그 함정이 여기서 3년짜리로 나타났다.

아침 결론이 오후에 뒤집혔다

validate_only로 토큰을 일괄 검증했다.

웹 토큰   1.8%  → 404 UNREGISTERED
앱 토큰  표본 500개 중 19.8% → 404 UNREGISTERED
400 INVALID_ARGUMENT  → 양쪽 다 0건

죽은 웹 토큰을 지웠다. 그런데 알림이 계속 왔다.

오후에 앱이 실제로 보내는 대상 전수를 실제 본문 그대로 validate_only로 던졌다 — 실패 0건. 200만 돌아왔다.

웹 토큰은 브라우저가 수시로 갱신한다.
죽은 것이 상시 섞였다 빠지므로, 알림이 뜬 지점을 뒤늦게 조회하면 언제나 깨끗하다.
→ 아침의 1.8% 는 그 순간의 스냅샷이었고, 원인이 아니었다.

진짜 원인: SDK가 실패를 삼키고 있었다

firebase-admin jar을 받아 javap로 뜯었다.

FirebaseMessaging$3 = sendOpForSendResponse
  sendEach / sendEachForMulticast 안의 토큰별 전송
  바이트코드에 예외 테이블이 있다
  → FirebaseMessagingException 을 잡아 SendResponse 로 바꾼다
  → 예외가 밖으로 안 나온다

APM 프로파일도 같은 이야기였다 — socket 24ms, call 135ms, elapsed 230ms, 예외 단계 없음. APM이 기록한 것은 예외가 아니라 나가는 HTTP 호출이다. 그래서 에러 텍스트가 URL뿐이었다.

그리고 앱은 이 실패를 전혀 모르고 있었다.

// 호출부가 반환값을 쓰지 않는다
sendEachForMulticast(message);        // BatchResponse 를 버린다
// → catch 도 안 탄다 → 앱 로그에 아무것도 없다

"알림은 오는데 앱 로그가 비어 있다"는 모니터링 오작동이 아니라 앱이 실패를 버리고 있다는 신호다.

부모 요청을 찾은 방법

백그라운드 스레드라 서비스명이 URL이 아니라 클래스명이다. 알림만으로는 어느 요청에서 왔는지 알 수 없어서, APM 알림의 초 단위 시각을 로드밸런서 액세스 로그의 time − target_processing_time 구간과 맞췄다.

17:17:15 · 17:19:33 · 17:21:25 · 17:23:37 · 17:44:36  → 부모 요청 4건 특정 (점포 4곳)

같은 구간에 웹 푸시 엔드포인트는 95분에 209건인데, 다른 전송 경로를 부르는 엔드포인트는 0건이었다. 경로가 하나로 좁혀졌다.

필터를 접었다가 다시 넣었다

아침에는 "릴레이에서 이 패턴을 거르자"는 안을 접었다. 오류 문구가 원인별로 같아서 거르면 인증 실패·할당량 초과 같은 진짜 장애까지 묻히기 때문이다.

오후에는 임시로 넣었다. 이유가 바뀌었다 —

앱이 로그를 남기기 전까지는 어차피 관측 수단이 없다.
→ 거른다고 잃는 것이 없다. 앱 수정이 배포되면 지운다.

같은 판단이 반나절 만에 뒤집힌 건, 근거가 "정보를 잃는다"에서 "잃을 정보가 애초에 없다"로 바뀌었기 때문이다.

곁가지로 나온 것들

-- 이 조건은 과거 날짜면 항상 참이다. "최근 로그인한 사람만"이 안 걸린다
ADDDATE(NOW(), INTERVAL 12 HOUR) > B.LOGIN_DATE
-- 뒤집으면 대상이 3분의 2로 준다

이번 오류의 원인은 아니었다. 제외될 대상의 토큰도 전부 살아 있었다. 원인이 아닌 걸 확인하고도 남겨 두는 게 맞다.

  • 웹 푸시 엔드포인트 target_processing_time이 대부분 1.3초, 토큰이 많은 곳은 2.6~7.6초. 서버 대 서버 호출이라 그동안 화면이 기다린다.
  • 서버 두 대 중 한 대에만 복호화 실패 Input length must be multiple of 8…이 하루 약 900건(다른 한 대는 0건). 별건으로 남겼다.

사건 2 — 배포할 때마다 503: 타깃이 하나뿐이었다

알람

2026-09-01  2분간 503 · 303건 · 점포 47곳이 받음
target_status_code = -

SSE용으로 분리한 타깃 그룹인데, 이미터가 JVM 로컬이라 한 대에 고정돼 있었다. 배포로 그 하나를 빼면 그룹이 비고 로드밸런서가 503 / target -을 낸다.

target_status_code가 -면 로드밸런서가 타깃에 연결조차 못 한 것이고, 숫자면 타깃이 응답한 것이다. 앱 앞에 프록시가 있으면 그 프록시가 낸 503도 target 503으로 기록되므로 "타깃이 응답함 = 앱까지 도달함"이 아니다.

이것도 원래 있던 장애였다

옛 구조에서도 배포 중 이 경로가 끊기는 일은 계속 있었다. 다만 앞단 웹서버가 낸 target 5xx라 스캐너가 만드는 500과 같은 지표에 섞였고, 옛 로드밸런서를 보는 알람이 0건이라 아무도 몰랐다.

전환 후 새로 울리는 알람은 "새 문제"로 단정하기 전에 옛 구조에서 같은 일이 어떻게 기록됐을지를 먼저 확인한다.

드레이닝을 늘리는 게 아니라 줄였다

직관과 반대였다.

타깃이 하나뿐인 그룹에서 드레이닝 30초
  = 기존 연결만 유지하고 새 요청은 안 받는다
  = 대신 받을 타깃이 없다
  = 드레인 시간이 통째로 503 구간

지키는 것(진행 중 스트림)보다 잃는 것(그 사이 도착한 새 요청)이 컸다. 30 → 5초로 줄였다. 타깃이 둘이 되면 되돌린다.

unhealthy_threshold도 마찬가지다. 빨리 unhealthy로 바꿔도 대신 받을 타깃이 없어 503이 될 뿐이고 짧은 GC 정지에 흔들리기만 한다. 헬스체크 간격을 줄일 때는 임계를 같이 올려 고장 감지 시간을 원래대로 유지했다.

간격 10초 × 임계 9회 = 90초   (감지 시간은 그대로, 창만 좁힘)

구조 문제라 배포 스크립트로는 못 푼다. 아무리 정확히 드레인해도 남는 타깃이 없다. 창을 좁힐 수는 있어도 0으로는 못 만든다.

알람 설명에 단정을 써서 틀렸다

5xx 알람 설명에 "배포로는 울리지 않는 설정"이라고 적어 두었는데, 그날 배포로 울렸다.

알람 설명은 메일을 받은 사람이 분류에 쓰는 유일한 문장이다. 틀린 단정 하나가 "배포가 아니니 진짜 장애" 같은 정반대 결론을 만든다. 관측한 사실과 확인 방법만 적고, 원인 단정은 넣지 않기로 했다.

곁가지 — SSH 없는 박스에 파일 올리기

관리 세션 전용으로 만든 박스에는 scp도 aws s3 cp도 안 된다.

SSH 키가 없다                      → scp 불가
인스턴스 역할이 관리 세션 + 알림뿐  → 배포 버킷을 못 읽는다 (버킷 정책도 없다)

presigned URL을 만들어 박스에서 curl로 받는다. 서명자의 권한으로 도는 URL이라 인스턴스에 권한을 주지 않아도 된다. 역할 세션이 1시간라 유효기간도 그 안이어야 한다.

변경이 몇 줄이면 박스에서 같은 치환을 돌리고 sha256sum으로 저장소 파일과 대조하는 편이 더 빠르고 확실했다.

지금 다시 한다면

  1. 전환 전 "0건"을 정상의 근거로 쓰지 않는다. 그 대상에서 마지막으로 온 알림이 언제인지를 같이 본다.
  2. 에이전트가 붙었는지가 아니라 수집기에 보이는지로 확인한다. 설정 파일 경로 하나가 틀리면 오류 없이 3년을 조용히 지나간다.
  3. APM 알림에 상태코드가 없으면 앱이 실패를 삼키고 있는지 먼저 본다. SDK가 예외 대신 결과 객체로 돌려주는데 호출부가 그걸 버리면, 알림은 오고 로그는 빈다.
  4. 타깃이 하나인 그룹에서는 드레이닝이 보호가 아니라 손해다. 늘리기 전에 대신 받을 타깃이 있는지부터 본다.
  5. 알람 설명에 원인 단정을 쓰지 않는다. 관측한 사실과 확인 방법만 적는다.
  6. 판단이 뒤집히면 근거가 바뀐 것을 같이 적는다.

다음 편은 값을 정하는 이야기다. 3분짜리 패킷 캡처로 정한 대역이 점포 아홉 곳의 전화를 끊었다.

댓글