Flutter 개발

Flutter 딥링킹 가이드 2편: GoRouter 고급 라우터 관리

엔터프라이즈급 라우터 시스템 설계와 구현

John Doe
2025년 9월 22일
12분 읽기
115

GoRouter 소개와 선택 이유

GoRouter는 Flutter 팀이 공식적으로 관리하는 선언적 라우팅 패키지입니다. Navigator 2.0 API를 기반으로 구축되어 복잡한 라우팅 시나리오를 우아하게 처리하죠.

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를 활용한 고급 라우터 관리 시스템을 구축했습니다. 라우트 구조 설계, 권한 관리, 리다이렉트 로직, 고급 네비게이션 패턴까지 실무에 필요한 핵심 기능들을 다뤘습니다.

핵심 포인트:
- 라우트를 중앙화하여 관리하면 유지보수가 쉬워집니다
- 메타데이터를 쓰면 라우트별 설정을 체계적으로 관리할 수 있습니다
- 팩토리 패턴으로 라우터 생성 로직을 깔끔하게 분리할 수 있습니다
- 리다이렉트 가드로 권한 관리와 조건부 라우팅을 구현할 수 있습니다

다음 편에서는 딥링크를 체계적으로 처리하는 전용 서비스를 구현해보죠.
#GoRouter
#네비게이션
#라우터관리
#라우터팩토리
#권한관리
#중첩라우팅
    Flutter 딥링킹 가이드 2편: GoRouter 고급 라우터 관리