Flutter 개발

Flutter 딥링킹 완벽 가이드 4편: 라우터 상태 관리와 분석

Riverpod을 활용한 네비게이션 상태 관리와 사용자 행동 분석

2025년 9월 20일
8분 읽기
Flutter 딥링킹 완벽 가이드 4편: 라우터 상태 관리와 분석

라우터 상태 관리의 필요성

복잡한 앱에서 라우터 상태를 체계적으로 관리하는 것은 매우 중요합니다. 단순히 화면 간 이동만 처리하는 것이 아니라, 전체 앱의 네비게이션 흐름을 제어하고 추적해야 합니다.

라우터 상태 관리가 필요한 이유:

1. 네비게이션 히스토리 추적
사용자가 어떤 경로로 현재 화면에 도달했는지 알아야 합니다. 이는 뒤로가기 동작, breadcrumb 표시, 사용자 경로 분석 등에 필수적입니다. 특히 딥링크로 진입한 경우와 일반 네비게이션으로 진입한 경우를 구분해야 할 때도 있습니다.

2. 조건부 네비게이션
특정 조건에 따라 다른 화면으로 이동해야 하는 경우가 많습니다. 예를 들어, 결제 프로세스 중 사용자가 로그아웃되면 로그인 화면으로 보낸 후 다시 결제 화면으로 돌아와야 합니다. 이런 복잡한 흐름을 관리하려면 상태 관리가 필수입니다.

3. 전역 네비게이션 제어
어디서든 특정 화면으로 이동할 수 있어야 합니다. 푸시 노티피케이션, 에러 발생, 세션 만료 등 다양한 이벤트에 대응하여 적절한 화면으로 사용자를 안내해야 합니다.

4. 딥링크와 일반 네비게이션 통합
딥링크로 진입한 사용자도 자연스러운 네비게이션 경험을 제공받아야 합니다. 예를 들어, 상품 상세 화면으로 딥링크로 진입했더라도 뒤로가기를 누르면 상품 목록이 나와야 합니다.

5. 분석과 디버깅
사용자가 어떻게 앱을 사용하는지 이해하려면 네비게이션 패턴을 추적해야 합니다. 어느 화면에서 이탈이 많은지, 어떤 경로가 가장 많이 사용되는지 등의 데이터가 필요합니다.

Riverpod을 선택한 이유

Flutter의 상태 관리 솔루션은 여러 가지가 있지만, 라우터 상태 관리에는 Riverpod이 특히 적합합니다.

Riverpod의 장점:

1. 전역 접근성
라우터 상태는 앱 전체에서 접근할 수 있어야 합니다. Riverpod의 Provider는 BuildContext 없이도 어디서든 접근 가능하므로, 서비스 클래스나 유틸리티 함수에서도 라우터 상태를 제어할 수 있습니다.

2. 타입 안전성
컴파일 타임에 타입 체크가 이루어져 런타임 에러를 방지합니다. 라우터 파라미터나 상태 변경 시 타입 불일치를 미리 발견할 수 있습니다.

3. 의존성 관리
라우터 상태가 인증 상태, 앱 설정 등 다른 상태에 의존하는 경우가 많습니다. Riverpod은 이러한 의존성을 명시적으로 관리할 수 있어 코드가 명확해집니다.

4. 테스트 용이성
Provider를 쉽게 오버라이드할 수 있어 테스트 작성이 간편합니다. 특정 라우터 상태를 시뮬레이션하여 네비게이션 로직을 테스트할 수 있습니다.

5. 성능 최적화
필요한 부분만 리빌드되도록 최적화되어 있어, 라우터 상태 변경이 전체 앱을 리빌드하지 않습니다.

라우터 상태 모델 설계

효과적인 라우터 상태 관리를 위해서는 잘 설계된 상태 모델이 필요합니다.
Dart
// models/router_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'router_state.freezed.dart';

@freezed
class RouterState with _$RouterState {
  const factory RouterState({
    required String currentLocation,
    required List<String> history,
    @Default({}) Map<String, String> pathParameters,
    @Default({}) Map<String, String> queryParameters,
    @Default(false) bool canGoBack,
    String? pendingDeepLink,
    DateTime? lastNavigationTime,
  }) = _RouterState;
  
