Flutter popUntil 먹통일 때 onGenerateRoute를 의심해야 하는 이유

Flutter로 복잡한 앱을 개발하다 보면 여러 페이지에 걸친 사용자 플로우를 제어해야 하는 순간이 반드시 찾아옵니다. 예를 들어, 전자상거래 앱에서 사용자가 '상품 목록(A) → 상품 상세(B) → 장바구니(C) → 주문(D)' 순서로 화면을 이동했다고 상상해 봅시다. 주문이 성공적으로 완료된 후, 사용자를 앱의 메인 화면(A)으로 한 번에 되돌려 보내고 싶을 때 어떤 방법을 사용해야 할까요? 안드로이드의 뒤로 가기 버튼을 여러 번 누르게 하거나, 각 화면마다 복잡한 콜백을 심는 것은 끔찍한 사용자 경험(UX)을 초래할 뿐입니다. 바로 이럴 때 Flutter의 내비게이션 스택을 외과 의사처럼 정교하게 제어하는 강력한 도구가 바로 popUntil 메소드입니다.

하지만 많은 Flutter 개발자들이 popUntil이 예상대로 동작하지 않아 디버깅 지옥에 빠지는 경험을 하곤 합니다. 분명 공식 문서를 참고하여 코드는 완벽하게 작성한 것 같은데, 버튼을 눌러도 아무런 반응이 없거나, 심지어는 앱이 예기치 않게 종료되는 현상을 마주하게 되죠. 이 글에서는 popUntil이 제대로 작동하지 않는 가장 흔하고 치명적인 원인을 Flutter의 동적 라우팅 핵심인 onGenerateRoute와 연관 지어 밑바닥까지 심도 있게 파헤쳐 보겠습니다. 이 글을 끝까지 정독하신다면, 여러분은 더 이상 모호한 내비게이션 버그로 귀중한 개발 시간을 낭비하는 일은 없게 될 것입니다.

Flutter 내비게이션의 심장, 스택(Stack) 구조의 완벽 이해

popUntil의 동작 원리를 정확히 이해하기 위해서는, 먼저 Flutter의 내비게이션 시스템이 어떤 원리로 작동하는지 그 근간을 알아야 합니다. Flutter의 전통적인 내비게이션 시스템(종종 Navigator 1.0으로 불립니다)은 컴퓨터 과학의 가장 기본적인 자료구조 중 하나인 **스택(Stack)**을 기반으로 합니다. 스택은 '나중에 들어온 것이 먼저 나간다'(Last-In, First-Out, LIFO)는 명확한 규칙을 가집니다. 마치 책상 위에 책을 한 권씩 쌓고, 맨 위에서부터 한 권씩 치우는 것을 상상하면 아주 쉽게 이해할 수 있습니다.

Flutter에서 화면은 단순한 위젯이 아니라 '라우트(Route)'라는 개념으로 관리됩니다. 라우트는 화면을 구성하는 위젯뿐만 아니라, 화면 전환 애니메이션, 이름, 상태 등 다양한 메타데이터를 포함하는 객체입니다. 이 라우트들이 내비게이터 스택에 차곡차곡 쌓이게 됩니다.

  • push: 새로운 라우트(페이지)를 스택의 가장 위에 쌓는 행위입니다. 대표적으로 Navigator.push()Navigator.pushNamed() 메소드를 사용합니다. 이는 책 더미 맨 위에 새로운 책을 한 권 올려놓는 것과 정확히 같습니다. 새로 푸시된 페이지가 사용자에게 보이게 됩니다.
  • pop: 스택의 가장 위에 있는 라우트를 제거하고, 그 아래에 있던 이전 페이지를 화면에 다시 보여주는 행위입니다. Navigator.pop() 메소드가 이 역할을 합니다. 책 더미 맨 위의 책을 치우면 그 아래에 있던 책이 드러나는 것과 동일한 원리입니다.

예를 들어, 사용자가 Home(A) → Profile(B) → Settings(C) 순서로 페이지를 이동했다면, 이 시점의 내비게이션 스택은 다음과 같은 구조를 가지게 됩니다.


|  Route for Settings (C) | <-- 스택의 TOP. 현재 사용자에게 보이는 화면
+-------------------------+
|   Route for Profile (B) |
+-------------------------+
|    Route for Home (A)   | <-- 스택의 BOTTOM. 가장 먼저 쌓인 화면
+-------------------------+

