Wednesday, February 12, 2020

Flutter popUntil이 동작하지 않는다면? onGenerateRoute 설정부터 확인하세요

Flutter로 앱을 개발하다 보면 복잡한 페이지 이동 로직을 처리해야 하는 순간이 반드시 찾아옵니다. 예를 들어, 사용자가 '상품 목록(A) -> 상품 상세(B) -> 장바구니(C) -> 주문(D)' 순서로 화면을 이동했다고 가정해 봅시다. 주문 완료 후, 사용자를 앱의 메인 화면(A)으로 한 번에 되돌려 보내고 싶을 때 어떻게 해야 할까요? 뒤로 가기 버튼을 여러 번 누르게 하는 것은 끔찍한 사용자 경험을 초래합니다. 이럴 때 Flutter의 내비게이션 스택을 효과적으로 제어하는 강력한 무기가 바로 popUntil 메소드입니다.

하지만 많은 개발자들이 popUntil이 예상대로 동작하지 않아 골머리를 앓는 경우가 많습니다. 분명 코드는 제대로 작성한 것 같은데, 아무런 반응이 없거나 얘기치 않은 동작을 보일 때가 있죠. 이 글에서는 popUntil이 제대로 작동하지 않는 가장 흔한 원인과 그 해결책을 onGenerateRoute와 연관 지어 심도 있게 파헤쳐 보겠습니다. 이 글을 끝까지 읽으신다면, 더 이상 내비게이션 문제로 시간을 낭비하는 일은 없을 것입니다.

Flutter Navigation Concept
Flutter의 강력한 내비게이션, 올바르게 이해하고 사용해야 합니다.

1. Flutter 내비게이션의 기본: 스택(Stack)의 이해

popUntil의 동작 원리를 이해하기 전에, Flutter의 내비게이션이 어떻게 작동하는지 기본 개념부터 짚고 넘어가야 합니다. Flutter의 기본 내비게이션 시스템(Navigator 1.0)은 **스택(Stack)** 자료구조를 기반으로 합니다. 스택은 '나중에 들어온 것이 먼저 나가는'(Last-In, First-Out, LIFO) 특징을 가집니다. 접시를 쌓는 것을 생각하면 쉽습니다.

  • push: 새로운 페이지(Route)를 스택의 가장 위에 쌓는 행위입니다. (Navigator.push(), Navigator.pushNamed() 등) 마치 새 접시를 접시 더미 맨 위에 올려놓는 것과 같습니다.
  • pop: 스택의 가장 위에 있는 페이지를 제거하고 이전 페이지를 보여주는 행위입니다. (Navigator.pop()) 접시 더미 맨 위의 접시를 치우는 것과 같죠.

예를 들어, Home(A) -> Profile(B) -> Settings(C) 순으로 페이지를 이동했다면, 내비게이션 스택은 아래와 같은 상태가 됩니다.


| Settings (C) | <-- 현재 화면 (가장 위)
+--------------+
| Profile (B)  |
+--------------+
|   Home (A)   | <-- 가장 아래
+--------------+

여기서 Navigator.pop(context)을 호출하면 Settings(C)가 스택에서 제거되고 Profile(B) 화면이 보이게 됩니다. 한 번 더 pop하면 Home(A) 화면으로 돌아가겠죠.

2. `popUntil`: 여러 페이지를 한번에 뛰어넘는 기술

이제 popUntil이 등장할 차례입니다. 만약 위 예시의 Settings(C) 화면에서 Home(A) 화면으로 즉시 돌아가고 싶다면 어떻게 해야 할까요? pop()을 두 번 호출하는 것은 지저분하고, 스택의 깊이를 항상 알고 있어야 한다는 단점이 있습니다. 이럴 때 popUntil은 매우 우아한 해결책을 제공합니다.

popUntil은 특정 조건(predicate)이 참(true)이 될 때까지 스택에서 페이지를 계속해서 pop하는 메소드입니다. 즉, "내가 원하는 페이지가 나올 때까지 모든 페이지를 닫아줘!"라는 명령인 셈입니다.