  factory RouterState.initial() => RouterState(
    currentLocation: '/',
    history: ['/'],
    lastNavigationTime: DateTime.now(),
  );
}

// models/navigation_event.dart
enum NavigationType {
  push,
  pop,
  replace,
  deeplink,
  redirect,
}

class NavigationEvent {
  final String from;
  final String to;
  final NavigationType type;
  final DateTime timestamp;
  final Map<String, dynamic>? metadata;

  NavigationEvent({
    required this.from,
    required this.to,
    required this.type,
    DateTime? timestamp,
    this.metadata,
  }) : timestamp = timestamp ?? DateTime.now();
}

Riverpod Provider 구현

StateNotifier를 사용하여 라우터 상태를 관리하는 Provider를 구현합니다.
Dart
// providers/router_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

class RouterNotifier extends StateNotifier<RouterState> {
  final Ref ref;
  late GoRouter _goRouter;
  final List<NavigationEvent> _events = [];

  RouterNotifier(this.ref) : super(RouterState.initial());

  void setRouter(GoRouter router) {
    _goRouter = router;
  }

  void updateLocation(String location, {
    Map<String, String>? pathParams,
    Map<String, String>? queryParams,
  }) {
    final newHistory = [...state.history, location];
    
    // 히스토리 크기 제한 (메모리 관리)
    if (newHistory.length > 50) {
      newHistory.removeAt(0);
    }

    state = state.copyWith(
      currentLocation: location,
      history: newHistory,
      pathParameters: pathParams ?? {},
      queryParameters: queryParams ?? {},
      canGoBack: newHistory.length > 1,
      lastNavigationTime: DateTime.now(),
    );

    // 이벤트 기록
    _recordEvent(
      from: state.currentLocation,
      to: location,
      type: NavigationType.push,
    );
  }

  void navigateTo(String location) {
    _goRouter.go(location);
    updateLocation(location);
  }

  void goBack() {
    if (state.canGoBack && state.history.length > 1) {
      final newHistory = [...state.history]..removeLast();
      final previousLocation = newHistory.last;
      
      _goRouter.go(previousLocation);
      state = state.copyWith(
        currentLocation: previousLocation,
        history: newHistory,
        canGoBack: newHistory.length > 1,
      );
      
      _recordEvent(
        from: state.currentLocation,
        to: previousLocation,
        type: NavigationType.pop,
      );
    }
  }

  void setPendingDeepLink(String deepLink) {
    state = state.copyWith(pendingDeepLink: deepLink);
  }

  void processPendingDeepLink() {
    if (state.pendingDeepLink != null) {
      navigateTo(state.pendingDeepLink!);
      state = state.copyWith(pendingDeepLink: null);
    }
  }

  void _recordEvent({
    required String from,
    required String to,
    required NavigationType type,
  }) {
    _events.add(NavigationEvent(
      from: from,
      to: to,
      type: type,
    ));
  }

  List<NavigationEvent> getRecentEvents([int limit = 10]) {
    final start = _events.length > limit ? _events.length - limit : 0;
    return _events.sublist(start);
  }
}

// Provider 정의
final routerProvider = StateNotifierProvider<RouterNotifier, RouterState>(
  (ref) => RouterNotifier(ref),
);

네비게이션 패턴 분석

사용자의 네비게이션 패턴을 분석하면 앱 개선에 중요한 인사이트를 얻을 수 있습니다.

분석해야 할 주요 패턴:

1. 사용자 경로 (User Journey)
사용자가 목표를 달성하기까지 거치는 화면들의 순서입니다. 예를 들어, 상품 구매 경로는 "홈 → 카테고리 → 상품 목록 → 상품 상세 → 장바구니 → 결제"와 같습니다. 이 경로를 분석하면 어디서 이탈이 발생하는지 파악할 수 있습니다.

2. 진입점과 이탈점 (Entry & Exit Points)
사용자가 앱에 들어오는 첫 화면과 나가는 마지막 화면을 추적합니다. 딥링크로 진입한 사용자와 앱 아이콘으로 진입한 사용자의 행동 차이를 비교할 수 있습니다.