이 상태에서 사용자가 뒤로 가기 버튼을 누르거나 코드상에서 Navigator.pop(context)을 호출하면, 스택의 최상단에 있던 'Route for Settings (C)'가 제거(pop)되고, 그 결과 'Route for Profile (B)'가 스택의 새로운 TOP이 되어 화면에 나타납니다. 여기서 한 번 더 pop을 호출하면 비로소 'Route for Home (A)' 화면으로 돌아가게 되는 것입니다. 이처럼 Flutter의 내비게이션은 스택이라는 단순하지만 강력한 모델 위에서 동작합니다.

잠깐! Navigator 2.0 (GoRouter 등)과는 다릅니다.
이 글에서 다루는 내용은 Flutter의 전통적인 명령형(Imperative) 라우팅 방식인 Navigator 1.0을 기준으로 합니다. 만약 GoRouter, Beamer 등과 같은 선언형(Declarative) 라우팅 패키지(Navigator 2.0)를 사용하고 있다면, 내비게이션 스택을 직접 제어하는 방식이 다르므로 popUntil 대신 해당 패키지가 제공하는 고유한 API(예: context.go('/'))를 사용해야 합니다. 두 방식을 혼용하면 예측 불가능한 오류가 발생할 수 있습니다.

popUntil의 진정한 힘과 사용법 마스터하기

이제 오늘의 주인공, popUntil이 왜 필요한지 명확해집니다. 만약 위 예시의 Settings(C) 화면에서 중간의 Profile(B)를 건너뛰고 Home(A) 화면으로 즉시 돌아가고 싶다면 어떻게 해야 할까요? pop()을 두 번 연속으로 호출하는 것은 스택의 현재 깊이를 정확히 알고 있어야만 가능한, 매우 불안정하고 지저분한 방법입니다. 스택의 구조가 변경되면 코드를 전부 수정해야 하니까요. 이럴 때 popUntil은 매우 우아하고 안정적인 해결책을 제시합니다.

popUntil은 이름 그대로 특정 조건(predicate)이 '참(true)'이 될 때까지 스택에서 라우트를 계속해서 pop하는, 즉 제거하는 메소드입니다. "내가 지정한 바로 그 페이지가 나올 때까지, 그 위에 쌓여있는 모든 페이지를 전부 닫아줘!"라는 매우 직관적인 명령을 내릴 수 있습니다.

기본 사용법: `ModalRoute.withName()`

popUntil의 가장 일반적이고 강력한 사용법은 ModalRoute.withName()과 함께 사용하는 것입니다. ModalRoute.withName()은 라우트의 이름(routeName)을 기반으로 특정 라우트를 찾아내는 predicate를 생성해주는 헬퍼 메소드입니다.


// 현재 페이지에서 '/home' 이라는 이름을 가진 라우트가 나타날 때까지
// 그 위의 모든 라우트를 스택에서 제거(pop)합니다.
Navigator.of(context).popUntil(ModalRoute.withName('/home'));

이 단 한 줄의 코드는 Settings(C)와 Profile(B)를 스택에서 순식간에 모두 제거하고, 우리가 목표로 했던 Home(A) 페이지만을 남겨 화면에 보여줍니다. 스택 위에 페이지가 10개가 쌓여있든 100개가 쌓여있든 상관없이, 정확하게 '/home'이라는 이름표를 가진 페이지만 남기고 모두 정리해줍니다. 매우 간결하고 강력하죠. 하지만 이 강력한 기능이 아무런 반응을 보이지 않는다면, 문제는 십중팔구 **스택에 쌓인 라우트들이 '이름표'를 잃어버렸기 때문**입니다.

`popUntil` vs `pushAndRemoveUntil`

비슷한 역할을 하는 다른 메소드들과의 차이점을 명확히 이해하는 것은 매우 중요합니다. 특히 pushAndRemoveUntil과 헷갈리는 경우가 많습니다.

