기술

iOS 26에서 APNs 토큰이 null이었고, 답은 AppDelegate override 네 개였습니다

2026.09.128분 읽기

안드로이드에서는 푸시가 정상이었습니다. 같은 코드, 같은 Firebase 프로젝트인데 iOS 시뮬레이터에서만 알림이 오지 않았습니다. 로그에는 에러가 한 줄도 없었습니다. 크래시도 예외도 없고, 그냥 아무 일도 일어나지 않았습니다.

iPhone 17 시뮬레이터, iOS 26.4, Xcode 26.4, macOS 26.4 조합이었습니다. 이 환경에서 푸시를 다시 살리는 데 네 시간이 걸렸습니다. 결론부터 적으면 plugin 설정 문제가 아니었고, 버전을 되돌려서 풀리는 문제도 아니었습니다.


에러가 하나도 안 나는 고장이었습니다

먼저 확인한 것은 토큰이었습니다. APNs 토큰 조회가 null 을 돌려줬고, 그 값을 받아 발급하는 FCM 토큰도 함께 실패했습니다. 토큰이 없으면 서버는 이 기기를 특정할 수 없습니다.

APNs 토큰이 null 이고 FCM 토큰 발급이 실패합니다

앱이 떠 있을 때 도착한 메시지 콜백이 한 번도 호출되지 않습니다

백그라운드에서 OS 배너는 뜨지만, 탭해도 앱 안으로 이벤트가 넘어오지 않습니다

같은 코드로 Android 는 전부 정상 동작합니다

세 번째 항목이 헷갈렸습니다. 배너가 뜬다는 것은 알림이 기기까지는 정상 도달했다는 뜻입니다. 그런데 탭 이벤트가 앱 코드로 넘어오지 않았습니다. 전달은 되는데 앱만 그 사실을 모르는 상태였습니다.


원인은 plugin 이 아니라 앱 진입 구조였습니다

이미 공개된 이슈였습니다. Flutter 저장소의 185048 번 이슈와 Firebase iOS SDK 저장소의 15315 번 이슈가 같은 현상을 다루고 있었습니다. 새 Xcode 가 만들어내는 UIScene 기반 진입 구조에서 Firebase Messaging plugin 의 method swizzling 이 걸리지 않는 문제였습니다.

swizzling 은 plugin 이 앱의 AppDelegate 메서드를 런타임에 가로채 자기 코드로 바꿔 끼우는 방식입니다. 이 가로채기가 실패하면 plugin 은 자기가 등록되지 않았다는 사실 자체를 모릅니다. 그래서 예외도 로그도 없이 조용히 멈춰 있습니다.

확인은 한 줄로 끝났습니다. Messaging 델리게이트가 nil 이었습니다. plugin 이 그 자리를 차지하지 못했으니 콜백을 받을 통로가 없었고, Dart 쪽 스트림은 흘려보낼 원본 자체가 없는 상태였습니다.

159-1.jpg


되돌리기로는 한 발도 못 나갔습니다

원인을 알기 전에 시도한 것은 대부분 버전을 되돌리는 방향이었습니다. 새 환경에서 깨졌으면 예전 조합으로 내려가면 된다고 봤습니다. 결과는 아래와 같았습니다.

시도

결과

firebase_messaging 다운그레이드

증상 동일

firebase_core 도 함께 다운그레이드

증상 동일

AppDelegate 정리 후 표준 예제 패턴 모방

증상 동일

Podfile 에 use_modular_headers! 추가

증상 동일

SceneDelegate 제거

증상 동일

Flutter 업그레이드 후 SPM 마이그레이션

증상 동일

iOS 18.6 시뮬레이터 내려받아 테스트

증상 동일

마지막 항목이 방향을 정해줬습니다. 이전 버전 시뮬레이터에서도 같은 증상이 나왔습니다. 시뮬레이터의 OS 버전이 아니라 그것을 띄우는 Xcode 쪽 구조가 원인이라는 뜻이었습니다.

여기서 되돌리기를 접었습니다. plugin 이 자동으로 못 잡는 자리라면 직접 채우는 쪽이 남은 선택지였습니다.


native 에서 받아 Dart 로 넘겼습니다

해결은 AppDelegate 에 override 네 개를 넣는 것이었습니다. plugin 이 가로채지 못한 자리를 직접 채우고, 받은 내용을 MethodChannel 로 Dart 에 넘기는 구조입니다.

기기 토큰 수신 콜백에서 FCM 쪽 apnsToken 속성에 토큰을 직접 대입합니다. 이 한 줄로 APNs 토큰과 FCM 토큰이 모두 정상 발급됐습니다

포그라운드 표시 콜백에서 페이로드와 제목·본문을 MethodChannel 로 넘기고, completionHandler 에는 빈 배열을 넘겨 OS 배너를 끕니다

