Flutter iOS 스플래시 간헐적 8초 지연 원인과 해결

분석일: 2026-07-15
대상: Flutter iOS TestFlight 앱

증상

앱을 콜드 스타트하면 흰 배경과 앱 아이콘으로 구성된 iOS 스플래시가 정상보다 오래 남습니다. 같은 TestFlight 빌드에서도 빠르게 열릴 때와 약 8초 동안 멈춘 것처럼 보일 때가 섞여 간헐적으로 재현됩니다.

실제 연결 기기에서 확인한 설치 앱은 새 프로파일로 배포한 빌드 11이 아니라 기존 dev TestFlight 1.0.0 (10)이었다. 따라서 프로비저닝 프로파일 교체나 빌드 11이 스플래시 재발의 원인은 아닙니다.

결론

첫 수정은 시작 경로가 Firebase 초기화를 기다리던 문제와 세션 정리 실패 시 restoring에 고정될 수 있는 문제를 해결했습니다. 그러나 별도의 Flutter 첫 프레임과 iOS SceneDelegate MethodChannel 등록 사이 경합이 남아 있었습니다.

Flutter가 인증 상태 복원을 빨리 끝내고 removeSplash를 호출했는데 iOS 채널 handler가 아직 등록되지 않았으면 MissingPluginException이 발생합니다. 기존 코드는 제거를 이미 예약했다는 플래그를 즉시 고정하고 재시도하지 않았습니다. 결과적으로 SceneDelegate에 설정된 8초 안전 타이머가 실행될 때까지 네이티브 스플래시 overlay가 화면을 계속 덮습니다.

스플래시 구조

iOS 네이티브

AppDelegate가 FlutterEngine.run()으로 Dart 엔진을 먼저 시작합니다.

이후 SceneDelegate가 FlutterViewController를 만들고 LaunchScreen storyboard view를 overlay로 올립니다.

SceneDelegate가 illowa/splash MethodChannel handler를 등록합니다.

Flutter에서 removeSplash를 받으면 overlay를 0.3초 fade-out합니다.

신호를 영원히 받지 못하는 경우를 대비해 8초 후 강제로 overlay를 제거합니다.

Flutter

bootstrap()에서 세션 복원을 시작하고 앱을 렌더링합니다.

_RootGate가 restoring을 벗어나면 첫 프레임 뒤 illowa/splash.removeSplash를 호출합니다.

기존 구현은 호출 성공 여부와 무관하게 _splashRemovalScheduled=true로 바꾸고 한 번만 호출했습니다.

경합이 발생하는 순서

AppDelegate가 Flutter 엔진을 실행합니다.

Dart의 bootstrap()과 빠른 secure storage 복원이 진행됩니다.

_RootGate가 로그인 또는 로그아웃 화면을 렌더링합니다.

Flutter가 첫 프레임 뒤 removeSplash를 호출합니다.

운이 나쁘면 SceneDelegate의 MethodChannel handler는 아직 등록 전입니다.

호출이 MissingPluginException으로 끝난다.

Flutter는 이미 예약 완료로 표시해 다시 호출하지 않습니다.

네이티브 fallback 타이머가 8초 뒤 overlay를 제거합니다.

이 구조는 같은 바이너리에서 성공과 실패가 섞이는 간헐성을 정확히 설명합니다. 시작이 빨라지면서 Flutter의 제거 호출이 앞당겨졌기 때문에 이전 성능 개선 뒤 경합이 더 잘 드러날 수도 있습니다.

첫 수정에서 해결된 문제

첫 번째 개선

Firebase 초기화를 첫 프레임의 await 경로에서 제거했습니다.

세션 복원을 먼저 시작했습니다.

Firebase 준비 신호 이후에만 PushService를 초기화하도록 분리했습니다.

시작 시 보이지 않는 모든 탭을 동시에 로드하던 동작을 lazy load로 변경했습니다.

두 번째 개선

secure storage 읽기·정리 예외에도 인증 상태를 loggedOut으로 전환하도록 보완했습니다.

인증 게이트가 끝난 뒤 네이티브 스플래시 제거 MethodChannel을 호출하도록 추가했습니다.

시작 경로 회귀 테스트를 추가했습니다.

이 수정들은 유효하지만 채널 등록 전에 발생하는 최초 호출 실패를 재시도하지 않아 간헐적 8초 지연이 남았습니다.