API 핵심 동작 스택 변화 주요 사용 사례
popUntil(predicate) 제거 (Pop): 특정 조건의 라우트를 만날 때까지 현재 스택 위의 라우트들을 제거합니다. 스택의 크기가 줄어들기만 합니다. 새로운 라우트를 추가하지 않습니다. 주문 완료 후 홈으로 돌아가기, 설정 변경 후 이전 화면으로 돌아가기 등 '복귀' 시나리오에 사용됩니다.
pushAndRemoveUntil(newRoute, predicate) 추가 후 제거 (Push & Pop): 새로운 라우트를 스택에 추가(push)한 뒤, 특정 조건의 라우트를 만날 때까지 이전의 모든 라우트를 제거합니다. 스택이 완전히 새로운 상태로 교체될 수 있습니다. 로그인 성공 후 이전의 모든 화면(로그인, 회원가입 등)을 제거하고 메인 대시보드 화면으로 이동시킬 때, 스플래시 화면 이후 온보딩 화면을 모두 제거하고 홈으로 이동시킬 때 등 '새로운 시작' 시나리오에 사용됩니다.
pushNamedAndRemoveUntil(routeName, predicate) 이름 기반 추가 후 제거 (Push Named & Pop): pushAndRemoveUntil과 동일하나, 새로운 라우트를 이름(routeName)으로 지정하여 추가합니다. pushAndRemoveUntil과 동일합니다. onGenerateRoute와 같은 동적 라우팅 환경에서 pushAndRemoveUntil을 사용할 때 주로 선택됩니다.

보시다시피, popUntil은 단순히 '돌아가는' 것에 초점을 맞추는 반면, pushAndRemoveUntil 계열은 '새로운 곳으로 이동하며 과거 기록을 지우는' 것에 가깝습니다. 목적에 맞는 정확한 API를 사용하는 것이 견고한 내비게이션 로직의 첫걸음입니다.

`popUntil` 실패의 99% 원인: `onGenerateRoute`와 `RouteSettings`

자, 이제 본론으로 들어가 봅시다. Flutter에서 페이지 이동 경로, 즉 라우트를 정의하는 방법은 크게 두 가지로 나뉩니다.

  1. routes 맵 사용: MaterialApp 위젯의 routes 속성에 Map<String, WidgetBuilder> 형태로 라우트 이름과 해당 위젯을 미리 모두 정의해두는 정적인 방식입니다. 앱 규모가 작고 페이지 이동 로직이 단순할 때 유용합니다.
  2. onGenerateRoute 콜백 사용: MaterialApponGenerateRoute 속성에 특정 시그니처를 가진 함수를 제공하는 동적인 방식입니다. Navigator.pushNamed가 호출될 때마다 이 함수가 실행되며, 요청된 라우트의 이름(settings.name)이나 함께 전달된 인자(settings.arguments)를 분석하여 조건에 맞는 페이지를 동적으로 생성하고 반환할 수 있습니다. 이 유연함과 확장성 덕분에 대부분의 실무 프로젝트에서는 onGenerateRoute 방식을 채택합니다.

그리고 popUntil을 배신하는 주범은 바로 이 유연하고 강력한 onGenerateRoute를 사용할 때 발생하는 사소한 실수에 있습니다. 많은 개발자들이 onGenerateRoute를 아래와 같이 '간결하게' 작성하는 실수를 저지르곤 합니다.

❌ 잘못된 `onGenerateRoute` 구현 예시 (popUntil을 망가뜨리는 코드)


// main.dart
MaterialApp(
  initialRoute: '/home',
  onGenerateRoute: (settings) {
    // settings.name에 따라 분기 처리
    switch (settings.name) {
      case '/home':
        return HomePage(); // <-- 바로 이 부분이 문제입니다! 위젯을 직접 반환합니다.
      case '/detail':
        // settings.arguments를 사용하여 상세 페이지에 데이터를 전달
        final args = settings.arguments as Map<String, dynamic>;
        return DetailPage(productId: args['id']); // <-- 여기도 마찬가지입니다.
      default:
        return HomePage();
    }
  },
)
// 여기서 HomePage()나 DetailPage() 위젯을 직접 반환하는 것은 Route 객체가 아닙니다.
// Flutter 프레임워크가 내부적으로 이 위젯을 MaterialPageRoute로 감싸주긴 하지만,
// 이 과정에서 아주 중요한 '설정' 정보가 누락되는 치명적인 문제가 발생합니다.