3. 순환 패턴 (Circular Navigation)
동일한 화면을 반복적으로 방문하는 패턴입니다. 이는 사용자가 원하는 것을 찾지 못하거나 UI가 혼란스럽다는 신호일 수 있습니다.

4. 백트래킹 (Backtracking)
뒤로가기를 많이 사용하는 패턴입니다. 네비게이션 구조가 직관적이지 않거나 사용자가 실수로 잘못된 화면에 진입했을 가능성이 있습니다.

5. 화면 체류 시간 (Screen Duration)
각 화면에 머무는 시간을 측정합니다. 너무 짧으면 콘텐츠가 유용하지 않거나 로딩이 느릴 수 있고, 너무 길면 사용자가 혼란스러워하거나 다음 단계를 찾지 못하고 있을 수 있습니다.
Dart
// services/navigation_analytics.dart
class NavigationAnalytics {
  final List<NavigationEvent> _events = [];
  final Map<String, int> _screenVisits = {};
  final Map<String, Duration> _screenDuration = {};
  final Map<String, DateTime> _screenEnterTime = {};

  void trackNavigation(NavigationEvent event) {
    _events.add(event);
    
    // 화면 방문 횟수 증가
    _screenVisits[event.to] = (_screenVisits[event.to] ?? 0) + 1;
    
    // 이전 화면 체류 시간 계산
    if (_screenEnterTime.containsKey(event.from)) {
      final duration = event.timestamp.difference(
        _screenEnterTime[event.from]!
      );
      _screenDuration[event.from] = 
        (_screenDuration[event.from] ?? Duration.zero) + duration;
    }
    
    // 새 화면 진입 시간 기록
    _screenEnterTime[event.to] = event.timestamp;
  }

  Map<String, dynamic> getAnalytics() {
    return {
      'totalNavigations': _events.length,
      'uniqueScreens': _screenVisits.keys.length,
      'mostVisited': _getMostVisitedScreens(),
      'averageDuration': _getAverageDuration(),
      'userJourneys': _extractUserJourneys(),
      'backtrackingRate': _calculateBacktrackingRate(),
    };
  }

  List<String> _getMostVisitedScreens([int limit = 5]) {
    final sorted = _screenVisits.entries.toList()
      ..sort((a, b) => b.value.compareTo(a.value));
    
    return sorted
        .take(limit)
        .map((e) => e.key)
        .toList();
  }

  Map<String, double> _getAverageDuration() {
    final averages = <String, double>{};
    
    _screenDuration.forEach((screen, totalDuration) {
      final visits = _screenVisits[screen] ?? 1;
      averages[screen] = totalDuration.inSeconds / visits;
    });
    
    return averages;
  }

  List<List<String>> _extractUserJourneys() {
    final journeys = <List<String>>[];
    List<String> currentJourney = [];
    
    for (final event in _events) {
      if (event.type == NavigationType.deeplink ||
          currentJourney.isEmpty) {
        if (currentJourney.isNotEmpty) {
          journeys.add(currentJourney);
        }
        currentJourney = [event.to];
      } else {
        currentJourney.add(event.to);
      }
    }
    
    if (currentJourney.isNotEmpty) {
      journeys.add(currentJourney);
    }
    
    return journeys;
  }

  double _calculateBacktrackingRate() {
    if (_events.isEmpty) return 0.0;
    
    int backtrackCount = 0;
    for (int i = 1; i < _events.length; i++) {
      if (_events[i].type == NavigationType.pop) {
        backtrackCount++;
      }
    }
    
    return backtrackCount / _events.length;
  }
}

실시간 모니터링과 디버깅

개발과 운영 단계에서 라우터 상태를 실시간으로 모니터링하고 디버깅할 수 있는 도구가 필요합니다.

모니터링 도구의 핵심 기능:

1. 실시간 상태 표시
현재 라우터 상태를 시각적으로 표시합니다. 현재 위치, 히스토리 스택, 파라미터 등을 한눈에 볼 수 있어야 합니다.

