Flutter 1.0의 내비게이션 시스템(Navigator 1.0)은 Navigator.push와 Navigator.pop이라는 명령형(Imperative) API를 기반으로 설계되었습니다. 이 방식은 간단한 화면 전환에는 직관적이지만, 복잡한 앱 시나리오에서 치명적인 한계를 드러냅니다. 특히 웹 브라우저의 주소창(URL) 동기화, 안드로이드 백 버튼의 커스텀 핸들링, 그리고 딥 링킹(Deep Linking) 구현 시 스택의 상태를 예측하기 어렵다는 문제가 발생합니다.
이러한 구조적 병목을 해결하기 위해 도입된 Navigator 2.0(Router API)은 "앱의 상태(State)가 UI를 결정한다"는 Flutter의 선언형(Declarative) 철학을 라우팅 시스템까지 확장했습니다. 본 글에서는 Navigator 2.0의 아키텍처를 엔지니어링 관점에서 분석하고, 이를 통해 현재 페이지를 정확히 식별하고 제어하는 실무적 구현 패턴을 제시합니다.
1. 패러다임 전환: 명령형에서 선언형으로
기존 Navigator 1.0은 개발자가 직접 "이동하라"는 명령을 내리는 방식이었습니다. 반면, Navigator 2.0은 "현재 상태가 이러하니, 내비게이션 스택은 이 페이지들로 구성되어야 한다"라고 선언하는 구조입니다. 이 차이는 단순한 문법의 변화가 아니라, 라우팅의 제어권(Control Flow)이 어디에 있는지를 재정의합니다.
| 특성 | Navigator 1.0 (명령형) | Navigator 2.0 (선언형) |
|---|---|---|
| 제어 방식 | push(), pop() 호출 시 즉시 스택 변경 |
앱 상태 변경 → build() 재호출 → 스택 재구성 |
| 상태 관리 | 내비게이터 내부 스택이 상태를 보유 (암시적) | 외부의 상태 객체(Provider, Bloc)가 라우팅 주도 (명시적) |
| URL 동기화 | 별도 플러그인 필요, 제한적 지원 | OS/브라우저와 양방향 동기화 기본 지원 |
| 복잡도 | 초기 구현 쉬움, 유지보수 어려움 | 초기 보일러플레이트 많음, 테스트 용이함 |
Navigator.push를 사용할 수 없는 것은 아닙니다. 하지만 두 방식을 혼용할 경우 스택 관리의 일관성이 깨질 수 있으므로, 2.0 도입 시 라우팅 로직을 RouterDelegate로 완전히 위임하는 것이 바람직합니다.
2. 아키텍처 핵심 구성 요소 (Core Components)
Navigator 2.0은 단일 위젯이 아닌, 여러 클래스의 유기적인 상호작용으로 동작합니다. 각 컴포넌트의 역할과 데이터 흐름을 이해해야만 커스텀 라우팅을 구현할 수 있습니다.
2.1 RouterDelegate: 라우팅 로직의 핵심
RouterDelegate는 앱의 상태를 수신하고, 이를 기반으로 Navigator 위젯을 빌드합니다. 또한 OS로부터 들어오는 뒤로 가기 이벤트를 처리합니다. React Native나 웹 프론트엔드의 라우터와 유사하게, 현재 URL(경로) 상태를 복원하고 갱신하는 중심축 역할을 합니다.
2.2 Page: 불변의 화면 정의
기존의 Route와 달리 Page는 불변(Immutable) 객체입니다. Navigator는 pages 파라미터로 List<Page>를 받습니다. 리빌드 시 Flutter 프레임워크는 이전 페이지 리스트와 새로운 리스트를 비교(Diffing)하여 어떤 페이지를 추가하거나 애니메이션과 함께 제거할지 결정합니다. 이때 Key(주로 ValueKey)가 페이지 식별의 기준이 됩니다.
2.3 RouteInformationParser: 플랫폼 통신
URL 문자열(또는 딥 링크)을 앱의 상태 객체로 변환하거나, 반대로 앱의 상태를 URL 문자열로 변환하여 플랫폼에 전달하는 역할을 수행합니다.
3. RouterDelegate 구현 및 상태 정의
실제 프로덕션 레벨에서 사용 가능한 RouterDelegate의 구조를 살펴보겠습니다. 여기서는 책 목록과 상세 페이지를 오가는 시나리오를 가정합니다.
// 1. 앱의 라우팅 상태를 정의하는 경로 클래스
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;
bool get isDetailsPage => id != null;
}
// 2. 라우터 델리게이트 구현
class AppRouterDelegate extends RouterDelegate<AppRoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {
@override
final GlobalKey<NavigatorState> navigatorKey;
// 앱의 상태 (State)
String? _selectedBookId;
bool _show404 = false;
AppRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
// 현재 상태를 반환 (URL 업데이트용)
@override
AppRoutePath get currentConfiguration {
if (_show404) return AppRoutePath.unknown();
if (_selectedBookId != null) return AppRoutePath.details(_selectedBookId!);
return AppRoutePath.home();
}
// 상태에 따른 Navigator 빌드 (선언형 라우팅의 핵심)
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
// 기본 홈 페이지 (항상 스택 바닥에 존재)
MaterialPage(
key: ValueKey('HomePage'),
child: HomeScreen(
onTapped: _handleBookTapped,
),
),
// 404 페이지 조건부 렌더링
if (_show404)
MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
// 상세 페이지 조건부 렌더링
else if (_selectedBookId != null)
MaterialPage(
key: ValueKey('DetailsPage_$_selectedBookId'),
child: BookDetailsScreen(id: _selectedBookId!),
)
],
// 뒤로 가기 처리 (pop)
onPopPage: (route, result) {
if (!route.didPop(result)) return false;
// 스택 변경에 맞춰 앱 상태(변수) 업데이트
_selectedBookId = null;
_show404 = false;
notifyListeners(); // 리빌드 트리거
return true;
},
);
}
void _handleBookTapped(String id) {
_selectedBookId = id;
notifyListeners();
}
@override
Future<void> setNewRoutePath(AppRoutePath path) async {
// OS/브라우저에서 URL 입력 시 호출됨
if (path.isUnknown) {
_selectedBookId = null;
_show404 = true;
} else if (path.isDetailsPage) {
_selectedBookId = path.id;
_show404 = false;
} else {
_selectedBookId = null;
_show404 = false;
}
}
}
<와 > 기호를 사용해야 하며, HTML 렌더링 시 문제가 발생하지 않도록 주의해야 합니다. 위 코드는 표준 형식을 따릅니다.
4. 현재 페이지 식별 전략 (Current Page Identification)
내비게이션 구조가 상태 기반으로 변경됨에 따라, "현재 내가 어떤 페이지에 있는가?"를 확인하는 방법도 달라져야 합니다. 이는 Analytics 트래킹이나 BottomNavigationBar의 인덱스 동기화에 필수적입니다.
4.1 RouterDelegate 상태 직접 참조 (Best Practice)
가장 권장되는 방식은 RouterDelegate가 관리하는 상태 변수를 직접 참조하는 것입니다. 라우팅 로직의 'Single Source of Truth'는 RouterDelegate이기 때문입니다.
Provider또는Riverpod을 사용하여RouterDelegate를 DI(Dependency Injection) 컨테이너에 등록합니다.- 하위 위젯에서 Delegate의 상태 변수(예:
_selectedBookId)를 구독합니다.
// Provider를 활용한 상태 접근 예시
final routerDelegate = Provider.of<AppRouterDelegate>(context);
final currentConfig = routerDelegate.currentConfiguration;
if (currentConfig.isDetailsPage) {
// 현재 상세 페이지에 위치함
analytics.logScreenView(screenName: 'Details_${currentConfig.id}');
}
4.2 ModalRoute 활용 (Legacy Support)
특정 위젯이 렌더링된 시점에서 자신을 포함하는 Route 정보를 얻고 싶다면 여전히 ModalRoute.of(context)를 사용할 수 있습니다. 단, 이를 위해서는 Page 객체 생성 시 name 속성을 명시적으로 할당해야 합니다.
// RouterDelegate 내부
MaterialPage(
key: ValueKey('Details'),
name: '/details/$_selectedBookId', // name 속성 필수
child: DetailsScreen(),
)
// 위젯 내부
final route = ModalRoute.of(context);
print('Current Route Name: ${route?.settings.name}');
ModalRoute.of(context)는 가장 가까운 Navigator의 Route를 반환합니다. 전체 앱 관점의 경로와 다를 수 있음을 인지해야 합니다.
5. 트레이드오프 및 도입 제언
Navigator 2.0은 강력한 제어권과 테스트 용이성을 제공하지만, 그 대가로 상당한 양의 보일러플레이트 코드를 요구합니다. 상태 관리 로직과 라우팅 로직이 강하게 결합되므로 초기 설계 비용이 높습니다.
따라서, 다음과 같은 기준으로 도입을 결정하는 것을 권장합니다.
- 복잡한 딥 링킹 및 웹 지원 필수: Navigator 2.0 (또는 GoRouter) 필수.
- 단순 모바일 앱 (스택형 화면 전환 위주): 기존 Navigator 1.0 유지 가능.
- 생산성 중시:
go_router와 같은 2.0 기반 래퍼(Wrapper) 패키지 사용 권장.go_router는 2.0의 복잡성을 숨기고 URL 기반 라우팅을 쉽게 제공합니다.
결론
Flutter Navigator 2.0의 핵심은 '동작'이 아닌 '상태'로 화면 전환을 정의하는 것입니다. RouterDelegate를 통해 앱의 내비게이션 상태를 중앙 집중화하면, 앱의 어느 곳에서든 현재 페이지를 명확하게 식별하고 제어할 수 있습니다. 비록 초기 학습 곡선은 가파르지만, 대규모 애플리케이션의 유지보수성과 확장성을 고려한다면 반드시 숙지해야 할 아키텍처 패턴입니다.
Post a Comment