가장 일반적으로 사용되는 형태는 ModalRoute.withName()과 함께 쓰는 것입니다.


// 현재 페이지에서 '/home' 이라는 이름을 가진 라우트가 나올 때까지 pop 합니다.
Navigator.of(context).popUntil(ModalRoute.withName('/home'));

이 한 줄의 코드는 Settings(C)와 Profile(B)를 스택에서 모두 제거하고 Home(A) 페이지만을 남겨줍니다. 매우 간결하고 강력하죠. 하지만 이 강력한 기능이 동작하지 않는다면, 문제는 십중팔구 **라우트의 '이름'**과 관련이 있습니다.


3. `popUntil`을 배신하는 주범: `onGenerateRoute`의 함정

Flutter에서 라우트(페이지 이동 경로)를 정의하는 방법은 크게 두 가지입니다.

  1. routes 맵 사용: MaterialApproutes 속성에 Map<String, WidgetBuilder> 형태로 라우트 이름과 위젯을 미리 정의하는 정적인 방식입니다. 간단한 앱에 적합합니다.
  2. onGenerateRoute 콜백 사용: MaterialApponGenerateRoute 속성에 함수를 제공하는 동적인 방식입니다. 라우트가 요청될 때마다 이 함수가 호출되며, 라우트 이름이나 전달된 인자(arguments)에 따라 다른 페이지를 동적으로 생성할 수 있어 훨씬 유연하고 확장성이 높습니다. 대부분의 실무 프로젝트에서는 이 방식을 사용합니다.

문제는 바로 이 onGenerateRoute를 사용할 때 발생하기 쉽습니다. 많은 개발자들이 onGenerateRoute를 아래와 같이 '간결하게' 작성하는 실수를 저지릅니다.

❌ 잘못된 `onGenerateRoute` 구현 예시 (popUntil이 동작하지 않는 코드)


// main.dart
MaterialApp(
  initialRoute: '/',
  onGenerateRoute: (settings) {
    switch (settings.name) {
      case HomePage.routeName: // '/home'
        return HomePage(); // <-- 바로 이 부분이 문제입니다!
      case DetailPage.routeName: // '/detail'
        return DetailPage(); // <-- 여기도 마찬가지입니다.
      // ... 다른 라우트들
      default:
        return HomePage();
    }
  },
)

// 여기서 HomePage()를 직접 반환하는 것은 Route 객체가 아닙니다.
// Flutter가 내부적으로 이 위젯을 Route로 감싸주긴 하지만,
// 중요한 '설정' 정보가 누락됩니다.

얼핏 보기에는 아무런 문제가 없어 보입니다. 실제로 Navigator.pushNamed(context, '/detail')과 같은 코드는 정상적으로 동작하여 페이지 이동이 잘 됩니다. 하지만 이 상태에서 popUntil(ModalRoute.withName('/home'))을 호출하면 아무 일도 일어나지 않습니다.

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

onGenerateRoute 콜백은 RouteSettings 타입의 객체를 파라미터로 받습니다. 이 settings 객체에는 매우 중요한 정보들이 담겨 있습니다.

  • name: 요청된 라우트의 이름 (예: '/home', '/detail')
  • arguments: pushNamed를 통해 전달된 데이터

popUntil(ModalRoute.withName('/home'))이 동작하려면, 내비게이션 스택 안에 있는 라우트들이 각각 자신의 이름(settings.name)을 제대로 가지고 있어야 합니다. ModalRoute.withName('/home')은 스택을 위에서부터 훑으면서 "이 라우트의 이름이 '/home'인가?"를 계속해서 확인하기 때문입니다.

하지만 위 잘못된 예시처럼 onGenerateRoute에서 위젯(HomePage())을 직접 반환하면, Flutter는 이 위젯을 MaterialPageRoute로 감싸주기는 하지만, 원래 onGenerateRoute로 전달되었던 settings 객체를 이 새로운 MaterialPageRoute에 전달해주지 않습니다. 그 결과, 스택에 쌓이는 페이지 라우트들의 settings.name 값은 모두 `null`이 되어버립니다.

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