알림 탭 콜백에서 페이로드를 같은 채널로 넘긴 뒤 상위 구현을 호출합니다

엔진 초기화 콜백에서 plugin registrar 로 메신저를 확보하고, 그전에 들어온 탭을 보관해 뒀다가 엔진이 준비된 뒤 흘려보냅니다

네 번째가 가장 늦게 찾은 구멍이었습니다. 앱이 완전히 종료된 상태에서 알림을 탭하면 탭 이벤트가 엔진보다 먼저 도착합니다. 그때 그냥 채널로 던지면 받는 쪽이 없어서 이벤트가 사라집니다.

Dart 쪽은 같은 이름의 MethodChannel 에 리스너 하나를 거는 것으로 끝났습니다. 포그라운드 수신이면 인앱 배너를 띄우고, 탭이면 지정된 화면으로 라우팅합니다. Android 는 이 채널을 호출하지 않으므로 기존 동작에 영향이 없었습니다.

159-2.jpg


포그라운드 알림을 두 번 띄우지 않으려면

앱이 켜져 있을 때 알림을 어떻게 보여줄지는 따로 정해야 했습니다.

옵션 A — 포그라운드 표시 옵션을 켜고 표시 콜백에서 직접 제어합니다. 표시 주체가 한 곳입니다

옵션 B — 로컬 알림 라이브러리로 직접 띄웁니다. OS 자동 표시가 살아 있으면 같은 알림이 두 번 뜹니다

옵션 B 를 먼저 붙였다가 알림이 두 개 겹쳐 뜨는 것을 보고 되돌렸습니다. OS 가 자동으로 띄우는 배너와 우리 코드가 띄우는 알림이 동시에 동작했기 때문입니다.

표시 주체를 한쪽으로 몰아야 이 중복이 사라집니다. 옵션 A 로 일원화하고, 배너 모양은 앱 안에서 직접 그리는 위젯으로 통일했습니다.

인앱 배너를 띄우는 데서 한 번 더 막혔습니다. GoRouter 가 넘겨주는 context 로는 Overlay 를 찾지 못했습니다. navigatorKey 로 OverlayState 를 직접 받아 entry 를 넣는 방식으로 바꿔서 해결했습니다.

159-3.jpg


푸시로 여는 화면은 shell 밖에 뒀습니다

푸시로 들어간 상세 화면 아래에 하단 탭바가 같이 그려지는 문제도 있었습니다. 탭바가 필요한 화면과 그렇지 않은 화면이 모두 shell branch 안에 들어 있었기 때문입니다.

탭바가 필요 없는 하위 화면 13개를 top-level 라우트로 옮겼습니다. 이후로는 어디서 열든 전체 화면으로 뜨고 뒤로가기도 원래 자리로 돌아왔습니다. shell branch 에는 탭바가 실제로 필요한 화면만 남겼습니다.


검증과 남은 것

두 시나리오로 확인했습니다. 앱을 완전히 종료한 상태에서 푸시를 받아 탭하면 앱이 켜지면서 의도한 화면으로 들어갑니다. 앱이 떠 있는 상태에서 푸시를 받으면 인앱 배너가 내려오고, 탭하면 같은 화면으로 이동합니다.

이 우회는 영구 해법이 아닙니다. plugin 이 새 진입 구조에서 swizzling 을 정상적으로 잡게 되면 override 네 개는 불필요해집니다. 다만 지우기 전에 같은 두 시나리오를 다시 돌려봐야 합니다.

OS·IDE 메이저 버전을 올린 직후에는 푸시를 기능 검증 목록에 넣습니다

푸시 이상은 에러 없이 조용히 나타나므로 토큰 값 자체를 로그로 확인합니다

plugin 이 런타임 가로채기에 기대는 부분은 native 에서 직접 채울 수 있는지 확인합니다

종료 상태 탭은 엔진 준비 전에 도착하므로 보관 후 재전달 경로를 둡니다

같은 앱에서 푸시 때문에 시간을 쓴 적이 한 번 더 있었습니다. 그때는 구현이 없는 줄 알고 새로 만들려다가, 코드는 이미 다 있고 설정 한 곳이 비어 있던 경우였습니다.

네 시간 중 세 시간은 버전을 되돌리는 데 썼습니다. 되돌릴 수 있는 것을 다 되돌린 뒤에야 방향을 바꿨습니다. 새 메이저 버전에서 조용히 죽는 기능을 만나면 되돌리기에 쓸 시간의 상한을 먼저 정해두는 편이 낫습니다.

#iOS26#Flutter#FirebaseMessaging#AppDelegate#MethodChannel#GoRouter#푸시알림