Skip to content

fix(push): APNs 사유로 죽은 토큰 판정 + 알람이 원인을 지목하게 - #193

Open
seizeh wants to merge 1 commit into
mainfrom
fix/apns-error-reason-and-alert-detail
Open

seizeh wants to merge 1 commit into
mainfrom
fix/apns-error-reason-and-alert-detail

Conversation

@seizeh

@seizeh seizeh commented Aug 22, 2026

Copy link
Copy Markdown
Owner

08-17·08-20 두 번 울린 [운영] 푸시 발송 오류 를 파고든 결과입니다. 알람이 지목한 원인이 틀렸고, 알람 본문은 정작 원인이 적힌 자리에서 잘려 있었습니다.

1. 분류가 iOS를 못 가르고 있었다

violatesTokenField()details[].fieldViolations[].field 만 봅니다. 그런데 APNs는 fieldViolations를 쓰지 않습니다 — 자기 사유를 ApnsError.reason 으로 줍니다:

{"@type":"…FcmError",  "errorCode":"INVALID_ARGUMENT"}
{"@type":"…ApnsError", "statusCode":400, "reason":"BadDeviceToken"}

그래서 iOS 실패는 항상 false 로 떨어졌고, 결과가 두 방향으로 틀렸습니다:

결과 증상
tokenDead=false 죽은 토큰을 영원히 안 지움 다음 푸시가 또 그 토큰을 때리고 또 알람 (자기강화)
needsAttention=true "우리 페이로드 버그" 틀린 곳을 지목

페이로드 버그가 아니라는 근거

두 번의 실패가 서로 다른 알림이었습니다:

시각 알림
08-17 09:00:00 vaccine_reminder "김첨지 접종일이 다가와요"
08-20 16:39:01 security_login "새 기기에서 로그인되었어요"

페이로드는 모든 푸시가 같은 틀을 씁니다(제목·본문·data만 다름). 버그였다면 매번 터졌어야 합니다. 그리고 안드로이드·웹 토큰은 멀쩡했고 오류는 ApnsError 였습니다.

실제 피해 — 토큰이 쌓여 있었습니다

계정 플랫폼 활성 전체
lbh8520 ios 17 43
2106008admin ios 1 10
그 외 1 1~22

실기기는 한두 대인데 활성 토큰이 17개입니다. 다른 계정은 UNREGISTERED 경로로 정상 정리돼 1개씩만 남아 있습니다.

#170의 반대편 오류입니다

그때는 INVALID_ARGUMENT 를 무조건 토큰 사망으로 봐서 멀쩡한 기기를 껐습니다. 지금은 APNs 계열을 전부 페이로드 버그로 봐서 죽은 토큰을 못 지웠습니다. 어느 쪽이든 원인은 하나 — 근거 없이 단정한 것입니다.

그래서 이번에도 허용목록으로 갑니다. APNs가 토큰을 명시적으로 지목한 세 사유만 죽었다고 보고, 모르는 사유는 살려 두고 사람에게 넘깁니다:

const APNS_TOKEN_DEAD_REASONS = new Set([
  "BadDeviceToken",          // 토큰이 잘못됐거나 환경이 다름(dev↔prod)
  "DeviceTokenNotForTopic",  // 다른 번들 ID 로 발급된 토큰
  "Unregistered",            // 앱 삭제
]);

⚠️ 허용목록에서 가장 조심한 것: ExpiredProviderToken / InvalidProviderToken 은 우리 APNs 인증키 문제입니다. 여기 넣었으면 키 만료 한 번에 모든 사용자의 기기가 전부 꺼집니다. 테스트로 못 박았습니다.

2. 알람이 원인 직전에서 잘리고 있었다

attention ??= `${v.code}${JSON.stringify(err?.error?.details ?? err).slice(0, 160)}`;

