애플리케이션의 본질은 사용자와의 상호작용이며, 이 상호작용의 핵심에는 '화면 전환', 즉 네비게이션이 자리 잡고 있습니다. 사용자가 버튼을 누르고, 목록의 항목을 선택하고, 특정 기능으로 진입하는 모든 과정은 네비게이션을 통해 이루어집니다. Flutter 프레임워크 초기부터 제공된 `Navigator.push`와 `Navigator.pop`으로 대표되는 명령형(Imperative) 방식의 API, 즉 Navigator 1.0은 간단하고 직관적이어서 많은 개발자들이 쉽게 앱의 화면 흐름을 구현할 수 있도록 도왔습니다. 하지만 애플리케이션이 복잡해짐에 따라 이러한 명령형 방식의 한계가 드러나기 시작했습니다.
중첩된 라우팅 구조, 웹 브라우저의 주소 표시줄 동기화, 플랫폼의 뒤로 가기 버튼에 대한 정교한 제어, 그리고 외부 링크를 통해 앱의 특정 페이지로 직접 진입하는 딥 링킹(Deep Linking)과 같은 고급 시나리오들은 Navigator 1.0만으로는 처리하기가 까다로웠습니다. 근본적으로 Navigator 1.0은 "지금 당장 이 화면으로 이동해" 혹은 "현재 화면을 닫아"와 같이 네비게이션 스택을 직접 조작하는 방식이었기 때문에, 애플리케이션의 현재 상태와 네비게이션 스택의 상태를 일치시키고 관리하는 책임이 온전히 개발자에게 있었습니다. 이는 상태 불일치 버그의 원인이 되기도 했습니다.
이러한 문제들을 해결하기 위해 Flutter 팀은 새로운 패러다임의 네비게이션 API, Navigator 2.0을 도입했습니다. Navigator 2.0은 '선언형(Declarative)' 접근 방식을 채택합니다. 이는 "이 화면으로 이동해"라고 명령하는 대신, "애플리케이션의 현재 상태가 이러하니, 네비게이션 스택은 이런 모습이어야 한다"고 선언하는 방식입니다. 즉, 네비게이션의 상태를 애플리케이션의 핵심 상태(State)의 파생물로 취급함으로써, 상태와 UI를 항상 동기화하고 복잡한 시나리오를 더욱 견고하고 예측 가능하게 처리할 수 있도록 설계되었습니다. 이 글에서는 Navigator 1.0의 한계를 되짚어보고, Navigator 2.0의 선언적 패러다임이 무엇인지, 그리고 그 핵심을 이루는 구성 요소들을 심도 있게 분석하며 실제 구현 예제를 통해 그 작동 원리를 명확하게 이해해 보겠습니다.
패러다임의 전환: 명령형에서 선언형으로
Navigator 2.0을 이해하기 위한 첫걸음은 명령형과 선언형 프로그래밍 패러다임의 차이를 네비게이션 관점에서 파악하는 것입니다. 이 둘의 차이는 단순히 API 사용법의 차이를 넘어, 애플리케이션의 흐름을 설계하는 근본적인 사고방식의 차이를 의미합니다.
Navigator 1.0: 명령형 네비게이션의 직관성과 한계
Navigator 1.0은 명령형(Imperative) 패러다임에 기반합니다. 개발자는 네비게이션 스택을 직접 변경하는 '명령'을 코드에 명시합니다. 예를 들어, 사용자가 로그인 버튼을 눌렀을 때 홈 화면으로 이동시키는 코드는 다음과 같습니다.
// 사용자가 '로그인' 버튼을 클릭했을 때 호출되는 함수
void onLoginButtonPressed(BuildContext context) {
// 'HomeScreen'으로 이동하라는 명시적인 명령
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
}
// 사용자가 프로필 화면에서 '뒤로 가기'를 눌렀을 때
void onBackButtonPressed(BuildContext context) {
// 현재 화면을 스택에서 제거하라는 명시적인 명령
Navigator.pop(context);
}
이 방식의 가장 큰 장점은 단순함과 직관성입니다. 코드 한 줄로 특정 화면으로 이동하거나 이전 화면으로 돌아가는 동작을 구현할 수 있어 배우기 쉽고, 간단한 앱에서는 매우 효율적입니다. 하지만 앱의 규모가 커지고 네비게이션 로직이 복잡해질수록 다음과 같은 문제들이 발생합니다.
- 상태 관리의 분리: 앱의 비즈니스 로직 상태(예: 로그인 여부, 선택된 아이템 ID)와 네비게이션 스택의 상태가 분리됩니다. 예를 들어, 사용자가 로그아웃했을 때, 단순히 상태 변수만 바꾸는 것만으로는 부족하고, 로그인해야만 접근 가능한 모든 화면들을 스택에서 수동으로 제거(`pushAndRemoveUntil` 등)하는 명령을 직접 실행해야 합니다. 이를 잊으면 상태와 UI가 불일치하는 버그가 발생할 수 있습니다.
- 웹 및 딥 링킹의 어려움: 웹 브라우저의 주소 표시줄은 현재 화면의 상태를 반영해야 합니다. `Navigator.push`는 URL을 변경하지 않습니다. 따라서 브라우저의 '새로고침' 버튼을 누르면 앱의 초기 상태로 돌아가 버립니다. 또한 `/products/123`과 같은 특정 URL로 직접 진입했을 때, 해당하는 화면 스택(`[ProductListScreen, ProductDetailScreen(id: 123)]`)을 재구성하는 로직을 별도로 복잡하게 구현해야 합니다.
- 유연성 부족: 네비게이션 스택의 중간에 있는 페이지를 제거하거나 순서를 바꾸는 등 스택 전체를 동적으로 재구성하는 작업이 매우 번거롭고 오류가 발생하기 쉽습니다.
Navigator 2.0: 선언형 네비게이션의 견고함
Navigator 2.0은 Flutter의 핵심 철학인 선언형(Declarative) UI 패러다임을 네비게이션에까지 확장합니다. 선언형 UI가 "현재 상태가 이러하니, 위젯 트리는 이런 모습이어야 한다"고 선언하는 것처럼, 선언형 네비게이션은 "현재 앱의 상태가 이러하니, 네비게이션 스택은 이런 페이지들의 목록이어야 한다"고 선언합니다.
개발자는 더 이상 `push`나 `pop` 같은 명령을 직접 호출하지 않습니다. 대신, 앱의 상태를 변경하면 그 상태에 따라 그려져야 할 페이지들의 목록(`List<Page>`)을 시스템에 제공합니다. 그러면 Flutter 프레임워크가 이전 페이지 목록과 현재 페이지 목록을 비교하여 필요한 애니메이션과 함께 화면 전환을 자동으로 처리해 줍니다.
// 앱의 네비게이션 상태를 관리하는 클래스 (예시)
class AppState extends ChangeNotifier {
bool _isLoggedIn = false;
String? _selectedBookId;
bool get isLoggedIn => _isLoggedIn;
String? get selectedBookId => _selectedBookId;
void login() {
_isLoggedIn = true;
notifyListeners(); // 상태 변경 알림
}
void selectBook(String bookId) {
_selectedBookId = bookId;
notifyListeners(); // 상태 변경 알림
}
void logout() {
_isLoggedIn = false;
_selectedBookId = null;
notifyListeners(); // 상태 변경 알림
}
}
// ... RouterDelegate의 build 메서드 내부 ...
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
// 앱의 현재 상태(appState)를 기반으로 페이지 목록을 '선언'
pages: [
MaterialPage(child: HomeScreen(onBookTap: (book) => appState.selectBook(book.id))),
if (appState.selectedBookId != null)
MaterialPage(child: BookDetailsScreen(id: appState.selectedBookId!)),
if (!appState.isLoggedIn)
MaterialPage(child: LoginScreen(onLogin: () => appState.login())),
],
onPopPage: (route, result) {
// ... 뒤로 가기 처리 로직 ...
},
);
}
위 예시 코드에서 `build` 메서드는 앱의 상태(`isLoggedIn`, `selectedBookId`)가 변경될 때마다 다시 호출됩니다. 개발자는 단지 상태에 따라 `pages` 목록을 어떻게 구성할지만 정의하면 됩니다. 예를 들어 `selectBook` 메서드가 호출되어 `_selectedBookId`가 설정되면, `build` 메서드가 다시 실행되고 `pages` 목록에 `BookDetailsScreen`이 포함됩니다. Flutter는 이 변화를 감지하고 `HomeScreen` 위로 `BookDetailsScreen`을 `push`하는 것과 같은 애니메이션을 자동으로 수행합니다. 사용자가 로그아웃하면 `isLoggedIn`이 `false`가 되고, `LoginScreen`이 스택의 최상단에 나타나게 됩니다.
이처럼 네비게이션 로직이 앱의 상태에 직접적으로 의존하게 되면서, Navigator 1.0의 문제점들이 자연스럽게 해결됩니다.
- 상태와 UI의 완벽한 동기화: 네비게이션 스택은 항상 앱 상태를 반영하는 '결과물'입니다. 따라서 상태와 네비게이션이 불일치하는 버그가 원천적으로 발생하기 어렵습니다.
- 웹 및 딥 링킹의 체계적인 지원: URL은 앱 상태를 표현하는 또 다른 방식일 뿐입니다. `/books/123`이라는 URL이 들어오면, 이를 파싱하여 앱 상태를 `selectedBookId = '123'`으로 설정합니다. 그러면 선언형 로직에 따라 자연스럽게 `[HomeScreen, BookDetailsScreen]` 스택이 구성됩니다. 반대로 앱 상태가 변경되면, 그에 맞는 URL을 생성하여 브라우저 주소 표시줄에 반영할 수 있습니다.
- 뛰어난 유연성과 제어력: 복잡한 조건에 따라 네비게이션 스택 전체를 원하는 대로 쉽게 재구성할 수 있습니다.
Navigator 2.0의 핵심 구성 요소 심층 분석
Navigator 2.0의 선언적 네비게이션 시스템은 여러 클래스가 유기적으로 협력하여 동작합니다. 처음에는 이들의 관계가 복잡하게 느껴질 수 있지만, 각자의 역할을 명확히 이해하면 전체 그림을 파악할 수 있습니다. 데이터의 흐름은 주로 다음과 같습니다.
(웹 브라우저/OS) → RouteInformationParser
→ (앱 상태) ↔ RouterDelegate
→ (UI: Navigator 위젯)
이제 각 구성 요소를 자세히 살펴보겠습니다.