2. 이벤트 로그
모든 네비게이션 이벤트를 시간순으로 기록하고 필터링할 수 있어야 합니다. 딥링크 진입, 리다이렉트, 에러 등을 구분하여 표시합니다.

3. 경로 시뮬레이션
특정 경로로 직접 이동하거나 네비게이션 시나리오를 재현할 수 있어야 합니다. 이는 버그 재현과 테스트에 유용합니다.

4. 성능 메트릭
네비게이션 성능을 측정합니다. 화면 전환 시간, 데이터 로딩 시간 등을 추적하여 병목 지점을 찾습니다.
Dart
// widgets/router_debugger.dart
class RouterDebugger extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final routerState = ref.watch(routerProvider);
    
    if (!kDebugMode) return const SizedBox.shrink();
    
    return DraggableScrollableSheet(
      initialChildSize: 0.1,
      minChildSize: 0.1,
      maxChildSize: 0.8,
      builder: (context, scrollController) {
        return Container(
          color: Colors.black87,
          child: ListView(
            controller: scrollController,
            padding: const EdgeInsets.all(16),
            children: [
              _buildHeader(routerState),
              const SizedBox(height: 16),
              _buildHistory(routerState),
              const SizedBox(height: 16),
              _buildParameters(routerState),
              const SizedBox(height: 16),
              _buildActions(ref),
            ],
          ),
        );
      },
    );
  }
  
  Widget _buildHeader(RouterState state) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'Current: ${state.currentLocation}',
          style: const TextStyle(
            color: Colors.white,
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
        ),
        Text(
          'Can Go Back: ${state.canGoBack}',
          style: const TextStyle(color: Colors.grey),
        ),
      ],
    );
  }
  
  Widget _buildHistory(RouterState state) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          'History:',
          style: TextStyle(
            color: Colors.white,
            fontWeight: FontWeight.bold,
          ),
        ),
        ...state.history.reversed.take(5).map(
          (location) => Text(
            '  • $location',
            style: const TextStyle(color: Colors.white70),
          ),
        ),
      ],
    );
  }
  
  Widget _buildParameters(RouterState state) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          'Parameters:',
          style: TextStyle(
            color: Colors.white,
            fontWeight: FontWeight.bold,
          ),
        ),
        if (state.pathParameters.isNotEmpty)
          ...state.pathParameters.entries.map(
            (e) => Text(
              '  ${e.key}: ${e.value}',
              style: const TextStyle(color: Colors.white70),
            ),
          ),
        if (state.queryParameters.isNotEmpty)
          ...state.queryParameters.entries.map(
            (e) => Text(
              '  ?${e.key}=${e.value}',
              style: const TextStyle(color: Colors.white70),
            ),
          ),
      ],
    );
  }
  
  Widget _buildActions(WidgetRef ref) {
    return Row(
      children: [
        ElevatedButton(
          onPressed: () {
            ref.read(routerProvider.notifier).goBack();
          },
          child: const Text('Go Back'),
        ),
        const SizedBox(width: 8),
        ElevatedButton(
          onPressed: () {
            ref.read(routerProvider.notifier).navigateTo('/');
          },
          child: const Text('Go Home'),
        ),
      ],
    );
  }
}

다음 단계

Riverpod을 활용한 라우터 상태 관리와 분석 시스템을 구축했습니다. 상태 모델 설계, 네비게이션 패턴 분석, 실시간 모니터링까지 실무에 필요한 핵심 기능들을 다뤘습니다.

핵심 포인트:
- 라우터 상태 관리는 복잡한 네비게이션 흐름 제어에 필수적입니다
- Riverpod은 전역 상태 관리와 의존성 처리에 탁월합니다
- 네비게이션 패턴 분석으로 사용자 경험을 개선할 수 있습니다
- 디버깅 도구는 개발 효율성을 크게 높여줍니다

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

다음 편에서는 지금까지 학습한 모든 내용을 통합하여 실제 앱에 적용하는 완전한 예제를 다루겠습니다.
#Riverpod
#상태관리
#라우터관리
#분석
#네비게이션패턴