Flutter에서 go_router를 이용한 웹앱 URL 전략

Flutter에서 go_router를 이용한 웹앱 URL 전략

Flutter에서 웹 애플리케이션을 개발할 때, URL 전략은 사용자 경험에 큰 영향을 미칩니다. 특히 웹 환경에서는 사용자가 URL을 통해 직접 페이지에 접근하거나, 뒤로 가기 버튼을 사용하는 등 네비게이션이 매우 중요합니다. 이번 글에서는 Flutter의 강력한 라우팅 라이브러리인 go_router를 소개하고, 이를 활용한 효과적인 라우팅 전략을 알아보겠습니다.

go_router 소개

go_router는 Flutter 팀에서 공식적으로 권장하는 라우팅 솔루션입니다. 기존의 Navigator 2.0의 복잡성을 해결하면서도, 선언적이고 강력한 라우팅 기능을 제공합니다.

주요 특징

  • 선언적 라우트 정의
  • 중첩 네비게이션 지원
  • URL 파라미터 및 쿼리 파라미터 지원
  • 딥링크 지원
  • 리다이렉션 규칙 설정
  • 에러 핸들링
  • 웹 플랫폼 최적화

설치하기

pubspec.yaml 파일에 다음과 같이 의존성을 추가합니다:

dependencies:
  go_router: ^14.3.0 # 2024-10 기준 최신 버전입니다.

1. 해시태그 제거하기

Flutter 웹 애플리케이션의 기본 URL 전략은 해시 기반입니다(예: example.com/#/home). 이는 SEO에 좋지 않고 사용자 경험도 저하시킵니다. usePathUrlStrategy()를 사용하면 이를 개선할 수 있습니다.

import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  usePathUrlStrategy(); // 해시태그 제거
  runApp(MyApp());
}

2. go_router 기본 설정

2.1 기본 라우터 설정

final router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
      routes: [
        GoRoute(
          path: 'details/:id',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            return DetailsScreen(id: id);
          },
        ),
      ],
    ),
  ],
);

2.2 Riverpod과 통합

Riverpod과 go_router를 통합하면 앱의 상태에 따른 동적 라우팅을 효과적으로 구현할 수 있습니다. 특히 인증 상태나 사용자 권한에 따른 라우팅 처리가 매우 용이해집니다.

기본 설정

먼저 필요한 provider들을 정의합니다:

// 인증 상태를 관리하는 provider
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
  return AuthNotifier();
});

// 라우터 provider
final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authStateProvider);

  return GoRouter(
    navigatorKey: rootNavKey,
    refreshListenable: authState as Listenable,
    initialLocation: '/',
    routes: [
      // 라우트 설정
    ],
    redirect: (context, state) {
      // 리다이렉트 로직
      return null;
    },
  );
});

인증 상태 클래스 구현

class AuthState extends ChangeNotifier {
  AuthStatus _status = AuthStatus.initial;
  User? _user;

  AuthState() {
    // 초기 인증 상태 체크
    checkAuthStatus();
  }

  AuthStatus get status => _status;
  User? get user => _user;

  void setAuthenticated(User user) {
    _user = user;
    _status = AuthStatus.authenticated;
    notifyListeners();
  }

  void setUnauthenticated() {
    _user = null;
    _status = AuthStatus.unauthenticated;
    notifyListeners();
  }
}

라우터 설정과 리다이렉트 로직

final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authStateProvider);

  return GoRouter(
    navigatorKey: rootNavKey,
    refreshListenable: authState as Listenable,
    initialLocation: '/',
    routes: [
      GoRoute(
        path: '/',
        builder: (context, state) => const HomeScreen(),
      ),
      GoRoute(
        path: '/login',
        builder: (context, state) => const LoginScreen(),
      ),
      GoRoute(
        path: '/dashboard',
        builder: (context, state) => const DashboardScreen(),
      ),
    ],
    redirect: (context, state) {
      final authStatus = authState.status;
      final isLoggingIn = state.matchedLocation == '/login';

      // 초기 상태일 경우 리다이렉트하지 않음
      if (authStatus == AuthStatus.initial) return null;

      // 인증되지 않은 사용자가 보호된 경로에 접근할 경우
      if (authStatus == AuthStatus.unauthenticated) {
        return isLoggingIn ? null : '/login';
      }

      // 인증된 사용자가 로그인 페이지에 접근할 경우
      if (authStatus == AuthStatus.authenticated && isLoggingIn) {
        return '/dashboard';
      }

      return null;
    },
  );
});

실제 사용 예시

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(routerProvider);

    return MaterialApp.router(
      routerConfig: router,
      title: 'My App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
    );
  }
}

// 로그인 처리 예시
class LoginScreen extends ConsumerWidget {
  const LoginScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            // 로그인 로직 수행
            final user = await performLogin();

            // 인증 상태 업데이트
            ref.read(authStateProvider.notifier).setAuthenticated(user);

            // go_router가 자동으로 리다이렉트 처리
          },
          child: const Text('로그인'),
        ),
      ),
    );
  }
}

이렇게 Riverpod과 통합하면 다음과 같은 이점이 있습니다:

  1. 상태 기반 라우팅: 앱의 상태 변화에 따라 자동으로 적절한 페이지로 리다이렉트됩니다.
  2. 코드 구조화: 라우팅 로직을 provider로 분리하여 관리할 수 있습니다.
  3. 테스트 용이성: provider를 통한 의존성 주입으로 테스트가 쉬워집니다.
  4. 유지보수성: 인증 관련 로직이 한 곳에서 관리되어 유지보수가 용이합니다.

2.3 URL 파라미터 활용

GoRoute(
  path: '/user/:userId/posts/:postId',
  builder: (context, state) {
    final userId = state.pathParameters['userId']!;
    final postId = state.pathParameters['postId']!;
    return PostDetailScreen(userId: userId, postId: postId);
  },
),

2.4 쿼리 파라미터 처리

GoRoute(
  path: '/search',
  builder: (context, state) {
    final query = state.uri.queryParameters['q'];
    final filter = state.uri.queryParameters['filter'];
    return SearchScreen(query: query, filter: filter);
  },
),

3. 고급 라우팅 기능

3.1 중첩 네비게이션

GoRoute(
  path: '/dashboard',
  builder: (context, state) => const DashboardScreen(),
  routes: [
    GoRoute(
      path: 'profile',
      builder: (context, state) => const ProfileScreen(),
    ),
    GoRoute(
      path: 'settings',
      builder: (context, state) => const SettingsScreen(),
    ),
  ],
),

3.2 리다이렉트 설정

redirect: (context, state) {
  final authState = ref.read(authStateProvider);
  final isLoginPage = state.matchedLocation == '/login';

  // 인증이 필요한 경로들
  final protectedPaths = ['/dashboard', '/profile', '/settings'];

  // 현재 경로가 보호된 경로인지 확인
  final isProtectedRoute = protectedPaths.any(
    (path) => state.matchedLocation.startsWith(path),
  );

  if (authState == AuthState.unauthenticated && isProtectedRoute) {
    // 로그인 페이지로 리다이렉트하면서 원래 가려던 경로를 쿼리 파라미터로 저장
    return '/login?redirect=${state.matchedLocation}';
  }

  if (authState == AuthState.authenticated && isLoginPage) {
    // 이미 인증된 사용자가 로그인 페이지에 접근하면 대시보드로 이동
    return '/dashboard';
  }

  return null;
},

3.3 에러 처리

errorBuilder: (context, state) {
  return MaterialPage(
    key: state.pageKey,
    child: Scaffold(
      appBar: AppBar(
        title: const Text('오류가 발생했습니다'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(
              Icons.error_outline,
              color: Colors.red,
              size: 60,
            ),
            const SizedBox(height: 16),
            Text(
              '요청하신 페이지를 찾을 수 없습니다:\n${state.error}',
              textAlign: TextAlign.center,
              style: const TextStyle(fontSize: 16),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => context.go('/'),
              child: const Text('홈으로 돌아가기'),
            ),
          ],
        ),
      ),
    ),
  );
},

4. 실전 활용 예제

4.1 페이지 전환 애니메이션

CustomTransitionPage<void>(
  key: state.pageKey,
  child: const DetailsScreen(),
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    return FadeTransition(
      opacity: animation,
      child: child,
    );
  },
),

4.2 페이지 히스토리 관리

// 뒤로 가기
context.pop();

// 특정 경로로 이동 (히스토리 추가)
context.push('/details/123');

// 특정 경로로 이동 (히스토리 교체)
context.replace('/new-route');

// 홈으로 이동하고 히스토리 초기화
context.go('/');

5. 성능 최적화

5.1 라우트 캐싱

final router = GoRouter(
  routes: routes,
  // 페이지 캐싱 설정
  restorationScopeId: 'router',
  // 캐시할 페이지 수 제한
  observers: [
    GoRouterObserver(
      navigatorObservers: () => [
        HeroController(),
      ],
    ),
  ],
);

6. 결론

go_router는 Flutter 웹 애플리케이션에서 강력하고 유연한 라우팅 솔루션을 제공합니다. URL 전략, 인증 처리, 중첩 네비게이션 등 다양한 시나리오를 효과적으로 처리할 수 있으며, Riverpod과의 통합을 통해 상태 관리도 깔끔하게 할 수 있습니다.

특히 웹 환경에서는 SEO 최적화, 딥링킹, 브라우저 히스토리 관리 등이 중요한데, go_router를 활용하면 이러한 요구사항들을 효과적으로 구현할 수 있습니다.

더 자세한 정보와 예제는 go_router 공식 문서에서 확인할 수 있습니다.