Navigator 2.0의 주요 구성 요소와 데이터 흐름
1. Page: 라우트의 불변 설정 객체
`Page`는 Navigator 2.0에서 네비게이션 스택의 각 항목을 나타내는 기본 단위입니다. 이는 위젯이 아니라, 특정 라우트(`Route`)를 어떻게 생성할지에 대한 '설정' 또는 '청사진'을 담고 있는 불변(immutable) 객체입니다. `Navigator` 위젯은 `List<Page>`를 받아 이전 목록과 비교하고, 변경된 부분을 감지하여 실제 화면에 표시될 `Route`(예: `MaterialPageRoute`)를 생성하거나 제거합니다.
- 주요 역할: 라우트의 설정을 정의합니다 (예: 어떤 위젯을 보여줄지, 라우트의 고유 키는 무엇인지).
- 불변성: `Page` 객체는 불변이므로, Flutter의 위젯 트리 비교(diffing) 알고리즘이 효율적으로 작동할 수 있도록 돕습니다.
- `key`의 중요성: 각 `Page`에 `ValueKey`와 같은 고유한 키를 부여하는 것이 매우 중요합니다. Flutter는 이 키를 사용하여 페이지 목록이 변경되었을 때 어떤 페이지가 추가, 제거 또는 재정렬되었는지 식별합니다. 키가 없으면 Flutter는 페이지의 타입만으로 비교하게 되어 예기치 않은 동작을 유발할 수 있습니다.
- 구현체: Flutter는 기본적으로 `MaterialPage`와 `CupertinoPage`를 제공하며, 이들은 각각 플랫폼에 맞는 기본 전환 애니메이션을 포함하는 `MaterialPageRoute`와 `CupertinoPageRoute`를 생성합니다. 전환 애니메이션을 직접 제어하고 싶다면 `Page`를 상속하여 `createRoute` 메서드를 오버라이드하면 됩니다.
// HomePage를 위한 Page 객체 생성
const MaterialPage(
key: ValueKey('HomePage'), // 위젯 트리 재구성 시 식별을 위한 고유 키
child: HomePage(),
name: '/', // 디버깅 및 분석에 유용한 라우트 이름
);
// BookDetailsScreen을 위한 Page 객체 생성
MaterialPage(
key: ValueKey('BookDetailsPage_123'), // 동적인 ID를 포함한 고유 키
child: BookDetailsScreen(bookId: '123'),
name: '/book/123',
);
2. `RouteInformationParser`: OS와 앱 사이의 번역가
`RouteInformationParser`는 이름 그대로 '라우트 정보 파서'입니다. 이 클래스의 주된 역할은 운영체제(OS)로부터 받은 라우트 정보(예: 웹 브라우저의 URL)를 앱이 이해할 수 있는 데이터 구조로 변환하거나, 그 반대의 작업을 수행하는 것입니다. 즉, OS의 세계와 Flutter 앱의 세계를 연결하는 '번역가'입니다.
이 클래스는 추상 클래스이므로, 개발자는 자신의 앱에 맞는 파싱 로직을 직접 구현해야 합니다.
parseRouteInformation
(OS → 앱): 이 메서드는 OS로부터 `RouteInformation` 객체(주로 `location` 속성에 URL 문자열이 담겨 있음)를 받습니다. 개발자는 이 URL을 파싱하여 앱의 네비게ATION 상태를 나타내는 사용자 정의 객체(예: `BookRoutePath`)로 변환하여 `Future`로 반환합니다. 예를 들어 `'/book/123'`이라는 문자열을 `BookRoutePath.details(id: '123')` 객체로 변환합니다. 앱이 처음 시작되거나 웹에서 URL이 직접 입력될 때 호출됩니다.restoreRouteInformation
(앱 → OS): 이 메서드는 `RouterDelegate`가 제공하는 앱의 현재 라우트 상태 객체를 받아, OS가 이해할 수 있는 `RouteInformation` 객체로 다시 변환합니다. 이 결과는 주로 웹 브라우저의 주소 표시줄을 업데이트하는 데 사용됩니다. 예를 들어, `BookRoutePath.details(id: '123')` 객체를 `RouteInformation(location: '/book/123')`으로 변환합니다.
3. `RouterDelegate`: 네비게이션의 두뇌
`RouterDelegate`는 Navigator 2.0 시스템의 가장 핵심적인 부분으로, 네비게이션 로직의 '두뇌' 역할을 담당합니다. 이 클래스는 앱의 현재 상태를 기반으로 네비게이션 스택(`List<Page>`)을 구성하고, 시스템의 요청에 따라 상태를 업데이트하는 모든 책임을 집니다.
이 클래스 또한 추상 클래스이며 `ChangeNotifier`를 믹스인(mixin)하는 것이 일반적입니다. 상태가 변경될 때마다 `notifyListeners()`를 호출하여 `Router` 위젯에게 UI를 다시 빌드해야 함을 알립니다.
- 상태 관리: `RouterDelegate`는 현재 선택된 책 ID, 로그인 상태 등 네비게이션에 영향을 주는 앱의 상태를 직접 가지고 있거나, 외부 상태 관리 객체(Provider, BLoC 등)를 구독합니다.
build
메서드: 가장 중요한 메서드로, 현재 상태를 기반으로 `Navigator` 위젯을 생성하여 반환합니다. 이 `Navigator` 위젯의 `pages` 매개변수에 현재 상태에 맞는 `List<Page>`를 제공합니다. 이 메서드는 `notifyListeners()`가 호출될 때마다 다시 실행됩니다.setNewRoutePath
메서드: `RouteInformationParser`가 URL을 파싱한 후, 그 결과(사용자 정의 라우트 객체)를 이 메서드를 통해 `RouterDelegate`에 전달합니다. 델리게이트는 이 정보를 바탕으로 내부 상태를 업데이트하고 `notifyListeners()`를 호출하여 화면을 갱신해야 합니다.currentConfiguration
프로퍼티: 현재 앱의 라우트 상태를 나타내는 사용자 정의 객체를 반환합니다. 이 값은 `RouteInformationParser`의 `restoreRouteInformation` 메서드로 전달되어 URL을 복원하는 데 사용됩니다.PopNavigatorRouterDelegateMixin
: 이 믹스인은 디바이스의 뒤로 가기 버튼 이벤트를 `RouterDelegate`가 처리할 수 있도록 도와줍니다. 이 믹스인을 사용하면 `popRoute` 메서드를 구현하여 뒤로 가기 버튼을 눌렀을 때 어떤 동작을 할지(예: 상태 변경) 정의할 수 있습니다.
4. `Router`: 조립 및 연결
`Router` 위젯은 위에서 설명한 `RouteInformationParser`와 `RouterDelegate`를 하나로 묶어 네비게이션 시스템이 실제로 동작하도록 만드는 역할을 합니다. 일반적으로 개발자가 `Router` 위젯을 직접 사용하는 경우는 드물며, 대신 `MaterialApp.router` 생성자를 사용합니다.
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Navigator 2.0 Example',
// 우리가 직접 구현한 파서를 제공
routeInformationParser: MyRouteInformationParser(),
// 우리가 직접 구현한 델리게이트를 제공
routerDelegate: MyRouterDelegate(),
);
}
}
`MaterialApp.router`는 내부적으로 `Router` 위젯을 설정하고, OS로부터 라우팅 이벤트를 수신하여 `RouteInformationParser`와 `RouterDelegate`에게 적절히 전달하는 모든 배선 작업을 처리해 줍니다.
실전 구현: 도서 목록 앱 만들기
개념만으로는 와닿지 않을 수 있습니다. 이제 간단한 도서 목록 앱을 단계별로 구현하면서 Navigator 2.0이 실제로 어떻게 작동하는지 구체적으로 살펴보겠습니다. 이 앱은 세 가지 화면을 가집니다: 도서 목록 화면, 도서 상세 화면, 그리고 존재하지 않는 경로를 위한 404 화면.
1단계: 모델 및 데이터 정의
먼저 앱에서 사용할 간단한 `Book` 모델과 샘플 데이터를 정의합니다.
// data/book.dart
class Book {
final String id;
final String title;
final String author;
Book(this.id, this.title, this.author);
}
final List<Book> books = [
Book('1', '스트레인저', '알베르 카뮈'),
Book('2', '데미안', '헤르만 헤세'),
Book('3', '호밀밭의 파수꾼', 'J.D. 샐린저'),
];
2단계: 경로 상태 정의 (`RoutePath`)
URL과 일대일로 대응되는 앱의 경로 상태를 표현할 클래스를 만듭니다. 이 클래스는 `RouteInformationParser`와 `RouterDelegate` 사이에서 정보를 교환하는 데 사용됩니다.
// navigation/path.dart
class BookRoutePath {
final String? id;
final bool isUnknown;
BookRoutePath.home() : id = null, isUnknown = false;
BookRoutePath.details(this.id) : isUnknown = false;
BookRoutePath.unknown() : id = null, isUnknown = true;
bool get isHomePage => id == null && !isUnknown;
bool get isDetailsPage => id != null;
}
3단계: `RouteInformationParser` 구현
이제 URL 문자열을 `BookRoutePath` 객체로, 그리고 그 반대로 변환하는 파서를 구현합니다.
// navigation/parser.dart
import 'package:flutter/material.dart';
import 'path.dart';
class BookRouteInformationParser extends RouteInformationParser<BookRoutePath> {
// URL을 BookRoutePath로 변환
@override
Future<BookRoutePath> parseRouteInformation(RouteInformation routeInformation) async {
final uri = Uri.parse(routeInformation.location ?? '');
// 홈 경로: /
if (uri.pathSegments.isEmpty) {
return BookRoutePath.home();
}
// 상세 경로: /book/:id
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'book') {
final id = uri.pathSegments[1];
return BookRoutePath.details(id);
}
// 그 외의 모든 경로는 알 수 없는 경로로 처리
return BookRoutePath.unknown();
}
// BookRoutePath를 URL로 변환
@override
RouteInformation? restoreRouteInformation(BookRoutePath path) {
if (path.isUnknown) {
return const RouteInformation(location: '/404');
}
if (path.isHomePage) {
return const RouteInformation(location: '/');
}
if (path.isDetailsPage) {
return RouteInformation(location: '/book/${path.id}');
}
return null;
}
}
4단계: `RouterDelegate` 구현
가장 복잡하고 핵심적인 부분입니다. 앱의 상태를 관리하고, 그 상태에 따라 페이지 스택을 만드는 로직을 구현합니다.
// navigation/delegate.dart
import 'package:flutter/material.dart';
import '../data/book.dart';
import '../ui/screens.dart';
import 'path.dart';
class BookRouterDelegate extends RouterDelegate<BookRoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
@override
final GlobalKey<NavigatorState> navigatorKey;
Book? _selectedBook;
bool _show404 = false;
BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
// 현재 상태를 BookRoutePath로 변환 (URL 복원용)
@override
BookRoutePath get currentConfiguration {
if (_show404) {
return BookRoutePath.unknown();
}
return _selectedBook == null
? BookRoutePath.home()
: BookRoutePath.details(_selectedBook!.id);
}
// 상태에 따라 페이지 스택을 선언
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
MaterialPage(
key: const ValueKey('BookListPage'),
child: BookListScreen(
books: books,
onTapped: _handleBookTapped,
),
),
if (_show404)
const MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
else if (_selectedBook != null)
MaterialPage(
key: ValueKey(_selectedBook),
child: BookDetailsScreen(book: _selectedBook!))
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
// 상세 페이지에서 뒤로가기 시, 선택된 책 상태를 null로 변경
_selectedBook = null;
_show404 = false;
notifyListeners(); // 상태 변경 알림
return true;
},
);
}
// 파서가 변환한 BookRoutePath로 내부 상태를 업데이트
@override
Future<void> setNewRoutePath(BookRoutePath path) async {
if (path.isUnknown) {
_show404 = true;
_selectedBook = null;
return;
}
if (path.isDetailsPage) {
// id에 해당하는 책이 있는지 확인
final book = books.firstWhere((book) => book.id == path.id, orElse: () {
_show404 = true;
return Book('', '', ''); // 임시값, orElse는 null을 반환할 수 없음
});
if (!_show404) {
_selectedBook = book;
}
} else {
_selectedBook = null;
}
_show404 = false;
}
// UI 이벤트로 상태 변경
void _handleBookTapped(Book book) {
_selectedBook = book;
notifyListeners(); // 상태 변경 알림 -> build() 재호출
}
}
위 코드의 흐름을 주목해야 합니다. `_handleBookTapped`와 같은 UI 이벤트나 `setNewRoutePath`와 같은 시스템 이벤트가 발생하면, `_selectedBook`이나 `_show404` 같은 내부 상태 변수가 변경됩니다. 그리고 `notifyListeners()`가 호출되면 `build` 메서드가 다시 실행되어 현재 상태에 맞는 새로운 페이지 목록을 `Navigator`에 전달하고, 화면이 업데이트됩니다.
5단계: UI 화면 및 `MaterialApp.router` 연결
마지막으로 간단한 UI 화면들을 만들고, `main.dart` 파일에서 모든 것을 연결합니다.
// ui/screens.dart
import 'package:flutter/material.dart';
import '../data/book.dart';
class BookListScreen extends StatelessWidget {
final List<Book> books;
final ValueChanged<Book> onTapped;
const BookListScreen({required this.books, required this.onTapped, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('도서 목록')),
body: ListView(
children: [
for (var book in books)
ListTile(title: Text(book.title), subtitle: Text(book.author), onTap: () => onTapped(book)),
],
),
);
}
}
class BookDetailsScreen extends StatelessWidget {
final Book book;
const BookDetailsScreen({required this.book, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(book.title)),
body: Padding(padding: const EdgeInsets.all(8.0), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(book.title, style: Theme.of(context).textTheme.headline6),
Text(book.author, style: Theme.of(context).textTheme.subtitle1),
])),
);
}
}
class UnknownScreen extends StatelessWidget {
const UnknownScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('페이지를 찾을 수 없음')),
body: const Center(child: Text('404: 페이지를 찾을 수 없습니다.')),
);
}
}
// main.dart
import 'package:flutter/material.dart';
import 'navigation/delegate.dart';
import 'navigation/parser.dart';
void main() => runApp(const BooksApp());
class BooksApp extends StatelessWidget {
const BooksApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: '도서 목록 앱',
routerDelegate: BookRouterDelegate(),
routeInformationParser: BookRouteInformationParser(),
);
}
}
이제 이 앱을 실행하면 도서 목록이 나타납니다. 목록의 항목을 클릭하면 `_handleBookTapped`가 호출되어 `_selectedBook` 상태가 변경되고, `notifyListeners()`에 의해 `BookRouterDelegate`의 `build` 메서드가 다시 실행됩니다. 그 결과 `pages` 목록에 `BookDetailsScreen`이 추가되어 상세 화면으로 자연스럽게 전환됩니다. 웹에서 실행한다면 주소 표시줄도 `/book/1`과 같이 동기화되는 것을 확인할 수 있습니다.
심화 주제 및 고려사항
Navigator 2.0의 기본을 익혔다면, 이제 더 복잡한 시나리오와 주의할 점들을 살펴볼 차례입니다.
중첩 라우팅 (Nested Routing)
Navigator 2.0의 가장 강력한 기능 중 하나는 중첩 라우팅을 손쉽게 구현할 수 있다는 점입니다. 예를 들어, `BottomNavigationBar`를 사용하는 앱에서 각 탭이 독립적인 네비게이션 스택을 가지게 할 수 있습니다. 이는 각 탭에 해당하는 위젯 내부에 별도의 `Router` 위젯을 두고, 그 `Router`를 위한 자식 `RouterDelegate`를 구현함으로써 가능합니다. 부모 `RouterDelegate`는 현재 활성화된 탭이 무엇인지만 관리하고, 각 탭 내부의 화면 전환은 자식 `RouterDelegate`에게 위임하는 구조입니다.
상태 관리 라이브러리와의 통합
예제에서는 `RouterDelegate`가 직접 `ChangeNotifier`를 사용하여 상태를 관리했지만, 앱의 규모가 커지면 Provider, Riverpod, BLoC, GetX 등 전문 상태 관리 라이브러리와 통합하는 것이 훨씬 효율적입니다. 이 경우 `RouterDelegate`는 상태를 소유하는 대신, 상태 관리 라이브러리가 제공하는 상태 객체를 구독(watch, listen)합니다. 상태 변경 로직은 비즈니스 로직 레이어(ViewModel, BLoC 등)에 위치하게 되어 코드가 더 깔끔하게 분리됩니다. `RouterDelegate`는 오직 상태를 읽고 그에 맞는 페이지 목록을 선언하는 '뷰'의 역할에만 충실하게 됩니다.
보일러플레이트 코드와 대안 패키지
Navigator 2.0의 가장 큰 단점으로 지적되는 것은 상대적으로 많은 양의 보일러플레이트(boilerplate) 코드입니다. 간단한 네비게이션을 구현하기 위해서도 `RoutePath`, `RouteInformationParser`, `RouterDelegate`를 모두 직접 작성해야 합니다. 이로 인해 초기 학습 곡선이 가파르고 코드량이 늘어나는 부담이 있습니다.
이러한 단점을 보완하기 위해 커뮤니티에서는 Navigator 2.0 API를 더 쉽게 사용할 수 있도록 추상화한 여러 패키지를 개발했습니다. 대표적인 패키지는 다음과 같습니다.
- go_router: Flutter 팀이 공식적으로 지원하는 패키지로, URL 기반의 라우팅 방식을 매우 간결하게 구현할 수 있도록 도와줍니다. 문자열 기반 경로 정의, 타입 세이프한 파라미터 전달, 리다이렉션 등 강력한 기능을 제공하여 Navigator 2.0의 복잡성을 크게 줄여줍니다.
- auto_route: 코드 생성을 기반으로 하여 라우팅 관련 보일러플레이트 코드를 자동으로 만들어주는 패키지입니다. 어노테이션을 사용하면 라우터 설정을 자동으로 생성해 주어 개발 편의성이 높습니다.
- beamer: `BeamLocation`과 `BeamState`라는 개념을 통해 상태 기반 라우팅을 좀 더 구조화된 방식으로 접근할 수 있도록 돕습니다.
이러한 패키지들은 Navigator 2.0 위에 구축된 것이므로, 내부 동작 원리를 이해하기 위해서는 Navigator 2.0의 핵심 개념을 알아두는 것이 여전히 매우 중요합니다. 프로젝트의 복잡성과 팀의 선호도에 따라 순수 Navigator 2.0을 사용할지, 아니면 이러한 패키지를 도입할지 결정하는 것이 좋습니다.
결론: 언제 Navigator 2.0을 사용해야 하는가?
Navigator 2.0은 의심할 여지 없이 Flutter의 네비게이션을 한 단계 발전시킨 강력한 도구입니다. 상태 중심의 선언적 접근 방식은 복잡한 앱의 네비게이션 로직을 견고하고 예측 가능하게 만들어주며, 특히 Flutter가 지향하는 크로스플랫폼(모바일, 웹, 데스크톱) 환경에서 일관된 사용자 경험을 제공하는 데 필수적입니다.
하지만 모든 프로젝트에 Navigator 2.0이 정답인 것은 아닙니다. 그 강력함에는 복잡성과 학습 곡선이라는 대가가 따릅니다. 선택의 기준은 다음과 같이 정리할 수 있습니다.
- Navigator 1.0 (또는 간단한 패키지)이 적합한 경우:
- 주로 모바일 플랫폼만을 대상으로 하는 간단한 앱
- 화면 흐름이 단순하고 중첩 라우팅이나 딥 링킹 요구사항이 없는 경우
- 빠른 프로토타이핑이나 개발 속도가 매우 중요한 프로젝트
- Navigator 2.0 (순수 API 또는 go_router 등)이 적합한 경우:
- 웹과 데스크톱을 포함한 멀티플랫폼을 지원해야 하는 앱
- 브라우저 히스토리, 주소 표시줄 동기화가 필수적인 경우
- 복잡한 딥 링킹, 중첩된 네비게이션 스택, 조건부 라우팅 등 고급 기능이 필요한 경우
- 애플리케이션 상태와 네비게이션 상태를 완벽하게 동기화하여 장기적으로 유지보수가 용이한 구조를 만들고 싶은 경우
Navigator 2.0은 단순한 API 업데이트가 아니라 Flutter에서 애플리케이션의 흐름을 설계하는 방식에 대한 근본적인 패러다임의 변화입니다. 처음에는 어렵게 느껴질 수 있지만, 그 원리를 이해하고 나면 이전에는 불가능하거나 매우 복잡했던 네비게이션 시나리오들을 명확하고 우아하게 해결할 수 있는 새로운 가능성의 문이 열릴 것입니다.
0 개의 댓글:
Post a Comment