details[0] 은 언제나 {"@type":"…FcmError","errorCode":"…"} 라는 상용구입니다. 160자가 그 상용구와 {"@type":"…ApnsError", 까지만 채우고 reason 직전에서 끝났습니다. 운영에 저장된 본문이 실제로 그렇게 잘려 있어, 알람을 받고도 원인을 알 수 없었습니다.

summarizeFcmError() 를 만들어 잘려도 정보가 남는 순서로 바꿨습니다 — APNs 사유를 맨 앞, 그다음 필드 위반, 그다음 메시지. 상용구는 버립니다. 상한도 600으로 올렸습니다(notifications.bodytext 라 길이 제약 없음).

곁가지로 배치 내 건수도 붙입니다 — 첫 건만 보여주면 "한 대만 그런가"로 읽힙니다.

검증

deno test  _shared/*_test.ts        22 passed  (fcm 신규 9 + summarize 4 포함)
deno check functions/*/index.ts     타입 오류 1건 = 기존 auth.ts:65 (래칫 상한 그대로)

push_error 는 자유 텍스트이고(스키마 주석: '디버그/모니터링용') 파싱하는 곳이 없어, code 에 사유를 붙여도(INVALID_ARGUMENT/BadDeviceToken) 안전합니다 — 전수 확인했습니다.

남는 것

실제 ApnsError.reason 값은 아직 못 봤습니다. 알람에서 잘렸고, push_report 는 일부 토큰이 성공하면 오류를 통째로 버립니다. 이 변경으로 다음 발생 때 보입니다.

BadDeviceToken 으로 나오면 개발↔프로덕션 APNs 환경 혼재이므로 0031 §5.7(aps-environment: development)과 이어집니다. 그때 §5.7을 함께 처리하는 게 맞습니다.

별건으로 남긴 것(이번 PR 밖):

  • push_report 가 토큰별 오류를 보존하지 않음 — 일부 성공 시 통째 유실
  • 실패한 로그인이 기록되지 않음(record_auth_log 는 성공 분기에만)
  • app.rate_limits 버킷 키에 IP가 평문으로 들어감(login:ip:139.178.129.10:…) — auth_logs 는 방침대로 해싱하는데 여기만 평문

🤖 Generated with Claude Code

08-17·08-20 두 번 울린 "[운영] 푸시 발송 오류" 를 파고든 결과. 알람이 지목한
원인("설정/페이로드 확인 필요")이 **틀렸고**, 알람 본문은 정작 원인이 적힌
자리에서 잘려 있었다.

## 1) 분류가 iOS 를 못 가르고 있었다

violatesTokenField() 는 details[].fieldViolations[].field 만 본다. 그런데
**APNs 는 fieldViolations 를 쓰지 않는다** — 자기 사유를 ApnsError.reason 으로 준다:

  {"@type":"…FcmError",  "errorCode":"INVALID_ARGUMENT"}
  {"@type":"…ApnsError", "statusCode":400, "reason":"BadDeviceToken"}

그래서 iOS 실패는 항상 false 로 떨어졌고 결과가 두 방향으로 틀렸다:

  · tokenDead=false     → 죽은 토큰을 영원히 안 지운다. 다음 푸시가 또 그 토큰을
                          때리고 또 알람이 온다(자기강화).
  · needsAttention=true → "우리 페이로드 버그" 라고 틀린 곳을 지목한다.

페이로드 버그가 아니라는 근거는 데이터에 있었다 — 두 번의 실패가 **서로 다른
알림**(접종 알림·로그인 알림)이었고, 페이로드는 모든 푸시가 같은 틀을 쓴다.
버그였다면 매번 터졌어야 한다. 그리고 안드로이드·웹 토큰은 멀쩡했다.

실제 피해: 계정 하나에 활성 iOS 토큰이 17개 쌓여 있었다(전체 43). 실기기는 한두
대다. 다른 계정은 UNREGISTERED 경로로 정상 정리돼 1개씩만 남아 있었다.

#170 이 고친 것의 **반대편 오류**다. 그때는 INVALID_ARGUMENT 를 무조건 토큰
사망으로 봐서 멀쩡한 기기를 껐다. 어느 쪽이든 원인은 하나 — 근거 없이 단정한 것.
그래서 이번에도 허용목록으로 간다: APNs 가 토큰을 명시적으로 지목한 세 사유만
죽었다고 보고(BadDeviceToken · DeviceTokenNotForTopic · Unregistered), 모르는
사유는 살려 두고 사람에게 넘긴다.

허용목록에서 특히 조심한 것 — ExpiredProviderToken / InvalidProviderToken 은
우리 APNs 인증키 문제다. 여기 넣었으면 **키 만료 한 번에 모든 사용자의 기기가
전부 꺼진다.** 테스트로 못 박았다.

## 2) 알람이 원인 직전에서 잘리고 있었다

  attention ??= `${v.code} — ${JSON.stringify(err?.error?.details ?? err).slice(0, 160)}`

details[0] 은 언제나 {"@type":"…FcmError","errorCode":"…"} 라는 상용구다. 160자가
그 상용구와 {"@type":"…ApnsError", 까지만 채우고 **reason 직전에서 끝났다.**
운영에 실제로 저장된 본문이 그렇게 잘려 있어 알람을 받고도 원인을 알 수 없었다.

summarizeFcmError() 를 만들어 **잘려도 정보가 남는 순서**로 바꿨다 — APNs 사유를
맨 앞에, 그다음 필드 위반, 그다음 메시지, 상용구는 버린다. 상한도 600 으로 올렸다
(notifications.body 는 text 라 길이 제약이 없다).

곁가지로 배치 내 건수도 붙인다. 첫 건만 보여주면 "한 대만 그런가" 로 읽힌다.

## 검증

  deno test _shared/*_test.ts   22 passed (fcm 신규 9 + summarize 4 포함)
  deno check functions/*/index.ts  타입 오류 1건 = 기존 auth.ts:65, 래칫 상한 그대로

push_error 는 자유 텍스트고(스키마 주석: '디버그/모니터링용') 파싱하는 곳이 없어
code 에 사유를 붙여도(INVALID_ARGUMENT/BadDeviceToken) 안전하다 — 전수 확인했다.

## 남는 것

실제 ApnsError.reason 값은 **아직 못 봤다.** 알람에서 잘렸고 push_report 는 일부
토큰이 성공하면 오류를 통째로 버린다(0031 유형). 이 변경으로 다음 발생 때 보인다.
BadDeviceToken 이라면 개발↔프로덕션 APNs 환경 혼재이므로 0031 §5.7
(aps-environment: development)과 이어진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant