Flutter 개발

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

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

2025년 9월 20일
12분 읽기
Flutter 딥링킹 완벽 가이드 2편: GoRouter 고급 라우터 관리

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

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

시리즈 구성:
- 1편: 기초 개념과 플랫폼 설정
- 2편: GoRouter 고급 라우터 관리 (현재)
- 3편: 딥링크 처리 서비스 구현
- 4편: 라우터 상태 관리와 분석
- 5편: 실전 통합 구현

다음 편에서는 딥링크를 체계적으로 처리하는 전용 서비스를 구현해보겠습니다.
#GoRouter
#네비게이션
#라우터관리
#라우터팩토리
#권한관리
#중첩라우팅