얼핏 보기에는 아무런 문제가 없어 보입니다. 실제로 Navigator.pushNamed(context, '/detail', arguments: {'id': '123'})과 같은 코드는 정상적으로 동작하여 페이지 이동이 완벽하게 잘 됩니다. 하지만 이 상태에서 상세 페이지의 어떤 버튼을 눌러 popUntil(ModalRoute.withName('/home'))을 호출하면, 아무 일도 일어나지 않습니다. 콘솔에는 아무런 에러도 뜨지 않고, 그저 묵묵부답일 뿐입니다.

🤔 도대체 왜 동작하지 않을까? `RouteSettings`의 실종

이 미스터리를 풀 열쇠는 onGenerateRoute 콜백이 파라미터로 받는 RouteSettings 객체에 있습니다. 이 settings 객체에는 pushNamed가 호출될 때 전달된 매우 중요한 정보들이 고스란히 담겨 있습니다.

  • name: 요청된 라우트의 이름 (예: '/home', '/detail')
  • arguments: pushNamedarguments 파라미터를 통해 전달된 모든 데이터 (객체, 맵, 리스트 등)

popUntil(ModalRoute.withName('/home'))이 올바르게 동작하려면, 내비게이션 스택 안에 쌓여있는 모든 라우트들이 각각 자신의 이름(settings.name)을 명확하게 가지고 있어야 합니다. 왜냐하면 ModalRoute.withName('/home') predicate의 내부 동작은 스택을 위에서부터 하나씩 훑으면서 "이 라우트의 이름(settings.name)이 '/home'과 일치하는가?"를 계속해서 확인하는 것이기 때문입니다.

하지만 위 잘못된 예시처럼 onGenerateRoute에서 위젯(HomePage())을 직접 반환하면, Flutter 프레임워크는 편의를 위해 이 위젯을 MaterialPageRoute로 감싸주는 처리를 합니다. 문제는 이 자동 변환 과정에서, 원래 onGenerateRoute로 전달되었던 소중한 settings 객체를 새로 생성된 MaterialPageRoute에 전달해주지 않는다는 것입니다. 그 결과, 스택에 쌓이는 모든 페이지 라우트들의 settings.name 값은 모두 `null`이 되어버리는 대참사가 발생합니다.

이때의 스택 상태를 머릿속으로 그려보면 다음과 같습니다.


// 잘못된 구현으로 인해 이름표(name)를 잃어버린 라우트 스택
+-------------------------------------------------+
| MaterialPageRoute(name: null, child: DetailPage) | <-- 현재 화면 (Top)
+-------------------------------------------------+
|  MaterialPageRoute(name: null, child: HomePage)  |
+-------------------------------------------------+

이 상태에서 popUntil(ModalRoute.withName('/home'))을 실행하면 어떤 일이 벌어질까요? Flutter는 스택의 최상단 라우트(DetailPage)의 이름을 확인합니다. null입니다. '/home'이 아니므로 pop 합니다. DetailPage가 사라지고 HomePage가 나타납니다. 다음으로, 이제 최상단이 된 HomePage 라우트의 이름을 확인합니다. 역시 `null`입니다. '/home'이 아니므로 또 pop 합니다. 결국 스택의 모든 라우트를 다 확인해도 이름이 '/home'인 라우트를 영원히 찾지 못하고, popUntil은 스택의 바닥까지 모든 것을 pop 해버리거나(앱이 종료될 수 있음) 아무것도 하지 못하고 종료되는 예기치 않은 결과를 낳게 됩니다.

✅ 올바른 `onGenerateRoute` 구현: `settings`를 명시적으로 전달하기

이 골치 아픈 문제를 해결하는 방법은 놀랍도록 간단합니다. onGenerateRoute에서 새로운 `Route` 객체(일반적으로 MaterialPageRoute 또는 CupertinoPageRoute)를 직접 생성하고, 그 생성자에 파라미터로 받은 settings 객체를 그대로 전달해주기만 하면 됩니다.


