ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Flutter 앱 생명주기는 화면보다 먼저 시작됩니다
    AI Agent/flutter 2026. 7. 1. 15:00
    728x90
    반응형

    새벽 책상에서 스플래시 도중 꺼진 폰 화면을 쥔 손과 로그가 흐릿한 노트북
    스플래시 전에 앱을 멈춘 로그 한 줄에서 시작한 아침

    로그의 첫 줄은 이거였습니다.

    Unhandled Exception: Binding has not yet been initialized.

    스플래시 이미지가 뜨기도 전에 앱이 흰 화면에서 멈췄습니다.
    출근하자마자 QA 채널에 올라온 리포트였는데, 재현이 애매했습니다.
    열 번 켜면 세 번쯤 죽고 나머지는 멀쩡했거든요.
    그 세 번이 문제였습니다.
    화면 코드는 한 줄도 안 건드렸는데, 첫 위젯이 그려지기 한참 전에 무언가 무너지고 있었습니다.

    앱 생명주기라고 하면 보통 화면이 나타났다 사라지는 이야기를 떠올립니다.
    그런데 Flutter 앱은 첫 프레임이 그려지기 전에 이미 꽤 많은 일을 지나갑니다.
    바인딩을 세우고, 플러그인을 등록하고, 저장된 세션을 읽습니다.
    그 순서가 어긋나면 화면은 나오지도 못하고 죽습니다.

    main()은 화면보다 먼저 흐릅니다

    문제가 된 코드는 이런 모양이었습니다.

    void main() {
      final prefs = SharedPreferences.getInstance(); // 바인딩 전에 플러그인 호출
      runApp(MyApp(prefs));
    }
    

    SharedPreferences는 네이티브 쪽과 메시지를 주고받는 플러그인입니다.
    이 통로는 WidgetsFlutterBinding이 초기화된 뒤에야 열립니다.
    그런데 위 코드는 바인딩을 세우기도 전에 플러그인을 부르고 있었습니다.
    그럼 왜 열 번 중 일곱 번은 멀쩡했을까요.
    이 부분은 아직도 정확히는 설명 못 합니다.
    cold start와 warm start에서 바인딩이 준비되는 타이밍이 미묘하게 달랐던 것 같은데, 확신은 없습니다.

    고친 건 한 줄이었습니다.

    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      final prefs = await SharedPreferences.getInstance();
      runApp(MyApp(prefs));
    }
    

    ensureInitialized()를 먼저 부르면, 플러그인 통로가 열린 걸 보장한 다음에 나머지가 흐릅니다.
    이 한 줄을 빼먹었다는 게 좀 허탈했지만, 재현이 애매했던 이유는 오히려 깔끔하게 맞아떨어졌습니다.

    초기화는 순서지, 목록이 아닙니다

    여기서 한 번 더 헤맸습니다.
    초기화할 게 여러 개면 Future.wait로 한꺼번에 돌리고 싶어집니다.
    빠르니까요.
    그런데 Firebase를 먼저 세워야 그 위에서 인증 토큰을 읽는 플러그인이 동작하는 경우처럼, 사이에 순서가 있는 것들이 섞여 있습니다.
    병렬로 던지면 어떤 날은 되고 어떤 날은 죽습니다.
    아까 그 "열 번에 세 번"과 똑같은 얼굴입니다.

    사실 그날 저를 반나절 헤매게 한 건 Firebase였습니다.
    앱을 켜자마자 원격 설정을 읽어서 프로모션 배너를 띄우는 코드가 있었는데, Firebase 초기화가 끝나기 전에 그 값을 불렀습니다.
    그러니 어떤 날은 기본값이, 어떤 날은 서버 값이 들어왔고, 배너는 떴다 안 떴다 했습니다.
    저는 이걸 A/B 테스트 설정 문제로 오해했습니다.
    대시보드를 뒤지고, 세그먼트를 확인하고, 엉뚱한 데서 원인을 찾았습니다.
    알고 보니 값이 틀린 게 아니라 값을 읽는 시점이 초기화보다 빨랐던 거였습니다.

    그래서 초기화 코드를 볼 때는 "이것들이 서로 기다려야 하나"를 먼저 나눕니다.
    서로 상관없는 건 같이 돌려도 되고, 앞이 뒤의 전제가 되는 건 줄을 세웁니다.
    이 구분을 안 하고 속도만 보면, 나중에 재현 안 되는 크래시로 돌아옵니다.

    돌아온 앱은 새로 시작한 앱이 아닙니다

    시작만 생명주기가 아닙니다.
    사용자가 앱을 백그라운드로 내렸다 다시 열면 resumed 상태로 돌아오는데, 이때 저장된 값이 그대로일 거라 믿으면 또 어긋납니다.
    카메라 권한을 시스템 설정에서 껐다 켜고 돌아온 경우가 그랬습니다.
    앱은 여전히 옛날 권한 상태를 들고 있었습니다.

    이것도 처음엔 권한 플러그인 버그로 봤습니다.
    다시 열었더니 여전히 "권한 없음"으로 뜨길래요.
    그런데 앱을 완전히 종료했다 켜면 멀쩡했습니다.
    종료 후 재시작과 백그라운드 복귀가 서로 다른 길이라는 걸 그때 눈으로 봤습니다.
    재시작은 처음부터 다시 읽고, 복귀는 들고 있던 걸 그대로 씁니다.

    @override
    void didChangeAppLifecycleState(AppLifecycleState state) {
      if (state == AppLifecycleState.resumed) {
        _refreshPermissionState(); // 돌아올 때마다 바깥 상태를 다시 읽습니다.
      }
    }
    

    resume은 이어붙이기가 아니라 두 번째 시작에 가깝습니다.
    내가 잠든 사이에 바깥에서 바뀐 것들을 다시 읽어와야 화면이 거짓말을 안 합니다.

    다음엔 로그 첫 줄부터 봅니다

    이번 일에서 남은 건 규칙이 아니라 습관 하나입니다.
    화면이 아예 안 뜨는 크래시를 만나면 위젯부터 열지 않고 main()과 로그 맨 윗줄부터 봅니다.
    "Binding has not yet been initialized" 같은 문장은 화면이 아니라 시작 순서를 가리키고 있으니까요.

    아직 안 풀린 것도 있습니다.
    그 세 번과 일곱 번을 가르는 정확한 조건은 여전히 모릅니다.
    다음에 비슷한 게 또 올라오면, 그땐 재현 기기부터 붙잡고 cold start 로그를 처음부터 찍어볼 생각입니다.

    728x90
    반응형
Designed by Tistory.