// 잘못된 구현으로 인해 스택에 쌓인 라우트들의 상태
+---------------------------------+
| Route (name: null, child: DetailPage) | <-- 현재 화면
+---------------------------------+
| Route (name: null, child: HomePage)   |
+---------------------------------+

이 상태에서 popUntil(ModalRoute.withName('/home'))을 실행하면, Flutter는 첫 번째 라우트의 이름을 확인합니다. `null`입니다. '/home'이 아니므로 pop 합니다. 다음 라우트의 이름을 확인합니다. 역시 `null`입니다. 결국 스택의 모든 라우트를 다 확인해도 이름이 '/home'인 라우트를 찾지 못하고, popUntil은 아무것도 하지 못하고 종료되거나 앱이 꺼지는 등의 예기치 않은 결과를 낳게 됩니다.

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

이 문제를 해결하는 방법은 매우 간단합니다. onGenerateRoute에서 새로운 `Route` 객체(주로 MaterialPageRoute)를 생성할 때, 파라미터로 받은 settings 객체를 그대로 전달해주기만 하면 됩니다.


// main.dart
MaterialApp(
  initialRoute: '/',
  onGenerateRoute: (settings) { // <-- (1) settings 객체를 받습니다.
    switch (settings.name) {
      case HomePage.routeName: // '/'
        return MaterialPageRoute(
          builder: (_) => HomePage(),
          settings: settings, // <-- (2) 받은 settings를 그대로 전달!
        );
      case DetailPage.routeName: // '/detail'
        return MaterialPageRoute(
          builder: (_) => DetailPage(),
          settings: settings, // <-- (2) 여기도 마찬가지로 전달!
        );
      // ... 다른 라우트들
      default:
        return MaterialPageRoute(
          builder: (_) => HomePage(),
          settings: settings,
        );
    }
  },
)

이렇게 수정하면, 스택에 쌓이는 모든 `MaterialPageRoute`는 자신만의 고유한 `settings`를 가지게 됩니다. 이제 스택의 상태는 우리가 기대하는 대로 구성됩니다.


// 올바른 구현으로 스택에 쌓인 라우트들의 상태
+---------------------------------------------+
| Route (name: '/detail', child: DetailPage) | <-- 현재 화면
+---------------------------------------------+
| Route (name: '/', child: HomePage)       |
+---------------------------------------------+

이 상태에서 popUntil(ModalRoute.withName('/'))을 실행하면, Flutter는 최상단 라우트의 이름을 확인합니다. '/detail'이므로 pop 합니다. 다음 라우트의 이름을 확인합니다. '/'입니다. 드디어 찾고자 하는 이름과 일치했으므로, pop을 멈춥니다. 결과적으로 DetailPage는 사라지고 HomePage가 화면에 나타나게 됩니다.


4. 전체 동작 예제 코드로 완벽하게 이해하기

백문이 불여일견입니다. 직접 실행해볼 수 있는 전체 예제 코드를 통해 개념을 확실히 다져보겠습니다. A(HomePage) -> B(中間Page) -> C(FinalPage)로 이동한 후, C에서 A로 한 번에 돌아오는 시나리오입니다.

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

MaterialApponGenerateRoute를 설정하는 파일입니다. 올바른 방식으로 settings를 전달하는 것이 핵심입니다.


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) {
        // 이 settings 객체를 반드시 MaterialPageRoute에 전달해야 합니다.
        print('Navigating to: ${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

앱의 시작 페이지입니다. 다음 페이지로 이동하는 버튼이 있습니다.


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.blue,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('여기는 첫 번째 페이지입니다.', style: TextStyle(fontSize: 20)),
            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.green,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('여기는 중간 페이지입니다.', style: TextStyle(fontSize: 20)),
            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.red,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('여기는 마지막 페이지입니다.', style: TextStyle(fontSize: 20)),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // HomePage.routeName('/')을 만날 때까지 스택에서 페이지를 제거합니다.
                Navigator.of(context).popUntil(ModalRoute.withName(HomePage.routeName));
              },
              style: ElevatedButton.styleFrom(backgroundColor: Colors.purple),
              child: const Text('Go Back to Home (A) AT ONCE!', style: TextStyle(color: Colors.white)),
            ),
          ],
        ),
      ),
    );
  }
}