// main.dart
MaterialApp(
  initialRoute: '/home',
  onGenerateRoute: (settings) { // <-- (1) pushNamed로부터 settings 객체를 받습니다.
    switch (settings.name) {
      case '/home':
        return MaterialPageRoute(
          builder: (context) => HomePage(),
          settings: settings, // <-- (2) 받은 settings를 그대로 전달! 이것이 핵심입니다.
        );
      case '/detail':
        final args = settings.arguments as Map<String, dynamic>;
        return MaterialPageRoute(
          builder: (context) => DetailPage(productId: args['id']),
          settings: settings, // <-- (2) 여기도 마찬가지로 빠짐없이 전달!
        );
      default:
        // 정의되지 않은 라우트에 대한 처리
        return MaterialPageRoute(
          builder: (context) => NotFoundPage(),
          settings: settings, // 예외 처리 페이지에도 전달하는 것이 좋습니다.
        );
    }
  },
)

이렇게 단 한 줄, settings: settings를 추가하는 것만으로 모든 것이 달라집니다. 이제 스택에 새로운 라우트가 쌓일 때마다, 해당 라우트는 pushNamed가 호출될 때 부여받은 자신의 이름표와 인자들을 온전히 간직하게 됩니다. 이제 스택의 상태는 우리가 처음부터 기대했던 이상적인 모습이 됩니다.


// 올바른 구현으로 각자 이름표(name)를 가진 라우트 스택
+---------------------------------------------------+
| MaterialPageRoute(name: '/detail', child: DetailPage) | <-- 현재 화면 (Top)
+---------------------------------------------------+
|  MaterialPageRoute(name: '/home', child: HomePage)   |
+---------------------------------------------------+

이 완벽한 상태에서 popUntil(ModalRoute.withName('/home'))을 실행하면, Flutter는 최상단 라우트(DetailPage)의 이름을 확인합니다. '/detail'이므로 pop 합니다. 다음 라우트(HomePage)의 이름을 확인합니다. '/home'입니다. 드디어 찾고자 하는 이름과 일치했으므로, popUntil은 그 즉시 pop 동작을 멈춥니다. 그 결과, DetailPage는 스택에서 사라지고 우리가 원했던 HomePage가 화면에 나타나게 됩니다.

실전 시나리오: 전체 예제 코드로 직접 확인하기

백 마디 설명보다 한 번의 실행이 더 효과적입니다. 직접 실행해볼 수 있는 전체 예제 코드를 통해 이 개념을 확실하게 다져보겠습니다. 시나리오는 간단합니다: A(HomePage) → B(IntermediatePage) → C(FinalPage) 순서로 이동한 후, 마지막 C 페이지에서 버튼 하나로 A 페이지까지 한 번에 돌아오는 상황입니다.

`main.dart` - 라우팅 설정의 핵심

MaterialApponGenerateRoute를 올바르게 설정하는 파일입니다. 모든 MaterialPageRoutesettings를 전달하는 부분을 유심히 살펴보세요.


import 'package:flutter/material.dart';
import 'home_page.dart';
import 'intermediate_page.dart';
import 'final_page.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter PopUntil Demo',
      // 앱이 처음 시작될 때 표시할 라우트 이름
      initialRoute: HomePage.routeName, 
      
      // onGenerateRoute를 사용하여 동적으로 라우트를 생성
      onGenerateRoute: (settings) {
        // 디버깅을 위해 현재 어떤 라우트가 생성되는지 출력해봅니다.
        debugPrint('Navigating to route: ${settings.name}');
        
        switch (settings.name) {
          case HomePage.routeName:
            return MaterialPageRoute(
              builder: (_) => const HomePage(),
              settings: settings, // ★★★★★ 이 한 줄이 모든 것을 결정합니다!
            );
          case IntermediatePage.routeName:
            return MaterialPageRoute(
              builder: (_) => const IntermediatePage(),
              settings: settings, // ★★★★★ 빠짐없이 모든 라우트에 적용해야 합니다.
            );
          case FinalPage.routeName:
            return MaterialPageRoute(
              builder: (_) => const FinalPage(),
              settings: settings, // ★★★★★ 실수로라도 누락하면 안 됩니다.
            );
          default:
            // 정의되지 않은 라우트 요청 시 예외 페이지 또는 홈으로 보낼 수 있습니다.
            return MaterialPageRoute(
              builder: (_) => Scaffold(
                body: Center(
                  child: Text('404: Page not found for route ${settings.name}'),
                ),
              ),
              settings: settings,
            );
        }
      },
    );
  }
}

`home_page.dart` - 시작 페이지 A

앱의 시작점이 되는 페이지입니다. 다음 페이지로 이동하는 버튼이 있습니다. 라우트 이름을 하드코딩하지 않고 static const 상수로 관리하는 것이 모범 사례입니다.


import 'package:flutter/material.dart';
import 'intermediate_page.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});
  
  // 오타를 방지하고 코드의 유지보수성을 높이기 위해 라우트 이름을 상수로 관리합니다.
  static const String routeName = '/';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home Page (A)'),
        backgroundColor: Colors.indigo,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('여기는 첫 번째 페이지입니다.', style: TextStyle(fontSize: 22)),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // 이름 기반 라우팅을 사용하여 다음 페이지로 이동합니다.
                Navigator.of(context).pushNamed(IntermediatePage.routeName);
              },
              child: const Text('Go to Intermediate Page (B)'),
            ),
          ],
        ),
      ),
    );
  }
}

`intermediate_page.dart` - 중간 페이지 B

중간 다리 역할을 하는 두 번째 페이지입니다.


import 'package:flutter/material.dart';
import 'final_page.dart';

class IntermediatePage extends StatelessWidget {
  const IntermediatePage({super.key});
  
  static const String routeName = '/intermediate';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Intermediate Page (B)'),
        backgroundColor: Colors.teal,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('여기는 중간 페이지입니다.', style: TextStyle(fontSize: 22)),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                Navigator.of(context).pushNamed(FinalPage.routeName);
              },
              child: const Text('Go to Final Page (C)'),
            ),
          ],
        ),
      ),
    );
  }
}

`final_page.dart` - 최종 페이지 C

popUntil을 사용하여 홈으로 한 번에 돌아가는 로직이 포함된 마지막 페이지입니다.


import 'package:flutter/material.dart';
import 'home_page.dart';

class FinalPage extends StatelessWidget {
  const FinalPage({super.key});
  
  static const String routeName = '/final';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Final Page (C)'),
        backgroundColor: Colors.orange,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('여기는 마지막 페이지입니다.', style: TextStyle(fontSize: 22)),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // HomePage.routeName('/')을 만날 때까지 스택에서 페이지를 제거합니다.
                // onGenerateRoute에서 settings를 잘 전달했기 때문에 완벽하게 동작합니다.
                Navigator.of(context).popUntil(ModalRoute.withName(HomePage.routeName));
              },
              style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
              child: const Text(
                'Go Back to Home (A) AT ONCE!', 
                style: TextStyle(color: Colors.white)
              ),
            ),
          ],
        ),
      ),
    );
  }
}

위 코드를 직접 여러분의 Flutter 환경에서 실행해 보세요. A → B → C로 순서대로 이동한 후, 마지막 페이지의 'Go Back to Home' 버튼을 눌렀을 때, 중간 페이지(B)를 건너뛰고 바로 홈 페이지(A)로 깔끔하게 돌아가는 것을 명확하게 확인할 수 있습니다. 이제 popUntil이 왜 onGenerateRoutesettings 전달에 그토록 의존하는지 완벽하게 이해하셨을 겁니다.

popUntil 문제 해결을 위한 최종 자가 진단 체크리스트