구현한 해결책

후속 수정에서는 Flutter 스플래시 제거 호출을 다음과 같이 변경했습니다.

제거 완료와 제거 진행 중 상태를 별도로 관리합니다.

인증 상태가 restoring을 벗어난 뒤 첫 프레임에서 제거를 시작합니다.

MissingPluginException 또는 일시적인 PlatformException이면 100ms 뒤 재시도합니다.

최대 20회, 총 약 2초로 제한합니다.

한 번 성공하면 즉시 종료하고 다시 호출하지 않습니다.

최종 실패만 로그에 남기며 네이티브 8초 fallback은 최후 방어선으로 유지합니다.

핵심은 단순히 타이머를 줄이는 것이 아니라 채널이 준비될 때까지 handshake를 재시도하는 것입니다.

회귀 테스트

새 widget test는 iOS MethodChannel handler가 처음 두 번 MissingPluginException을 반환하고 세 번째 호출에 성공하도록 구성합니다. Flutter 시간이 100ms씩 진행될 때 정확히 세 번째 호출에서 제거가 완료되는지 검증합니다.

검증 결과:

flutter test test/startup_test.dart: 통과
flutter test: 전체 5개 통과
flutter analyze: No issues found
git diff --check: 통과

APNs 오류와의 관계

빌드 10을 실제 iPhone에서 콘솔 연결해 실행했을 때 다음 오류도 다시 확인되었습니다.

응용 프로그램을 위한 유효한 ‘aps-environment’ 인타이틀먼트 문자열을 찾을 수 없습니다.
Failed to register for remote notifications

이 오류는 빌드 10의 잘못된 프로비저닝 프로파일 때문에 APNs 등록이 실패한 별도 문제입니다. Flutter 스플래시 overlay가 8초 동안 남는 직접 원인은 아닙니다.

새 Example Dev 프로파일은 aps-environment=production으로 검증됐고 CI 개발 환경 비밀 변수에 반영되었습니다. 이를 사용한 빌드 11은 TestFlight 업로드까지 성공했지만, 재현 당시 실제 기기에는 아직 빌드 10이 설치되어 있었습니다.

배포 및 확인 절차

dev TestFlight를 새로 배포합니다.

iPhone TestFlight에서 새 빌드 번호를 확인하고 업데이트합니다.

앱을 완전 종료한 뒤 5회 이상 콜드 스타트합니다.

매 실행에서 스플래시가 첫 화면 준비 직후 제거되는지 확인합니다.

연결 콘솔에서 더 이상 aps-environment 오류가 없는지 확인합니다.

APNs token received를 확인합니다.

개발 DB에서 해당 iOS device의 FCM token과 lastActiveAt 갱신을 확인합니다.

dev 서버 관리자 푸시 API로 해당 사용자에게 실제 푸시를 발송합니다.

포그라운드, 백그라운드, 앱 종료 상태 수신을 각각 확인합니다.

추가 방어 권고

현재 재시도 방식은 변경 범위가 작고 기존 구조에 맞는 실용적인 해결입니다. 장기적으로는 다음을 고려합니다.

네이티브에서 제거 요청 여부를 latch로 저장해 채널 호출이 overlay 설치보다 먼저 와도 설치 직후 즉시 제거합니다.

AppDelegate와 SceneDelegate 중 한 곳에서 채널 수명주기를 단일 소유합니다.

세션 restore의 secure storage platform call에도 제한 시간을 두어 실제 hang 시 restoring이 무기한 유지되지 않게 합니다.

앱 시작 단계별 timestamp를 release에서도 수집 가능한 signpost 또는 Crashlytics breadcrumb로 남깁니다.

CI에 최종 provisioning profile과 archive entitlement 검사를 추가합니다.

운영 판단 기준

정확히 8초 전후로 로고가 사라지면 MethodChannel 제거 신호 누락과 native fallback 작동을 우선 의심합니다.

로고가 8초 뒤 사라졌지만 Flutter 내부 스플래시가 계속 남으면 AuthState.restore() 또는 secure storage hang을 의심합니다.

화면은 정상 진입하지만 APNs 오류만 보이면 프로비저닝 entitlement 문제로 분리합니다.

TestFlight 업로드 성공만으로 실제 기기의 APNs token 발급을 확정하지 않습니다.