ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Flutter 라우팅은 화면 이동이 아니라 상태와 권한의 계약입니다
    AI Agent/flutter 2026. 7. 5. 21:00
    728x90
    반응형

    침대 옆 탁자에서 알림으로 켜진 폰과 뻗는 손
    알림을 눌렀는데 빈 화면이 뜬다는 문의

    푸시 알림을 누르고 들어온 사용자가 로그인도 안 한 채로 상세 화면 앞에 서 있었습니다.
    정확히는 상세 화면의 빈 껍데기였습니다.
    제목도 없고, 내용도 없고, 로딩 스피너만 하염없이 돌았습니다.
    로그인 가드가 분명히 있는데, 이 사용자는 그 문을 통째로 지나쳐 안쪽에 도착해 있었습니다.

    이 화면을 제가 직접 본 것도 아니었습니다.
    "알림 눌렀는데 아무것도 안 떠요"라는 문의에 짧은 화면 녹화 하나가 붙어 왔고, 스피너가 한참을 도는 동안 사용자는 결국 앱을 닫았습니다.
    재현하려고 제 폰에서 같은 알림을 눌러봤는데, 저는 이미 로그인이 되어 있어서 멀쩡히 상세가 떴습니다.
    로그인을 지우고 앱을 완전히 종료한 뒤에야 그 빈 껍데기가 제 눈앞에도 나타났습니다.

    알림에는 딥링크가 걸려 있었습니다.
    앱이 꺼진 상태에서 딥링크로 진입하면, 평소 로그인 화면을 거쳐 들어오던 순서가 통째로 건너뛰어졌습니다.
    저는 라우팅을 화면과 화면 사이를 잇는 길이라고만 생각했는데, 이 버그는 그게 아니라 "누가 어디에 들어올 자격이 있는가"를 정하는 계약에 가깝다는 걸 보여줬습니다.

    딥링크는 가드를 건너뛰고 들어옵니다

    일반적인 화면 이동이라면 사용자는 로그인 화면을 거쳐, 홈을 거쳐, 상세로 옵니다.
    그 길목마다 로그인 여부를 확인할 기회가 있습니다.
    그런데 딥링크는 그 길목을 전부 무시하고 목적지에 바로 꽂힙니다.
    중간에 있던 확인 지점들이 애초에 실행되질 않는 겁니다.

    그래서 go_router에는 redirect가 있습니다.
    어느 경로로 들어오든 화면을 그리기 전에 한 번 불려서, 여기 들어와도 되는지 판단하고 아니면 다른 곳으로 돌려보내는 관문입니다.
    저도 이걸 넣어뒀습니다.
    넣어뒀는데도 뚫렸습니다.

    제보를 받고 처음 한 건 딥링크 파싱 의심이었습니다.
    알림에 실린 URL이 이상하게 들어오나 싶어 링크를 로그로 찍고 파라미터를 하나씩 뜯어봤습니다.
    링크는 멀쩡했고, 정확히 제가 의도한 경로가 그대로 들어오고 있었습니다.
    한 시간 가까이 엉뚱한 데를 판 셈입니다.

    그다음 redirect 안에 로그를 심었습니다.
    거기서 두 가지가 한꺼번에 보였습니다.
    redirect는 분명히 호출되고 있었고, 그런데 그 순간 찍힌 isSignedIn 값이 false였습니다.
    이상한 건 저장소에는 로그인 토큰이 멀쩡히 남아 있었다는 점입니다.
    토큰은 있는데 로그인 상태는 false.
    관문이 없는 게 아니라, 관문이 아직 눈을 못 뜬 사이에 사용자가 지나간 거였습니다.

    redirect는 상태가 준비되기 전에 한 번 돕니다

    토큰을 저장소에서 읽어오는 건 비동기라, 앱이 켜지자마자 도는 redirect보다 한 박자 늦게 끝납니다.
    그 한 박자 사이에 제 코드는 "아직 모르는" 상태와 "로그인 안 된" 상태를 똑같이 취급하고 있었습니다.
    모른다고 답해야 할 자리에서 아니라고 답한 셈입니다.

    게다가 뒤늦게 토큰 로드가 끝나 로그인 상태가 확정돼도, 라우터는 그 변화를 몰라서 redirect를 다시 돌지 않았습니다.
    한 번 잘못 통과시킨 판단이 그대로 굳었습니다.
    빈 껍데기 화면은 그렇게 남은 거였습니다.

    라우터가 auth 상태를 구독하게 만듭니다

    고치는 방향은 두 가지였습니다.
    아직 상태를 모르는 동안에는 판단을 미루게 하고, 상태가 바뀌면 라우터가 그걸 알아채고 redirect를 다시 돌게 하는 것.
    go_router에서는 refreshListenable로 상태를 물려주면 됩니다.

    final router = GoRouter(
      refreshListenable: authState, // 상태가 바뀌면 redirect를 다시 태운다
      redirect: (context, state) {
        if (authState.isLoading) return null; // 아직 모른다: 판단을 미룬다
        final signedIn = authState.isSignedIn;
        final atLogin = state.matchedLocation == '/login';
        if (!signedIn && !atLogin) return '/login?from=${state.uri}';
        if (signedIn && atLogin) return '/home';
        return null;
      },
    );
    

    핵심은 isLoading일 때 null을 돌려주는 한 줄이었습니다.
    모를 땐 아무 데도 보내지 않고 잠깐 기다리게 두는 겁니다.
    토큰 로드가 끝나면 authState가 알림을 보내고, refreshListenable이 그걸 받아 redirect를 다시 돌립니다.
    그제야 로그인 안 한 사용자는 제대로 로그인 화면으로 튕겨 나갔고, from 파라미터에 원래 가려던 딥링크 목적지를 실어 보내 로그인 뒤엔 그 자리로 돌아오게 했습니다.

    다만 isLoading 동안 null을 돌려주면 그 짧은 순간 목적지 화면이 반짝 비칠 수 있어서, 로그인 상태가 확정되기 전까지는 중립 화면 하나를 걸어 그 깜빡임을 가렸습니다.

    지금은 새 화면을 라우터에 등록할 때, 이 경로가 딥링크로 곧장 열렸을 때 무슨 일이 벌어지는지를 먼저 그려봅니다.
    로그인 안 한 사람이 여기 꽂히면, 로드 중에 꽂히면, 권한 없는 사람이 꽂히면.
    그 세 장면을 머릿속에서 통과시켜보지 않으면, 저는 또 어딘가에서 하염없이 돌아가는 스피너 앞에 선 사용자를 마주하게 됩니다.

    원문 기준

    원문 기준: https://docs.flutter.dev/ui/navigation

    원문 본 날짜: 2026-07-01.

    728x90
    반응형
Designed by Tistory.