GoRouter 소개와 선택 이유
GoRouter는 Flutter 팀이 공식적으로 관리하는 선언적 라우팅 패키지입니다. Navigator 2.0 API를 기반으로 구축되어 복잡한 라우팅 시나리오를 우아하게 처리합니다.
GoRouter를 선택해야 하는 이유:
- 선언적 라우팅: 라우트를 한 곳에서 정의하고 관리
- URL 기반 네비게이션: 웹과 모바일에서 일관된 경험
- 딥링킹 내장 지원: 별도 설정 없이 딥링크 처리
- 중첩 라우팅: 복잡한 UI 구조 지원
- 리다이렉트 로직: 인증, 온보딩 등 조건부 라우팅
- 타입 안전성: 파라미터 타입 체크와 자동 완성 지원
기존 Navigator 1.0 방식과 달리, GoRouter는 앱의 모든 화면을 URL로 표현할 수 있어 딥링킹과 완벽하게 통합됩니다.
GoRouter를 선택해야 하는 이유:
- 선언적 라우팅: 라우트를 한 곳에서 정의하고 관리
- URL 기반 네비게이션: 웹과 모바일에서 일관된 경험
- 딥링킹 내장 지원: 별도 설정 없이 딥링크 처리
- 중첩 라우팅: 복잡한 UI 구조 지원
- 리다이렉트 로직: 인증, 온보딩 등 조건부 라우팅
- 타입 안전성: 파라미터 타입 체크와 자동 완성 지원
기존 Navigator 1.0 방식과 달리, GoRouter는 앱의 모든 화면을 URL로 표현할 수 있어 딥링킹과 완벽하게 통합됩니다.
체계적인 라우트 구조 설계
대규모 앱에서는 라우트를 체계적으로 관리하는 것이 중요합니다. 라우트 정의를 중앙화하고 메타데이터를 활용한 고급 패턴을 알아보겠습니다.
Dart
// routes/app_routes.dart
class AppRoutes {
// Singleton 패턴으로 라우트 상수 관리
AppRoutes._();
// 인증 관련 라우트
static const String splash = '/splash';
static const String onboarding = '/onboarding';
static const String login = '/auth/login';
static const String register = '/auth/register';
static const String forgotPassword = '/auth/forgot-password';
static const String resetPassword = '/auth/reset-password/:token';
static const String verifyEmail = '/auth/verify-email/:code';
// 메인 네비게이션
static const String home = '/';
static const String explore = '/explore';
static const String profile = '/profile';
static const String settings = '/settings';
// 동적 라우트 (파라미터 포함)
static const String userProfile = '/user/:userId';
static const String userFollowers = '/user/:userId/followers';
static const String userFollowing = '/user/:userId/following';
// 콘텐츠 관련
static const String postDetail = '/post/:postId';
static const String postEdit = '/post/:postId/edit';
static const String postCreate = '/post/create';
// 채팅 시스템 (중첩 라우팅)
static const String chatList = '/chat';
static const String chatRoom = '/chat/:roomId';
static const String chatRoomInfo = '/chat/:roomId/info';
static const String chatRoomMembers = '/chat/:roomId/members';
static const String chatRoomSettings = '/chat/:roomId/settings';
// 쇼핑 관련
static const String productList = '/products';
static const String productDetail = '/product/:productId';
static const String cart = '/cart';
static const String checkout = '/checkout';
static const String orderDetail = '/order/:orderId';
static const String orderList = '/orders';
// 관리자 전용
static const String adminDashboard = '/admin';
static const String adminUsers = '/admin/users';
static const String adminUserDetail = '/admin/users/:userId';
static const String adminReports = '/admin/reports';
static const String adminSettings = '/admin/settings';
// 특수 목적
static const String search = '/search';
static const String notification = '/notifications';
static const String deeplink = '/dl/:token';
static const String webview = '/webview';
static const String maintenance = '/maintenance';
static const String notFound = '/404';
// 라우트 생성 헬퍼 메서드
static String makeUserProfile(String userId) => '/user/$userId';
static String makePostDetail(String postId) => '/post/$postId';
static String makeChatRoom(String roomId) => '/chat/$roomId';
static String makeProductDetail(String productId) => '/product/$productId';
// 쿼리 파라미터 포함 라우트 생성
static String makeSearchRoute(String query, {String? category}) {
final params = {'q': query};
if (category != null) params['category'] = category;
return Uri(path: search, queryParameters: params).toString();
}
}
라우트 권한과 메타데이터 관리
Dart
// routes/route_metadata.dart
enum RoutePermission {
public, // 누구나 접근 가능
authenticated, // 로그인 필요
verified, // 이메일 인증 완료 필요
premium, // 프리미엄 회원 전용
admin, // 관리자 전용
custom, // 커스텀 권한 체크 필요
}
enum RouteTransition {
material, // 기본 Material 전환
cupertino, // iOS 스타일 전환
fade, // 페이드 인/아웃
scale, // 스케일 애니메이션
slide, // 슬라이드
none, // 애니메이션 없음
}
class RouteMetadata {
final String name;
final String description;
final RoutePermission permission;
final RouteTransition transition;
final bool showBottomNav;
final bool showAppBar;
final bool trackAnalytics;
final List<String> requiredParams;
final Map<String, dynamic> extra;
const RouteMetadata({
required this.name,
required this.description,
this.permission = RoutePermission.public,
this.transition = RouteTransition.material,
this.showBottomNav = false,
this.showAppBar = true,
this.trackAnalytics = true,
this.requiredParams = const [],
this.extra = const {},
});
}
// 라우트별 메타데이터 정의
class RoutesMetadata {
static const Map<String, RouteMetadata> metadata = {
AppRoutes.home: RouteMetadata(
name: '홈',
description: '앱 메인 화면',
showBottomNav: true,
),
AppRoutes.login: RouteMetadata(
name: '로그인',
description: '사용자 인증 화면',
showAppBar: false,
transition: RouteTransition.fade,
),
AppRoutes.userProfile: RouteMetadata(
name: '사용자 프로필',
description: '사용자 상세 정보 화면',
permission: RoutePermission.authenticated,
requiredParams: ['userId'],
),
AppRoutes.chatRoom: RouteMetadata(
name: '채팅방',
description: '실시간 채팅 화면',
permission: RoutePermission.authenticated,
requiredParams: ['roomId'],
showBottomNav: false,
),
AppRoutes.adminDashboard: RouteMetadata(
name: '관리자 대시보드',
description: '시스템 관리 화면',
permission: RoutePermission.admin,
transition: RouteTransition.scale,
),
AppRoutes.checkout: RouteMetadata(
name: '결제',
description: '주문 결제 프로세스',
permission: RoutePermission.verified,
trackAnalytics: true,
extra: {
'requiresSSL': true,
'sessionTimeout': 300, // 5분
},
),
};
static RouteMetadata? getMetadata(String route) {
return metadata[route];
}
static bool hasPermission(String route, RoutePermission userPermission) {
final routeMeta = getMetadata(route);
if (routeMeta == null) return true;
// 권한 레벨 비교 로직
return userPermission.index >= routeMeta.permission.index;
}
}
GoRouter 팩토리 패턴 구현
복잡한 앱에서는 라우터 생성 로직을 팩토리 패턴으로 관리하는 것이 유리합니다. 의존성 주입과 설정 관리가 용이해집니다.
Dart
// router/router_factory.dart
import 'package:go_router/go_router.dart';
class RouterFactory {
// 의존성들
final AuthService authService;
final AnalyticsService analyticsService;
final DeeplinkService deeplinkService;
RouterFactory({
required this.authService,
required this.analyticsService,
required this.deeplinkService,
});
GoRouter createRouter() {
return GoRouter(
initialLocation: AppRoutes.splash,
debugLogDiagnostics: kDebugMode,
// 전역 에러 핸들러
errorBuilder: _errorBuilder,
// 전역 리다이렉트 로직
redirect: _globalRedirect,
// 라우트 정의
routes: _buildRoutes(),
// 네비게이션 관찰자
observers: [
RouterObserver(analyticsService),
if (kDebugMode) DebugRouterObserver(),
],
);
}
List<RouteBase> _buildRoutes() {
return [
// 스플래시 및 온보딩
GoRoute(
path: AppRoutes.splash,
name: 'splash',
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: AppRoutes.onboarding,
name: 'onboarding',
pageBuilder: (context, state) => CustomTransitionPage(
child: const OnboardingScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
),
),
// 인증 라우트 그룹
..._buildAuthRoutes(),
// 메인 앱 Shell (Bottom Navigation 포함)
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) {
// 메타데이터 기반 UI 결정
final metadata = RoutesMetadata.getMetadata(state.location);
final showBottomNav = metadata?.showBottomNav ?? false;
return MainShell(
showBottomNav: showBottomNav,
currentRoute: state.location,
child: child,
);
},
routes: [
// 홈
GoRoute(
path: AppRoutes.home,
name: 'home',
builder: (context, state) => const HomeScreen(),
routes: [
// 홈에서 시작하는 서브 라우트
GoRoute(
path: 'announcement/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return AnnouncementScreen(id: id);
},
),
],
),
// 사용자 프로필 (동적 라우트)
GoRoute(
path: AppRoutes.userProfile,
name: 'userProfile',
builder: (context, state) {
final userId = state.pathParameters['userId']!;
return UserProfileScreen(userId: userId);
},
routes: [
GoRoute(
path: 'followers',
builder: (context, state) {
final userId = state.pathParameters['userId']!;
return UserFollowersScreen(userId: userId);
},
),
GoRoute(
path: 'following',
builder: (context, state) {
final userId = state.pathParameters['userId']!;
return UserFollowingScreen(userId: userId);
},
),
],
),
],
),
// 채팅 시스템 (중첩 Shell)
ShellRoute(
navigatorKey: _chatNavigatorKey,
builder: (context, state, child) => ChatShell(child: child),
routes: [
GoRoute(
path: AppRoutes.chatList,
builder: (context, state) => const ChatListScreen(),
routes: [
GoRoute(
path: ':roomId',
builder: (context, state) {
final roomId = state.pathParameters['roomId']!;
return ChatRoomScreen(roomId: roomId);
},
routes: [
GoRoute(
path: 'info',
builder: (context, state) {
final roomId = state.pathParameters['roomId']!;
return ChatRoomInfoScreen(roomId: roomId);
},
),
GoRoute(
path: 'settings',
builder: (context, state) {
final roomId = state.pathParameters['roomId']!;
return ChatRoomSettingsScreen(roomId: roomId);
},
),
],
),
],
),
],
),
// 관리자 라우트 (별도 Shell)
ShellRoute(
navigatorKey: _adminNavigatorKey,
builder: (context, state, child) => AdminShell(child: child),
routes: _buildAdminRoutes(),
),
];
}
List<RouteBase> _buildAuthRoutes() {
return [
GoRoute(
path: AppRoutes.login,
name: 'login',
builder: (context, state) {
// 쿼리 파라미터로 리다이렉트 URL 전달
final redirectUrl = state.queryParameters['redirect'];
return LoginScreen(redirectUrl: redirectUrl);
},
),
GoRoute(
path: AppRoutes.register,
name: 'register',
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: AppRoutes.forgotPassword,
name: 'forgotPassword',
builder: (context, state) => const ForgotPasswordScreen(),
),
GoRoute(
path: AppRoutes.resetPassword,
name: 'resetPassword',
builder: (context, state) {
final token = state.pathParameters['token']!;
return ResetPasswordScreen(token: token);
},
),
];
}
List<RouteBase> _buildAdminRoutes() {
return [
GoRoute(
path: AppRoutes.adminDashboard,
builder: (context, state) => const AdminDashboardScreen(),
),
GoRoute(
path: AppRoutes.adminUsers,
builder: (context, state) => const AdminUsersScreen(),
routes: [
GoRoute(
path: ':userId',
builder: (context, state) {
final userId = state.pathParameters['userId']!;
return AdminUserDetailScreen(userId: userId);
},
),
],
),
];
}
// Navigator Key 관리
final GlobalKey<NavigatorState> _shellNavigatorKey =
GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> _chatNavigatorKey =
GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> _adminNavigatorKey =
GlobalKey<NavigatorState>();
}
전역 리다이렉트와 가드 구현
리다이렉트 로직은 라우터의 핵심 기능 중 하나입니다. 인증, 권한, 온보딩 상태 등을 체크하여 적절한 화면으로 유도합니다.
Dart
// router/route_guard.dart
class RouteGuard {
final AuthService authService;
final OnboardingService onboardingService;
final MaintenanceService maintenanceService;
RouteGuard({
required this.authService,
required this.onboardingService,
required this.maintenanceService,
});
String? globalRedirect(BuildContext context, GoRouterState state) {
final location = state.location;
final isLoggedIn = authService.isAuthenticated;
final user = authService.currentUser;
// 1. 유지보수 모드 체크
if (maintenanceService.isMaintenanceMode &&
location != AppRoutes.maintenance) {
return AppRoutes.maintenance;
}
// 2. 온보딩 체크 (첫 사용자)
if (!onboardingService.isCompleted &&
location != AppRoutes.onboarding &&
location != AppRoutes.splash) {
return AppRoutes.onboarding;
}
// 3. 라우트 메타데이터 가져오기
final metadata = RoutesMetadata.getMetadata(location);
if (metadata == null) {
// 메타데이터가 없는 라우트는 통과
return null;
}
// 4. 권한 체크
switch (metadata.permission) {
case RoutePermission.public:
// 공개 라우트는 누구나 접근 가능
break;
case RoutePermission.authenticated:
if (!isLoggedIn) {
// 로그인 페이지로 리다이렉트 (원래 가려던 URL 보존)
return '${AppRoutes.login}?redirect=${Uri.encodeComponent(location)}';
}
break;
case RoutePermission.verified:
if (!isLoggedIn) {
return '${AppRoutes.login}?redirect=${Uri.encodeComponent(location)}';
}
if (user != null && !user.isEmailVerified) {
return AppRoutes.verifyEmail;
}
break;
case RoutePermission.premium:
if (!isLoggedIn) {
return '${AppRoutes.login}?redirect=${Uri.encodeComponent(location)}';
}
if (user != null && !user.isPremium) {
// 프리미엄 구독 안내 페이지로
return '/premium/upgrade?from=${Uri.encodeComponent(location)}';
}
break;
case RoutePermission.admin:
if (!isLoggedIn || user?.role != 'admin') {
// 관리자가 아니면 홈으로
return AppRoutes.home;
}
break;
case RoutePermission.custom:
// 커스텀 권한 체크 로직
final hasAccess = _checkCustomPermission(location, user);
if (!hasAccess) {
return AppRoutes.home;
}
break;
}
// 5. 특수 케이스 처리
// 로그인한 사용자가 로그인 페이지 접근 시
if (isLoggedIn && _isAuthRoute(location)) {
return AppRoutes.home;
}
// 스플래시 화면에서 자동 이동
if (location == AppRoutes.splash && onboardingService.isCompleted) {
return isLoggedIn ? AppRoutes.home : AppRoutes.login;
}
return null; // 리다이렉트 없음
}
bool _isAuthRoute(String location) {
const authRoutes = [
AppRoutes.login,
AppRoutes.register,
AppRoutes.forgotPassword,
];
return authRoutes.any((route) => location.startsWith(route));
}
bool _checkCustomPermission(String location, User? user) {
// 예: 특정 기능에 대한 세밀한 권한 체크
if (location.startsWith('/feature/experimental')) {
return user?.hasFeatureFlag('experimental') ?? false;
}
if (location.startsWith('/organization/')) {
final orgId = _extractOrgId(location);
return user?.hasOrgAccess(orgId) ?? false;
}
return true;
}
String? _extractOrgId(String location) {
final regex = RegExp(r'/organization/([^/]+)');
final match = regex.firstMatch(location);
return match?.group(1);
}
}
// 에러 페이지 빌더
Widget _errorBuilder(BuildContext context, GoRouterState state) {
final error = state.error;
return ErrorScreen(
error: error,
location: state.location,
onRetry: () => context.go(AppRoutes.home),
);
}
고급 네비게이션 패턴
실무에서 자주 사용되는 고급 네비게이션 패턴들을 구현해보겠습니다.
Dart
// navigation/navigation_extensions.dart
extension GoRouterExtensions on GoRouter {
// 조건부 네비게이션
void goIf(bool condition, String location, [String? fallback]) {
if (condition) {
go(location);
} else if (fallback != null) {
go(fallback);
}
}
// 지연된 네비게이션
Future<void> goAfter(String location, Duration delay) async {
await Future.delayed(delay);
go(location);
}
// 스택 클리어하고 이동
void clearAndGo(String location) {
while (canPop()) {
pop();
}
go(location);
}
// 특정 라우트까지 팝
void popUntil(String targetRoute) {
while (location != targetRoute && canPop()) {
pop();
}
}
}
// 타입 안전 네비게이션 헬퍼
class TypedRouter {
final GoRouter _router;
TypedRouter(this._router);
// 사용자 프로필로 이동
void toUserProfile({
required String userId,
Map<String, String>? queryParams,
}) {
final uri = Uri(
path: '/user/$userId',
queryParameters: queryParams,
);
_router.go(uri.toString());
}
// 게시물 상세로 이동
void toPostDetail({
required String postId,
String? commentId,
bool? showComments,
}) {
final params = <String, String>{};
if (commentId != null) params['comment'] = commentId;
if (showComments != null) params['showComments'] = showComments.toString();
final uri = Uri(
path: '/post/$postId',
queryParameters: params.isNotEmpty ? params : null,
);
_router.go(uri.toString());
}
// 채팅방으로 이동
void toChatRoom({
required String roomId,
String? messageId,
}) {
var path = '/chat/$roomId';
if (messageId != null) {
path += '?message=$messageId';
}
_router.go(path);
}
// 검색 페이지로 이동
void toSearch({
String? query,
String? category,
List<String>? tags,
Map<String, dynamic>? filters,
}) {
final params = <String, String>{};
if (query != null) params['q'] = query;
if (category != null) params['category'] = category;
if (tags != null && tags.isNotEmpty) {
params['tags'] = tags.join(',');
}
if (filters != null) {
params['filters'] = jsonEncode(filters);
}
final uri = Uri(
path: '/search',
queryParameters: params.isNotEmpty ? params : null,
);
_router.go(uri.toString());
}
// 로그인으로 이동 (현재 위치 보존)
void toLoginWithRedirect() {
final currentLocation = _router.location;
_router.go('${AppRoutes.login}?redirect=${Uri.encodeComponent(currentLocation)}');
}
}
// 네비게이션 미들웨어
class NavigationMiddleware {
static final List<NavigationInterceptor> _interceptors = [];
static void addInterceptor(NavigationInterceptor interceptor) {
_interceptors.add(interceptor);
}
static Future<bool> canNavigate(String location) async {
for (final interceptor in _interceptors) {
final canProceed = await interceptor.canNavigate(location);
if (!canProceed) return false;
}
return true;
}
static Future<void> onNavigate(String from, String to) async {
for (final interceptor in _interceptors) {
await interceptor.onNavigate(from, to);
}
}
}
abstract class NavigationInterceptor {
Future<bool> canNavigate(String location);
Future<void> onNavigate(String from, String to);
}
// 예시: 저장되지 않은 변경사항 체크
class UnsavedChangesInterceptor implements NavigationInterceptor {
final FormService _formService;
UnsavedChangesInterceptor(this._formService);
@override
Future<bool> canNavigate(String location) async {
if (_formService.hasUnsavedChanges) {
final result = await showDialog<bool>(
context: navigatorKey.currentContext!,
builder: (context) => AlertDialog(
title: const Text('저장되지 않은 변경사항'),
content: const Text('변경사항을 저장하지 않고 나가시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('나가기'),
),
],
),
);
return result ?? false;
}
return true;
}
@override
Future<void> onNavigate(String from, String to) async {
// 네비게이션 발생 시 폼 초기화
_formService.reset();
}
}
다음 단계
GoRouter를 활용한 고급 라우터 관리 시스템을 구축했습니다. 라우트 구조 설계, 권한 관리, 리다이렉트 로직, 고급 네비게이션 패턴까지 실무에 필요한 핵심 기능들을 다뤘습니다.
핵심 포인트:
- 라우트를 중앙화하여 관리하면 유지보수가 쉬워집니다
- 메타데이터를 활용하면 라우트별 설정을 체계적으로 관리할 수 있습니다
- 팩토리 패턴으로 라우터 생성 로직을 깔끔하게 분리할 수 있습니다
- 리다이렉트 가드로 권한 관리와 조건부 라우팅을 구현할 수 있습니다
시리즈 구성:
- 1편: 기초 개념과 플랫폼 설정
- 2편: GoRouter 고급 라우터 관리 (현재)
- 3편: 딥링크 처리 서비스 구현
- 4편: 라우터 상태 관리와 분석
- 5편: 실전 통합 구현
다음 편에서는 딥링크를 체계적으로 처리하는 전용 서비스를 구현해보겠습니다.
핵심 포인트:
- 라우트를 중앙화하여 관리하면 유지보수가 쉬워집니다
- 메타데이터를 활용하면 라우트별 설정을 체계적으로 관리할 수 있습니다
- 팩토리 패턴으로 라우터 생성 로직을 깔끔하게 분리할 수 있습니다
- 리다이렉트 가드로 권한 관리와 조건부 라우팅을 구현할 수 있습니다
시리즈 구성:
- 1편: 기초 개념과 플랫폼 설정
- 2편: GoRouter 고급 라우터 관리 (현재)
- 3편: 딥링크 처리 서비스 구현
- 4편: 라우터 상태 관리와 분석
- 5편: 실전 통합 구현
다음 편에서는 딥링크를 체계적으로 처리하는 전용 서비스를 구현해보겠습니다.