위 코드를 직접 실행해 보면, A -> B -> C로 이동한 후 마지막 페이지의 버튼을 눌렀을 때 중간 페이지(B)를 건너뛰고 바로 홈 페이지(A)로 돌아가는 것을 확인할 수 있습니다. 이제 `popUntil`이 어떻게 `onGenerateRoute`와 긴밀하게 협력하는지 명확하게 이해하셨을 겁니다.


5. `popUntil` 문제 해결을 위한 최종 체크리스트

만약 여전히 `popUntil`에 문제가 있다면, 다음 체크리스트를 통해 문제를 진단해 보세요.

  • `onGenerateRoute`에서 `settings`를 전달하고 있는가?

    이 글에서 가장 강조한 부분입니다. `return MaterialPageRoute(..., settings: settings);` 구문이 모든 case에 포함되어 있는지 다시 한번 확인하세요. 이것이 99%의 원인입니다.

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

    popUntil(ModalRoute.withName('/home'))과 `static const String routeName = '/Home';` 처럼 대소문자가 다르거나, 슬래시(`/`) 유무 등 미세한 오타가 있는지 확인하세요. 이 때문에 라우트 이름을 각 페이지에 `static const`로 정의하고 가져다 쓰는 것이 안전합니다.

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

    예를 들어, Navigator.pushAndRemoveUntil을 사용하여 특정 페이지로 이동했다면, 그 이전의 스택은 모두 삭제됩니다. 돌아가고 싶은 페이지가 스택에서 이미 제거된 것은 아닌지 확인해야 합니다.

  • `Context`가 올바른 위치에 있는가?

    드문 경우지만, `Navigator`를 찾을 수 없는 `context`를 사용할 때 문제가 발생할 수 있습니다. 일반적으로 `Scaffold` 내부의 `build` 메소드에서 얻은 `context`를 사용하면 안전합니다.

  • Navigator 2.0 또는 GoRouter 등을 사용하고 있는가?

    만약 GoRouter와 같은 선언형 라우팅 패키지를 사용하고 있다면, `popUntil` 대신 해당 패키지가 제공하는 고유한 내비게이션 API(예: `context.go('/')`)를 사용해야 합니다. 다른 패러다임의 라우팅 시스템을 혼용하면 예기치 않은 동작이 발생할 수 있습니다.

결론: 기본에 충실한 코드가 버그를 막는다

Flutter의 popUntil은 복잡한 사용자 동선을 깔끔하게 정리해주는 매우 유용한 도구입니다. 하지만 이 기능이 동작하지 않을 때, 우리는 종종 Flutter 프레임워크 자체의 버그를 의심하거나 복잡한 해결책을 찾아 헤매곤 합니다. 그러나 대부분의 경우, 문제는 우리가 작성한 코드의 아주 기본적인 부분, 즉 `onGenerateRoute`에서 `RouteSettings`를 올바르게 전달하지 않은 사소한 실수에서 비롯됩니다.

오늘 살펴본 바와 같이, `onGenerateRoute`의 `settings` 파라미터는 단순한 이름표가 아니라, 내비게이션 스택의 각 페이지가 자신의 정체성을 유지하게 해주는 핵심적인 역할을 합니다. 이 `settings`를 새로운 `PageRoute`에 충실히 전달하는 습관을 들인다면, `popUntil`은 언제나 당신의 의도대로 충실하게 동작하는 든든한 아군이 되어줄 것입니다.

이제 내비게이션 로직에 자신감을 갖고, 더 멋지고 사용자 친화적인 앱을 만들어나가시길 바랍니다.


0 개의 댓글:

Post a Comment