Flutter 애플리케이션 개발에서 내비게이션은 사용자 경험의 핵심을 이룹니다. Flutter 1.0의 명령형(Imperative) 방식인 Navigator.push
와 Navigator.pop
은 간단한 앱에서는 직관적이고 사용하기 쉬웠지만, 애플리케이션이 복잡해짐에 따라 몇 가지 근본적인 한계에 부딪혔습니다. 웹과의 일관된 동작(브라우저 주소창 URL 연동, 뒤로/앞으로 가기 버튼), 중첩된 내비게이터(Nested Navigator)의 복잡성, 그리고 앱의 특정 상태로 직접 이동하는 딥 링킹(Deep Linking) 구현의 어려움이 대표적이었습니다. 이러한 과제를 해결하기 위해 Flutter 팀은 Navigator 2.0, 즉 'Router' API를 도입했습니다.
Navigator 2.0은 기존의 명령형 접근 방식에서 벗어나, 앱의 상태(State)가 UI를 결정한다는 Flutter의 핵심 원칙을 내비게이션에까지 확장한 선언형(Declarative) API입니다. 이는 "이 페이지로 이동해라"라고 명령하는 대신, "현재 앱의 상태는 이러하니, 내비게이션 스택은 이러한 페이지들로 구성되어야 한다"라고 선언하는 방식입니다. 이 패러다임의 전환은 처음에는 다소 복잡하게 느껴질 수 있지만, 한번 익숙해지면 훨씬 더 강력하고 예측 가능한 방식으로 라우팅을 제어할 수 있게 됩니다. 본문에서는 Navigator 2.0의 핵심 구성 요소를 깊이 있게 탐구하고, 이를 활용하여 애플리케이션의 현재 페이지를 정확하게 식별하고 관리하는 다양한 방법을 체계적으로 살펴보겠습니다.
Navigator 1.0에서 2.0으로: 패러다임의 전환
Navigator 2.0을 제대로 이해하려면 먼저 기존 방식과의 근본적인 차이점을 인지하는 것이 중요합니다.
- 명령형(Imperative) vs. 선언형(Declarative): Navigator 1.0은
Navigator.of(context).pushNamed('/details')
와 같이 특정 동작을 직접 명령하는 방식입니다. 이는 코드의 흐름을 따라가기는 쉽지만, 현재 내비게이션 스택이 어떻게 구성되어 있는지 전체적인 상태를 파악하기 어렵습니다. 반면 Navigator 2.0은 현재 상태를 기반으로Navigator
위젯에 표시할 페이지 목록(List<Page>
)을 제공합니다. 상태가 변경되면 위젯이 리빌드되면서 페이지 목록이 갱신되고, Flutter는 이전 목록과 새 목록을 비교하여 필요한 애니메이션과 함께 화면을 전환합니다. - 상태 관리의 분리: Navigator 1.0에서는 내비게이션 스택 자체가 상태의 역할을 했습니다. 스택에 페이지가 쌓이는 순서와 내용이 앱의 내비게이션 상태를 암묵적으로 정의했습니다. Navigator 2.0에서는 내비게이션 상태를 개발자가 명시적으로 관리하는 데이터 모델(예:
ChangeNotifier
를 상속한 클래스, BLoC의 상태 객체 등)로 분리합니다. 라우팅 로직은 이 상태를 읽고, 사용자 상호작용은 이 상태를 변경하는 방식으로 동작합니다. 이로 인해 내비게이션 로직이 테스트하기 쉬워지고, 상태 변화를 추적하기 용이해집니다. - URL과의 완전한 통합: 웹 애플리케이션에서 URL은 애플리케이션의 특정 상태를 가리키는 고유한 식별자입니다. Navigator 2.0은 이 개념을 모바일 앱에 그대로 적용합니다. 앱의 상태가 변경되면 URL이 업데이트되고, 사용자가 브라우저에 URL을 직접 입력하거나 뒤로 가기 버튼을 누르면 해당 URL을 파싱하여 앱의 상태를 복원합니다. 이는 Navigator 1.0에서 플러그인 없이는 구현하기 까다로웠던 기능을 API 수준에서 지원하는 것입니다.
이러한 변화의 중심에는 앱의 상태와 플랫폼(웹 브라우저, OS) 사이에서 정보를 변환하고 동기화하는 몇 가지 핵심 클래스가 있습니다. 이제 이들을 하나씩 자세히 살펴보겠습니다.
Navigator 2.0의 핵심 구성 요소 심층 분석
Navigator 2.0의 아키텍처는 네 가지 주요 클래스가 유기적으로 상호작용하며 완성됩니다. 이들의 역할과 데이터 흐름을 이해하는 것이 Navigator 2.0을 마스터하는 첫걸음입니다.
1. Page: 내비게이션 스택의 불변 단위
Navigator 1.0의 Route
와 유사하지만, Page
는 위젯 트리에 포함될 수 있는 불변(immutable) 객체라는 중요한 차이점이 있습니다. Navigator
위젯의 pages
속성은 이 Page
객체들의 리스트를 받습니다. Flutter 프레임워크는 리빌드될 때마다 이전 페이지 리스트와 새로운 페이지 리스트를 비교하여 변경 사항(추가, 제거, 순서 변경)을 감지하고 화면 전환을 처리합니다. 페이지를 식별하기 위해 key
(주로 ValueKey
)를 사용하는 것이 매우 중요합니다.
// 예시: 홈 화면과 상세 화면을 위한 Page 객체 생성
MaterialPage(
key: ValueKey('HomePage'),
name: '/',
child: HomeScreen(),
);
MaterialPage(
key: ValueKey('DetailsPage_123'),
name: '/details/123',
child: DetailsScreen(id: '123'),
);
2. RouterDelegate: 상태와 내비게이터를 잇는 지휘자
RouterDelegate
는 Navigator 2.0 아키텍처의 심장부입니다. 이 클래스의 주된 역할은 다음과 같습니다.
- 앱 상태(App State) 수신:
RouterDelegate
는 앱의 내비게이션 상태(예: 현재 선택된 책 ID, 로그인 여부 등)를 수신하거나 직접 관리합니다. 보통ChangeNotifier
를 믹스인하여 상태 변경 시 리스너들에게 알리는 패턴이 많이 사용됩니다. - 페이지 스택 구축: 수신한 앱 상태를 기반으로 현재 내비게이션 스택에 해당되는
List<Page>
를 생성합니다. Navigator
위젯 빌드:build
메서드 내에서 위에서 생성한 페이지 리스트를 사용하여Navigator
위젯을 생성하고 반환합니다. 상태가 변경되어notifyListeners()
가 호출되면build
메서드가 다시 실행되고, 새로운 페이지 리스트로Navigator
가 업데이트됩니다.- 플랫폼 이벤트 처리: OS의 '뒤로 가기' 버튼과 같은 이벤트를 처리합니다.
PopNavigatorRouterDelegateMixin
을 함께 사용하면 이 과정을 단순화할 수 있습니다. - 현재 상태 보고:
currentConfiguration
게터를 통해 현재 앱의 상태를 시스템에 보고합니다. 이 정보는 나중에RouteInformationParser
가 URL을 복원하는 데 사용됩니다.
아래는 RouterDelegate
의 필수적인 구조입니다.
class AppRouterDelegate extends RouterDelegate<AppRoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {
// 1. Navigator 위젯을 제어하기 위한 GlobalKey
@override
final GlobalKey<NavigatorState> navigatorKey;
// 2. 앱의 내비게이션 상태를 관리하는 변수
bool _show404 = false;
String? _selectedBookId;
AppRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
// 3. 현재 앱 상태를 시스템에 보고하는 getter
@override
AppRoutePath get currentConfiguration {
if (_show404) {
return AppRoutePath.unknown();
}
if (_selectedBookId != null) {
return AppRoutePath.details(_selectedBookId!);
}
return AppRoutePath.home();
}
// 4. 앱 상태에 따라 Navigator를 빌드하는 핵심 메서드
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
MaterialPage(
key: ValueKey('BookListPage'),
child: BookListScreen(
onTapped: _handleBookTapped,
),
),
if (_show404)
MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
else if (_selectedBookId != null)
MaterialPage(
key: ValueKey('BookDetailsPage_$_selectedBookId'),
child: BookDetailsScreen(id: _selectedBookId!),
)
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
// 스택에서 페이지가 pop 되면, 그에 맞게 상태를 업데이트하고 리스너에게 알림
_selectedBookId = null;
_show404 = false;
notifyListeners();
return true;
},
);
}
// 5. URL 변경 등 외부 요인으로 새로운 경로가 설정될 때 호출됨
@override
Future<void> setNewRoutePath(AppRoutePath path) async {
if (path.isUnknown) {
_selectedBookId = null;
_show404 = true;
} else if (path.isDetailsPage) {
_selectedBookId = path.id;
_show404 = false;
} else {
_selectedBookId = null;
_show404 = false;
}
// 상태 변경 후 리스너에게 알려 UI를 리빌드하도록 함
notifyListeners();
}
// 앱 내부 로직 (예: 책 목록에서 아이템 클릭)
void _handleBookTapped(String id) {
_selectedBookId = id;
notifyListeners();
}
}
3. RouteInformationParser: URL과 앱 상태를 잇는 번역가
RouteInformationParser
는 플랫폼 라우팅 정보(웹의 URL 등)와 RouterDelegate
가 이해하는 데이터 타입(앱의 상태 객체) 사이의 변환을 담당합니다.
parseRouteInformation
: 플랫폼으로부터RouteInformation
(주로location
문자열 포함)을 받아, 이를 앱의 상태 객체(위 예제에서는AppRoutePath
)로 파싱합니다. 예를 들어,/book/123
이라는 URL 문자열을AppRoutePath.details('123')
객체로 변환합니다. 이 메서드는 앱이 시작될 때 초기 URL을 처리하거나, 브라우저 주소창에 새로운 URL이 입력되었을 때 호출됩니다.restoreRouteInformation
:RouterDelegate
의currentConfiguration
이 반환한 앱 상태 객체를 다시 플랫폼이 이해할 수 있는RouteInformation
으로 변환합니다. 예를 들어,AppRoutePath.details('123')
객체를RouteInformation(location: '/book/123')
으로 변환하여 브라우저의 URL을 업데이트합니다.
// URL 경로를 나타내는 데이터 클래스
class AppRoutePath {
final String? id;
final bool isUnknown;
AppRoutePath.home() : id = null, isUnknown = false;
AppRoutePath.details(this.id) : isUnknown = false;
AppRoutePath.unknown() : id = null, isUnknown = true;
bool get isHomePage => id == null && isUnknown == false;
bool get isDetailsPage => id != null;
}
// 파서 구현
class AppRouteInformationParser extends RouteInformationParser<AppRoutePath> {
@override
Future<AppRoutePath> parseRouteInformation(
RouteInformation routeInformation) async {
final uri = Uri.parse(routeInformation.location!);
// '/'
if (uri.pathSegments.length == 0) {
return AppRoutePath.home();
}
// '/book/:id'
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'book') {
return AppRoutePath.details(uri.pathSegments[1]);
}
// 그 외의 모든 경로
return AppRoutePath.unknown();
}
@override
RouteInformation restoreRouteInformation(AppRoutePath path) {
if (path.isUnknown) {
return RouteInformation(location: '/404');
}
if (path.isHomePage) {
return RouteInformation(location: '/');
}
if (path.isDetailsPage) {
return RouteInformation(location: '/book/${path.id}');
}
return null; // 일반적으로 도달하지 않음
}
}
4. Router: 모든 것을 연결하는 위젯
Router
위젯은 위에서 설명한 구성 요소들을 하나로 묶어 실제로 동작하게 만드는 위젯입니다. 하지만 대부분의 경우 개발자가 직접 Router
위젯을 사용하기보다는 MaterialApp.router
생성자를 통해 간접적으로 사용하게 됩니다. 이 생성자는 routerDelegate
와 routeInformationParser
를 인자로 받아 내부적으로 Router
위젯을 설정해 줍니다.
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final AppRouterDelegate _routerDelegate = AppRouterDelegate();
final AppRouteInformationParser _routeInformationParser = AppRouteInformationParser();
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Navigator 2.0 Example',
routerDelegate: _routerDelegate,
routeInformationParser: _routeInformationParser,
);
}
}
현재 페이지(경로)를 확인하는 다양한 방법
Navigator 2.0의 선언적 특성을 이해했다면, 이제 애플리케이션의 특정 위치에서 현재 어떤 페이지에 있는지 확인하는 방법을 알아볼 차례입니다. 이는 분석 도구에 현재 화면 정보를 보내거나, 특정 UI 요소(예: 하단 네비게이션 바)의 활성화 상태를 결정하는 등 다양한 상황에서 필요합니다.
방법 1: `RouterDelegate`의 상태 직접 참조 (가장 권장되는 방식)
가장 'Navigator 2.0'다운 방식은 내비게이션의 '단일 진실 공급원(Single Source of Truth)'인 RouterDelegate
(또는 델리게이트가 참조하는 상태 관리 객체)의 현재 상태를 직접 확인하는 것입니다. 위 예제에서 `_selectedBookId`나 `_show404`와 같은 상태 변수들이 현재 페이지가 무엇인지를 명확하게 정의합니다.
이 상태에 위젯 트리 전반에서 접근할 수 있도록 하려면 `Provider`나 `Riverpod`과 같은 상태 관리 패키지를 사용하는 것이 이상적입니다. RouterDelegate
자체를 ChangeNotifierProvider
로 감싸면, 하위 위젯에서 이를 쉽게 소비(consume)할 수 있습니다.
// main.dart 수정
class MyApp extends StatelessWidget { // StatefulWidget에서 StatelessWidget으로 변경 가능
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AppRouterDelegate(),
child: Consumer<AppRouterDelegate>(
builder: (context, delegate, child) => MaterialApp.router(
title: 'Navigator 2.0 Example',
routerDelegate: delegate,
routeInformationParser: AppRouteInformationParser(),
),
),
);
}
}
// UI 위젯에서 현재 경로 확인하기
class SomeWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Provider를 통해 RouterDelegate 인스턴스에 접근
final delegate = Provider.of<AppRouterDelegate>(context, listen: false);
// delegate의 currentConfiguration을 통해 현재 경로 정보를 얻음
final currentPath = delegate.currentConfiguration;
String pageInfo;
if (currentPath.isHomePage) {
pageInfo = "현재 위치: 홈";
} else if (currentPath.isDetailsPage) {
pageInfo = "현재 위치: 상세 페이지 (ID: ${currentPath.id})";
} else {
pageInfo = "현재 위치: 알 수 없는 페이지";
}
return Text(pageInfo);
}
}
이 방법의 장점은 UI가 내비게이션 스택의 내부 구현에 의존하지 않고, 오직 추상화된 '상태'에만 의존한다는 점입니다. 이는 코드의 결합도를 낮추고 테스트를 용이하게 만듭니다.
방법 2: `ModalRoute.of(context)` 사용 (전통적인 방식)
Navigator 2.0 환경에서도 여전히 ModalRoute.of(context)
를 사용하여 현재 경로 정보를 얻을 수 있습니다. 이 방법은 위젯의 `BuildContext`를 통해 가장 가까운 상위 `Route`에 대한 정보를 가져옵니다. Page
객체를 생성할 때 `name` 속성을 지정했다면, 이 값을 통해 현재 경로를 식별할 수 있습니다.
// RouterDelegate의 build 메서드에서 Page를 생성할 때 name 속성 지정
@override
Widget build(BuildContext context) {
return Navigator(
// ...
pages: [
MaterialPage(
key: ValueKey('BookListPage'),
name: '/', // name 속성 추가
child: BookListScreen(...),
),
if (_selectedBookId != null)
MaterialPage(
key: ValueKey('BookDetailsPage_$_selectedBookId'),
name: '/book/$_selectedBookId', // name 속성 추가
child: BookDetailsScreen(id: _selectedBookId!),
)
],
// ...
);
}
// UI 위젯에서 확인
class AnotherWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final route = ModalRoute.of(context);
// route?.settings.name은 nullable일 수 있으므로 안전하게 처리
final currentRouteName = route?.settings.name ?? 'N/A';
return Text("현재 경로 이름: $currentRouteName");
}
}
이 방법은 간단하게 현재 최상단 경로의 이름을 가져올 수 있다는 장점이 있습니다. 하지만 몇 가지 주의할 점이 있습니다. 첫째, ModalRoute.of(context)
는 가장 가까운 `Navigator`의 최상단 경로를 반환하므로, 중첩된 `Navigator`가 있는 경우 의도치 않은 결과를 얻을 수 있습니다. 둘째, 이는 UI 렌더링 결과물(Route)에서 정보를 역으로 추출하는 방식으로, 상태가 UI를 결정한다는 선언형 패러다임과는 다소 거리가 있습니다.
방법 3: `Navigator.pages` 스택 직접 확인
RouterDelegate
가 빌드하는 `Navigator` 위젯의 `pages` 리스트는 현재 내비게이션 스택의 전체 구성을 담고 있습니다. `RouterDelegate` 내부나 델리게이트에 접근할 수 있는 곳에서는 이 리스트의 마지막 요소나 전체 구성을 통해 현재 페이지와 스택의 깊이를 파악할 수 있습니다.
// RouterDelegate 내에서
void logCurrentStack() {
// build 메서드에서 생성하는 로직을 활용하여 페이지 리스트를 얻음
final pages = [
MaterialPage(...),
if (_selectedBookId != null) MaterialPage(...),
];
if (pages.isNotEmpty) {
final currentPage = pages.last;
print("현재 페이지 키: ${currentPage.key}");
print("현재 페이지 이름: ${currentPage.name}");
print("총 스택 깊이: ${pages.length}");
}
}
이 방법은 현재 페이지뿐만 아니라 전체 내비게이션 히스토리를 파악해야 할 때 유용합니다. 하지만 이는 `RouterDelegate`의 내부 구현에 강하게 의존하게 되므로, 외부 위젯에서 이 로직을 직접 사용하는 것은 권장되지 않습니다. 대신, 델리게이트가 이 정보를 가공하여 외부에 노출하는 것이 더 나은 설계입니다.
결론: 상태 중심적 사고의 중요성
Flutter Navigator 2.0은 단순히 API의 변경을 넘어, 내비게이션을 바라보는 관점 자체를 '동작'에서 '상태' 중심으로 전환할 것을 요구합니다. 처음에는 `RouterDelegate`와 `RouteInformationParser`를 설정하는 과정이 번거롭게 느껴질 수 있지만, 이 구조는 애플리케이션의 규모가 커지고 웹 지원, 딥 링킹, 중첩 라우팅과 같은 복잡한 요구사항이 발생했을 때 진정한 가치를 발휘합니다.
현재 페이지를 확인하는 문제에 있어서도, 가장 견고하고 확장 가능한 접근 방식은 `RouterDelegate`가 관리하는 '상태'를 직접 참조하는 것입니다. 이는 내비게이션 로직을 앱의 다른 비즈니스 로직과 동일한 방식으로 다룰 수 있게 하여, 전체 아키텍처의 일관성을 높이고 예측 가능성을 보장합니다. `ModalRoute.of(context)`와 같은 보조적인 수단도 유용할 수 있지만, 핵심은 언제나 '상태가 UI를 이끈다(State Drives UI)'는 Flutter의 근본 원칙을 내비게이션에도 일관되게 적용하는 것입니다. 이 패러다임을 완전히 수용할 때, Navigator 2.0은 복잡한 사용자 흐름을 명쾌하게 풀어낼 수 있는 강력한 도구가 될 것입니다.
0 개의 댓글:
Post a Comment