만약 이 글을 읽고도 여전히 popUntil이 제대로 동작하지 않는다면, 다음 체크리스트를 통해 문제를 체계적으로 진단해 보세요.

  • ✅ `onGenerateRoute`의 모든 분기에서 `settings`를 전달하고 있는가?

    이 글에서 백 번 강조해도 지나치지 않은 부분입니다. 당신의 onGenerateRoute 함수 내 switch문의 모든 casedefault 구문에서 return MaterialPageRoute(..., settings: settings); 코드가 포함되어 있는지 다시 한번 꼼꼼히 확인하세요. 이것이 문제의 99%를 차지하는 원인입니다.

  • ✅ 라우트 이름에 오타는 없는가?

    popUntil(ModalRoute.withName('/home'))과 실제 라우트 이름이 static const String routeName = '/Home'; 처럼 대소문자가 다르거나, 슬래시(/) 유무 등 미세한 오타가 있는지 확인하세요. 이런 실수를 원천 봉쇄하기 위해, 위 예제 코드처럼 각 페이지 클래스에 static const로 라우트 이름을 정의하고, pushNamedpopUntil에서 이 상수를 직접 가져다 쓰는 습관을 들이는 것이 매우 중요합니다. 절대로 문자열을 직접 입력하지 마세요.

  • ✅ 돌아가려는 목표 라우트가 스택에 확실히 존재하는가?

    예를 들어, 로그인 후 Navigator.pushNamedAndRemoveUntil(context, '/home', (route) => false); 코드를 사용하여 홈 화면으로 이동했다면, (route) => false 조건 때문에 '/home' 이전의 모든 스택(로그인 화면 등)이 완전히 삭제됩니다. 이 상태에서는 더 이상 이전 화면으로 popUntil을 할 수 없습니다. 돌아가고 싶은 페이지가 스택에서 이미 제거된 것은 아닌지 내비게이션 로직을 전체적으로 검토해야 합니다.

  • ✅ 사용하고 있는 `BuildContext`가 올바른 위치에 있는가?

    매우 드문 경우지만, MaterialApp 위젯보다 상위 트리(ancestor)에 있는 `context`를 사용하여 Navigator를 호출하려고 하면 'Navigator not found' 에러가 발생합니다. 일반적으로 Scaffold 내부의 build 메소드에서 얻은 `context`나 Builder 위젯을 통해 얻은 `context`를 사용하면 이 문제는 거의 발생하지 않습니다.

  • ✅ Navigator 2.0 또는 GoRouter와 같은 라우팅 패키지를 혼용하고 있는가?

    서두에서 언급했듯이, GoRouter와 같은 선언형 라우팅 라이브러리는 자체적인 내비게이션 상태 관리 시스템을 가지고 있습니다. 이런 환경에서 Navigator 1.0의 API인 popUntil을 직접 호출하면 상태 불일치가 발생하여 예상치 못한 동작을 일으킬 수 있습니다. 사용 중인 라이브러리의 공식 문서를 참고하여, 해당 라이브러리가 제공하는 올바른 내비게이션 API(예: GoRouter의 context.go() 또는 context.pop())를 일관되게 사용해야 합니다.

디버깅 팁: 내비게이션 스택 엿보기
라우트 이름이 제대로 설정되었는지 확신이 서지 않을 때는, `onGenerateRoute` 함수 맨 위에 `print('Generating route: ${settings.name}');` 코드를 추가해 보세요. 페이지를 이동할 때마다 콘솔에 라우트 이름이 출력되므로, 이름이 올바르게 전달되고 있는지 즉시 확인할 수 있습니다. 또한 Flutter DevTools의 'Logging' 탭에서도 내비게이션 이벤트를 추적할 수 있습니다.

결론: 견고한 내비게이션의 초석은 기본 원칙 준수

Flutter의 popUntil은 복잡하게 얽힌 사용자 동선을 단 한 줄의 코드로 깔끔하게 정리해주는 매우 강력하고 우아한 도구입니다. 하지만 이 기능이 예상대로 동작하지 않을 때, 우리는 종종 Flutter 프레임워크 자체의 버그를 의심하거나, 복잡한 상태 관리 라이브러리를 도입하는 등 먼 길을 돌아가며 해결책을 찾아 헤매곤 합니다. 그러나 대부분의 경우, 문제의 근원은 프레임워크의 버그가 아닌 우리가 작성한 코드의 아주 기본적인 부분, 즉 onGenerateRoute에서 RouteSettings 객체를 올바르게 전달하지 않은 사소하지만 치명적인 실수에서 비롯됩니다.

오늘 우리가 깊이 파헤쳐 본 바와 같이, onGenerateRoutesettings 파라미터는 단순한 이름표나 데이터 덩어리가 아니라, 내비게이션 스택의 각 페이지가 자신의 정체성을 유지하고 서로를 식별할 수 있게 해주는 핵심적인 역할을 합니다. 이 settings를 새로운 PageRoute에 충실히 전달하는 작은 습관 하나가, 예측 가능하고 안정적인 내비게이션 시스템을 구축하는 데 가장 중요한 초석이 됩니다. 이 원칙을 지킨다면, popUntil은 언제나 당신의 의도대로 충실하게 동작하는 든든한 아군이 되어줄 것입니다.

이제 Flutter 내비게이션의 핵심 원리를 완벽하게 이해하셨으니, 자신감을 갖고 더 멋지고 사용자 친화적인 앱을 만들어나가시길 바랍니다.

Post a Comment