Thursday, July 16, 2020

플러터 개발의 핵심, 현재 화면(Route)을 완벽하게 추적하는 실전 기술

플러터(Flutter)로 정교한 애플리케이션을 구축하다 보면 필연적으로 마주치는 질문이 있습니다. "사용자는 지금 어떤 화면을 보고 있는가?" 이 단순해 보이는 질문에 대한 답은 생각보다 훨씬 더 중요하며, 앱의 사용자 경험(UX)과 안정성을 결정짓는 핵심 요소로 작용합니다. 가장 대표적인 시나리오는 바로 실시간 채팅 앱의 푸시 알림(FCM) 처리입니다.

상상해 보십시오. 사용자가 '개발자 A'와의 채팅방에 들어가 대화를 나누고 있습니다. 이때 '개발자 A'로부터 새로운 메시지가 도착했다는 푸시 알림이 상단에 또다시 뜬다면 어떨까요? 사용자는 이미 해당 화면을 보고 있으므로 불필요한 중복 알림이 될 뿐입니다. 이는 명백히 좋지 않은 사용자 경험입니다. 이상적인 시나리오는, 앱이 사용자가 해당 채팅방에 있음을 인지하고 알림을 띄우는 대신, 조용히 채팅 목록만 새로고침하는 것입니다. 이처럼 현재 화면의 경로(Route) 정보는 사용자 경험을 한 차원 높이는 데 필수적입니다.

이 외에도 활용 사례는 무궁무진합니다.

  • 정교한 딥링킹(Deep Linking): myapp://products/1234와 같은 딥링크로 앱이 실행되었을 때, 사용자가 이미 해당 상품 상세 페이지에 머물고 있다면 페이지를 새로고침할 필요 없이 그대로 두는 것이 더 효율적입니다.
  • 정확한 사용자 행동 분석: Google Analytics, Firebase Analytics 등에 사용자가 어떤 화면에 얼마나 머물렀는지(`screen_view` 이벤트) 정확히 기록하려면 현재 화면의 이름을 알아야 합니다.
  • 효율적인 리소스 관리: 특정 화면에서만 재생되어야 하는 동영상이나 백그라운드 폴링(Polling) 작업이 있다면, 다른 화면으로 이동했을 때 이를 일시정지하고 다시 돌아왔을 때 재개해야 배터리와 데이터를 아낄 수 있습니다.

네이티브 안드로이드나 iOS 개발에서는 플랫폼이 제공하는 명확한 생명주기(Lifecycle) 모델(Activity, ViewController) 덕분에 현재 활성화된 컴포넌트를 파악하기가 비교적 수월합니다. 하지만 모든 것이 위젯(Widget)으로 이루어진 플러터의 세상에서는 탐색(Navigation) 또한 위젯 스택(Stack)을 기반으로 동작하기에, "현재 화면이 무엇인가?"라는 질문에 답하기 위해 조금 더 창의적인 접근이 필요합니다.

이 글에서는 플러터에서 현재 화면의 경로를 추적하는 두 가지 핵심적인 실전 기술을 밑바닥부터 심층적으로 파헤칩니다. 하나는 특정 순간에 즉시 확인이 가능한 `NavigatorState` 확장(Extension) 기법이며, 다른 하나는 플러터가 공식적으로 권장하는, 라우트의 모든 변화에 반응하는 `RouteObserver` 패턴입니다. 각 방법의 원리, 장단점, 그리고 실제 프로젝트 코드 예시를 통해 어떤 상황에서 어떤 기술을 선택해야 하는지에 대한 명확한 해답을 제시합니다.

1장: 모든 것의 시작, 라우트에 이름을 부여하라

앞으로 소개할 모든 기술을 적용하기 위한 절대적인 전제 조건은, 여러분의 앱에 존재하는 모든 라우트(화면)에 고유한 이름(Name)을 부여하는 것입니다. 이름 없는 라우트는 신원 미상의 존재와 같아서 우리가 추적하고 식별할 방법이 없습니다. 라우트 이름은 `RouteSettings` 객체를 통해 설정되며, 플러터의 네비게이션 시스템은 이 이름을 기준으로 라우트를 관리합니다.

라우트에 이름을 부여하는 가장 일반적인 두 가지 방법은 다음과 같습니다.

방법 1: `MaterialApp`의 `routes` 속성 활용 (정적 라우팅)

앱의 화면 구조가 단순하고, 화면 이동 시 복잡한 데이터를 전달할 필요가 없다면 `MaterialApp`의 `routes` 맵을 사용하는 것이 가장 간편합니다. 여기에 경로 이름(문자열)과 해당 경로가 빌드할 위젯 생성 함수를 매핑해두면, 플러터가 `Navigator.pushNamed` 호출 시 자동으로 해당 이름으로 `RouteSettings`를 생성해 줍니다.


// routes.dart
class AppRoutes {
  // 오타를 방지하고 중앙에서 관리하기 위해 상수로 정의하는 것을 권장합니다.
  static const String home = '/';
  static const String profile = '/profile';
  static const String settings = '/settings';
}

// main.dart
MaterialApp(
  initialRoute: AppRoutes.home,
  routes: {
    AppRoutes.home: (context) => HomeScreen(),
    AppRoutes.profile: (context) => ProfileScreen(),
    AppRoutes.settings: (context) => SettingsScreen(),
  },
);

// 사용 예시
// Navigator.pushNamed(context, AppRoutes.profile);

방법 2: `onGenerateRoute` 콜백 활용 (동적 라우팅)

대부분의 실용적인 앱에서는 화면 이동 시 특정 ID나 객체와 같은 인자(argument)를 전달해야 합니다. 예를 들어, 채팅방 화면으로 이동하려면 어떤 채팅방인지 식별하는 `roomId`가 필요합니다. 이처럼 동적인 라우팅 처리가 필요할 때 `onGenerateRoute` 콜백이 강력한 힘을 발휘합니다.

이 방법을 사용할 때 가장 중요한 점은, `MaterialPageRoute`를 생성할 때 `Navigator.pushNamed`에서 전달된 `settings` 객체를 반드시 그대로 전달해야 한다는 것입니다. 이 `settings` 객체 안에 우리가 지정한 `name`과 `arguments`가 모두 담겨있기 때문입니다.


// main.dart
MaterialApp(
  onGenerateRoute: (settings) {
    // settings 객체에는 name과 arguments가 포함되어 있습니다.
    // 예: Navigator.pushNamed(context, '/chat', arguments: 'room123');
    // -> settings.name == '/chat'
    // -> settings.arguments == 'room123'

    if (settings.name == '/chat') {
      final roomId = settings.arguments as String;
      return MaterialPageRoute(
        // 이 부분을 절대 잊지 마세요!
        settings: settings,
        builder: (context) => ChatScreen(roomId: roomId),
      );
    }
    
    if (settings.name == '/product_details') {
      final productId = settings.arguments as int;
      return MaterialPageRoute(
        // 라우트 이름이 '/product_details'로 설정됩니다.
        settings: settings, 
        builder: (context) => ProductDetailScreen(productId: productId),
      );
    }
    
    // 정의되지 않은 라우트 처리
    return MaterialPageRoute(
        builder: (context) => NotFoundScreen()
    );
  },
);

// 동적 라우트 이름 생성 예시
// '/chat/room123'과 같이 URL 파라미터 형식으로 이름을 관리하고 싶다면,
// onGenerateRoute에서 정규식(RegExp)을 사용해 파싱할 수도 있습니다.

// Navigator.pushNamed(context, '/chat', arguments: 'room123');
// Navigator.pushNamed(context, '/product_details', arguments: 99);

이제 모든 라우트가 명확한 신분증(`name`)을 갖게 되었으니, 이들을 추적하는 본격적인 여정을 시작해 보겠습니다.

2장: '지금 당장 확인!' - `NavigatorState` 확장 기법

이 방법은 특정 로직을 수행하는 바로 그 순간, "현재 사용자가 보고 있는 최상위 화면이 X가 맞는가?"를 즉시 확인해야 할 때 유용한, 빠르고 간결한 해결책입니다. 우리는 Dart의 확장(Extension) 기능을 이용해 `NavigatorState`에 새로운 커스텀 메서드를 추가하여 이 기능을 구현할 수 있습니다.

이 기법의 핵심은 `NavigatorState`가 가진 `popUntil` 메서드의 동작 원리를 교묘하게 활용하는 것입니다.

`NavigatorState` 확장 코드와 원리 분석


// utils/navigator_extension.dart

import 'package:flutter/widgets.dart';

extension NavigatorStateExtension on NavigatorState {
  /// 현재 네비게이션 스택의 최상단에 있는 라우트의 이름을 반환합니다.
  /// 라우트 이름이 설정되지 않은 경우 null을 반환할 수 있습니다.
  String? get currentRouteName {
    String? currentRouteName;
    // popUntil의 predicate 콜백은 스택의 각 라우트를 위에서부터(top to bottom) 순회합니다.
    // predicate가 true를 반환하면 순회를 멈추지 않습니다.
    // 우리는 이 점을 이용해, 실제로 아무것도 pop하지 않고 첫 번째 라우트의 이름만 얻어올 것입니다.
    popUntil((route) {
      // 순회 중 가장 먼저 만나는 라우트가 바로 현재 활성화된(최상위) 라우트입니다.
      currentRouteName = route.settings.name;
      // 여기서 false를 반환하면 첫 번째 라우트에서 순회가 즉시 중단됩니다.
      // 하지만 우리는 pop 동작 자체를 원하지 않으므로, 항상 true를 반환하여
      // 스택 끝까지 순회는 하되, pop은 일어나지 않도록 합니다.
      // 어차피 첫 번째 route에서 값을 이미 얻었으므로 성능상 큰 차이는 없습니다.
      return true; 
    });
    return currentRouteName;
  }

  /// 주어진 routeName이 현재 최상위 화면인지 확인합니다.
  bool isCurrent(String routeName) {
    return currentRouteName == routeName;
  }
}

`popUntil` 트릭 심층 분석

  • `popUntil(predicate)` 메서드의 본래 목적은 `predicate` 콜백이 `true`를 반환하는 라우트를 만날 때까지 스택에서 화면들을 제거(pop)하는 것입니다. 즉, `predicate`가 `false`를 반환하면 pop을 멈춥니다.
  • 하지만 위 코드에서는 `predicate`가 항상 `true`를 반환하도록 만들었습니다. 이는 "멈추지 말고 계속 스택을 탐색하라"는 신호와 같습니다. 결과적으로 `popUntil`은 스택의 모든 라우트를 순회하기만 할 뿐, 단 하나의 라우트도 스택에서 제거하지 않습니다.
  • `popUntil`은 스택의 최상단(top)부터 순회를 시작합니다. 따라서 콜백 함수가 처음으로 실행될 때의 `route`가 바로 사용자가 현재 보고 있는 화면입니다. 우리는 이 첫 번째 `route`의 `settings.name`을 변수에 저장하기만 하면 됩니다.

실제 프로젝트 적용 단계

이 확장 함수를 앱 전역에서 사용하려면, `MaterialApp`에 `GlobalKey`를 설정하여 어디서든 `NavigatorState`에 접근할 수 있는 통로를 만들어야 합니다.

1. GlobalKey 생성 및 등록


// main.dart

// 앱 전역에서 네비게이터 상태에 접근하기 위한 키
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

void main() {
  // ... Firebase 초기화 등
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // 1. 여기에 네비게이터 키를 설정합니다.
      navigatorKey: navigatorKey,
      title: 'Flutter Route Tracking Demo',
      // ... routes 또는 onGenerateRoute 설정
    );
  }
}

2. FCM 핸들러에서 사용 예시

이제 백그라운드 FCM 메시지를 처리하는 함수와 같이 `BuildContext`가 없는 곳에서도 현재 라우트를 확인할 수 있습니다.


// services/fcm_handler.dart
import 'package:my_app/main.dart'; // navigatorKey를 가져오기 위해
import 'package:my_app/utils/navigator_extension.dart'; // 우리가 만든 확장 함수를 가져오기 위해

Future<void> handleFcmMessage(Map<String, dynamic> messageData) async {
  // 예시: 푸시 데이터에 target_route와 roomId가 포함되어 있다고 가정
  // data: { 'target_route': '/chat', 'roomId': 'dev_A' }
  final String targetRoute = messageData['target_route'];
  final String roomId = messageData['roomId'];
  
  // onGenerateRoute에서 동적 라우팅을 사용한다면,
  // 라우트 이름 자체는 '/chat'으로 동일할 것입니다.
  // 이 경우, 라우트 이름과 함께 arguments까지 비교해야 더 정확합니다.
  // (이 예제에서는 이름만 비교하는 간단한 경우를 다룹니다)

  // 2. 확장 함수를 사용하여 현재 화면인지 확인
  final currentState = navigatorKey.currentState;
  if (currentState != null && currentState.isCurrent(targetRoute)) {
    print("사용자가 이미 '$targetRoute' 화면에 있습니다. 알림을 표시하지 않습니다.");
    // 여기에 EventBus, Provider, Riverpod 등을 사용하여
    // 현재 ChatScreen에 데이터 갱신이 필요하다는 신호를 보내는 로직을 구현합니다.
  } else {
    print("사용자가 다른 화면에 있습니다. 로컬 푸시 알림을 표시합니다.");
    // flutter_local_notifications 패키지 등을 사용하여 알림을 생성하는 로직
  }
}

`NavigatorState` 확장 기법의 한계

이 방법은 매우 간단하고 직관적이지만, 본질적으로는 요청(pull) 기반입니다. 즉, 우리가 확인하고 싶을 때마다 매번 네비게이터 스택을 확인해야 합니다. 또한, 라우트의 전체 생명주기(예: 화면이 다른 화면에 의해 가려졌을 때)를 추적하기에는 부적합합니다. 더 복잡하고 반응적인 상태 관리가 필요하다면, 다음 장에서 소개할 `RouteObserver`가 훨씬 우아하고 강력한 해답이 될 것입니다.

3장: '프로의 선택' - `RouteObserver` 반응형 패턴

`RouteObserver`는 플러터 프레임워크가 공식적으로 제공하는, 네비게이션 이벤트에 반응하여 동작하는 '구독(push)' 기반의 솔루션입니다. 특정 시점의 스냅샷만 보는 앞선 방법과 달리, `RouteObserver`는 `push`, `pop`, `replace` 등 모든 라우트 변경 이벤트를 감지하고, 이 변화에 관심 있는 '구독자'들에게 실시간으로 알려줍니다. 이 구독자 역할을 하는 것이 바로 `RouteAware` 믹스인(mixin)입니다.

이 패턴은 마치 방송국(`RouteObserver`)이 있고, 특정 프로그램의 방영 소식을 듣고 싶어 하는 시청자(`RouteAware`를 구현한 위젯)들이 있는 것과 같습니다. 라우트 상태가 변경되면 방송국이 모든 시청자에게 신호를 보내주는 원리입니다.

단계별 구현 가이드

1단계: `RouteObserver` 방송국 설립

먼저 앱 전체에서 단 하나만 존재할 `RouteObserver` 인스턴스를 생성하고, 이를 `MaterialApp`의 `navigatorObservers` 리스트에 등록하여 네비게이터의 모든 활동을 감시하도록 설정합니다.


// main.dart

// 앱 전역에서 사용할 RouteObserver 인스턴스.
// ModalRoute은 다이얼로그나 바텀시트 같은 모달 라우트까지 감지함을 의미합니다.
final RouteObserver<ModalRoute<dynamic>> routeObserver = RouteObserver<ModalRoute<dynamic>>();

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey,
      // 여기에 옵저버를 등록합니다.
      navigatorObservers: [routeObserver],
      // ...
    );
  }
}

2단계: `RouteAware`로 화면(위젯)을 시청자로 만들기

이제 라우트 변경 알림을 수신하고 싶은 위젯(일반적으로 `StatefulWidget`의 `State` 객체)에 `RouteAware` 믹스인을 추가해야 합니다. `RouteAware`를 추가하면 다음과 같은 강력한 생명주기 메서드들을 오버라이드할 수 있습니다.

  • didPush(): 이 라우트가 스택에 푸시되어 화면에 나타났을 때 호출됩니다.
  • didPop(): 이 라우트가 스택에서 팝 되어 화면에서 완전히 사라졌을 때 호출됩니다.
  • didPushNext(): 현재 라우트 위에 다른 라우트가 푸시되어 화면이 가려졌을 때 호출됩니다. (예: 채팅방 -> 프로필 화면)
  • didPopNext(): 바로 위를 덮고 있던 라우트가 팝 되어, 가려졌던 이 화면이 다시 나타났을 때 호출됩니다. (예: 프로필 화면 -> 채팅방)

이 생명주기들을 `ChatScreen`에 적용하는 완전한 코드는 다음과 같습니다.


// screens/chat_screen.dart
import 'package:my_app/main.dart'; // routeObserver를 가져오기 위해

class ChatScreen extends StatefulWidget {
  final String roomId;
  const ChatScreen({Key? key, required this.roomId}) : super(key: key);

  @override
  _ChatScreenState createState() => _ChatScreenState();
}

// 1. RouteAware 믹스인 추가
class _ChatScreenState extends State<ChatScreen> with RouteAware {

  // 2. 위젯이 위젯 트리에 삽입된 후, 의존성이 변경될 때 호출됩니다.
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 현재 라우트 정보를 얻어와서 routeObserver에 이 위젯(this)을 구독자로 등록합니다.
    // initState가 아닌 didChangeDependencies에서 하는 이유는,
    // ModalRoute.of(context)가 context에 의존하기 때문입니다.
    final route = ModalRoute.of(context);
    if (route != null) {
      routeObserver.subscribe(this, route as PageRoute);
    }
  }

  // 3. 위젯이 위젯 트리에서 완전히 제거될 때 호출됩니다.
  @override
  void dispose() {
    // 반드시 구독을 해제하여 메모리 누수를 방지해야 합니다.
    routeObserver.unsubscribe(this);
    super.dispose();
  }
  
  // -- RouteAware 생명주기 메서드 구현 --

  @override
  void didPush() {
    // 이 화면으로 네비게이션하여 들어왔을 때
    print("ChatScreen (ID: ${widget.roomId})이 푸시되었습니다. (didPush)");
    // 이 시점에 '현재 화면은 이 채팅방이다'라고 상태를 업데이트할 수 있습니다.
  }

  @override
  void didPop() {
    // 이 화면에서 뒤로가기 등으로 나갔을 때
    print("ChatScreen (ID: ${widget.roomId})이 팝 되었습니다. (didPop)");
    // '현재 화면 정보'를 초기화합니다.
  }

  @override
  void didPushNext() {
    // 이 화면 위로 다른 화면이 푸시되었을 때
    print("ChatScreen (ID: ${widget.roomId})이 다른 화면에 의해 가려졌습니다. (didPushNext)");
  }

  @override
  void didPopNext() {
    // 위의 화면이 사라지고 이 화면이 다시 활성화되었을 때
    print("ChatScreen (ID: ${widget.roomId})이 다시 전면에 나타났습니다. (didPopNext)");
  }

  @override
  Widget build(BuildContext context) {
    // ... 위젯 빌드 로직
    return Scaffold(
      appBar: AppBar(title: Text('Chat Room: ${widget.roomId}')),
      body: Center(child: Text('대화 내용')),
    );
  }
}

4장: 최종 진화 - `RouteObserver`와 전역 상태 추적기 결합

지금까지의 구현으로 각 화면은 자신의 상태 변화를 알 수 있게 되었습니다. 하지만 우리의 원래 목표였던 'FCM 핸들러'와 같이 위젯 트리 바깥에 있는 외부 서비스는 이 정보를 어떻게 알 수 있을까요? 바로 여기에 `RouteAware`의 생명주기 콜백과 전역 상태 관리자를 결합하는 궁극의 패턴이 등장합니다.

아이디어는 간단합니다. `RouteAware` 콜백이 호출될 때마다, 앱의 현재 라우트 정보를 담는 전역 싱글턴(Singleton) 객체의 상태를 업데이트하는 것입니다. 그러면 다른 어떤 서비스든 이 싱글턴 객체를 통해 현재 라우트 정보를 손쉽게 조회할 수 있습니다.

1단계: 전역 라우트 추적기(`GlobalRouteTracker`) 구현

앱의 현재 활성 라우트 이름을 저장하고 제공하는 단순한 싱글턴 클래스를 만듭니다.


// services/global_route_tracker.dart

/// 앱의 현재 활성 라우트를 추적하고 제공하는 싱글턴 클래스.
class GlobalRouteTracker {
  // private 생성자로 외부에서 인스턴스 생성을 막습니다.
  GlobalRouteTracker._privateConstructor();
  // 앱 전체에서 공유될 단일 인스턴스
  static final GlobalRouteTracker instance = GlobalRouteTracker._privateConstructor();

  String? _currentRouteName;
  Object? _currentRouteArguments;

  String? get currentRouteName => _currentRouteName;
  Object? get currentRouteArguments => _currentRouteArguments;

  /// RouteAware의 didPush, didPopNext에서 호출되어 현재 라우트를 설정합니다.
  void setCurrentRoute(String routeName, {Object? arguments}) {
    print("Global Tracker: Current route set to $routeName with args $arguments");
    _currentRouteName = routeName;
    _currentRouteArguments = arguments;
  }

  /// RouteAware의 didPop, didPushNext에서 호출되어 현재 라우트를 해제합니다.
  /// 안전을 위해 이전 라우트 이름이 일치할 때만 null로 설정합니다.
  /// 이는 빠른 화면 전환 시 발생할 수 있는 경쟁 상태(race condition)를 방지하는 데 도움이 됩니다.
  void clearCurrentRoute(String oldRouteName) {
    if (_currentRouteName == oldRouteName) {
      print("Global Tracker: Clearing current route $oldRouteName");
      _currentRouteName = null;
      _currentRouteArguments = null;
    }
  }
}

2단계: `RouteAware`와 `GlobalRouteTracker` 연결

이제 `_ChatScreenState`의 `RouteAware` 메서드들이 `GlobalRouteTracker`를 업데이트하도록 수정합니다.


// screens/chat_screen.dart (수정된 부분)
import 'package:my_app/services/global_route_tracker.dart';

class _ChatScreenState extends State<ChatScreen> with RouteAware {
  // ... didChangeDependencies, dispose는 동일 ...

  String get routeName => ModalRoute.of(context)!.settings.name!;
  
  @override
  void didPush() {
    GlobalRouteTracker.instance.setCurrentRoute(
        routeName, 
        arguments: ModalRoute.of(context)!.settings.arguments
    );
  }

  @override
  void didPop() {
    GlobalRouteTracker.instance.clearCurrentRoute(routeName);
  }

  @override
  void didPopNext() {
    // 다시 이 화면이 보일 때
    GlobalRouteTracker.instance.setCurrentRoute(
        routeName,
        arguments: ModalRoute.of(context)!.settings.arguments
    );
  }

  @override
  void didPushNext() {
    // 이 화면이 가려질 때
    GlobalRouteTracker.instance.clearCurrentRoute(routeName);
  }
  
  // ...
}

3단계: FCM 핸들러에서 최종 활용

이제 FCM 핸들러는 더 이상 `navigatorKey`에 의존할 필요 없이, `GlobalRouteTracker`의 상태만 확인하면 됩니다. 코드가 훨씬 깔끔해지고 역할이 명확하게 분리되었습니다.


// services/fcm_handler.dart
import 'package:my_app/services/global_route_tracker.dart';

Future<void> handleFcmMessage(Map<String, dynamic> messageData) async {
  final String targetRoute = messageData['target_route']; // 예: '/chat'
  final String targetRoomId = messageData['roomId'];     // 예: 'dev_A'

  final tracker = GlobalRouteTracker.instance;
  
  // 현재 라우트 이름과 인자(roomId)가 모두 일치하는지 확인
  if (tracker.currentRouteName == targetRoute && tracker.currentRouteArguments == targetRoomId) {
    print("사용자가 이미 '$targetRoomId' 채팅방에 있습니다. (Checked via GlobalRouteTracker)");
    // UI 갱신 로직 호출
  } else {
    print("사용자가 다른 화면에 있습니다. (Checked via GlobalRouteTracker)");
    // 로컬 푸시 알림 표시
  }
}

5장: 최신 트렌드 - Navigator 2.0 (선언적 라우팅)의 접근법

지금까지 논의한 방법들은 `Navigator.pushNamed`, `Navigator.pop`과 같은 명령형(Imperative) API, 즉 Navigator 1.0을 기반으로 합니다. 하지만 최근 플러터 생태계에서는 `go_router`, `Beamer`와 같은 선언적(Declarative) 라우팅 라이브러리, 즉 Navigator 2.0의 채택이 늘고 있습니다.

선언적 라우팅의 패러다임은 근본적으로 다릅니다. 우리는 더 이상 "네비게이터야, 이 화면을 푸시해줘"라고 명령하지 않습니다. 대신 "앱의 현재 상태가 이러하니, 이 상태에 맞는 화면 스택을 그려줘"라고 선언합니다. 네비게이션 스택 자체가 앱의 상태(State)로부터 파생되는 것입니다.

따라서 이 환경에서는 "현재 라우트가 무엇인가?"를 묻는 대신, "현재 라우팅 상태가 무엇인가?"를 확인하는 것이 올바른 접근입니다. 예를 들어, 가장 인기 있는 `go_router`를 사용한다면 현재 경로 확인은 매우 간단합니다.


// go_router를 사용하는 위젯 내에서
final String currentLocation = GoRouter.of(context).location;
// currentLocation은 '/chat/dev_A'와 같은 전체 경로 문자열을 반환합니다.

if (currentLocation == '/chat/$targetRoomId') {
  // 사용자는 이미 해당 채팅방에 있습니다.
}

// go_router의 상태를 직접 읽을 수도 있습니다.
final GoRouterState state = GoRouter.of(context).routerDelegate.router.routerState;
print(state.location); // 현재 위치
print(state.extra); // 전달된 arguments

더 나아가, `go_router`를 Riverpod이나 BLoC과 같은 상태 관리 솔루션과 함께 사용한다면, 라우터의 상태를 직접 읽기보다는 라우팅을 결정하는 애플리케이션의 핵심 상태(Core State)를 직접 읽는 것이 더욱 선언적인 방식입니다.

결론 및 최종 선택 가이드

플러터에서 현재 활성화된 화면을 추적하는 것은 앱의 품질을 한 단계 끌어올리기 위한 필수적인 기술입니다. 두 가지 핵심 전략을 다시 한번 정리하고, 어떤 것을 선택해야 할지 최종 가이드를 제시합니다.

특징 `NavigatorState` 확장 `RouteObserver` + `RouteAware`
접근 방식 요청 기반 (Pull) - 필요할 때 직접 묻기 이벤트 기반 (Push / Reactive) - 변경 시 알림 받기
설정 복잡도 낮음 (확장 코드, GlobalKey) 중간 (Observer 등록, Mixin 추가, 구독/해제 등)
정확성 및 기능 최상위 라우트 확인에 국한됨. 생명주기 추적 불가. 높음. Push, Pop, 화면 가려짐/드러남 등 전체 생명주기 추적 가능.
결합도(Coupling) `navigatorKey`에 대한 직접적인 의존성 발생. 낮음. 전역 추적기와 결합 시, 각 컴포넌트가 분리됨.
주요 용도 간단한 일회성 확인. 빠른 프로토타이핑. FCM/딥링크 처리, 분석, 리소스 관리 등 복잡한 상태 관리.
최종 추천 간단한 프로젝트나 특정 유틸리티 함수에 적합. 대부분의 프로덕션 레벨 앱에 강력히 권장되는 표준 방식.

요약하자면 다음과 같습니다.

  1. 라우트 이름 부여는 기본 중의 기본입니다. 이것이 모든 추적의 시작점입니다.
  2. 빠르게 현재 화면 이름만 확인하고 싶다면 `NavigatorState` 확장은 훌륭한 도구입니다.
  3. 하지만 앱의 규모가 커지고 안정적이며 반응적인 로직이 필요하다면, 고민 없이 `RouteObserver`와 `RouteAware` 패턴을 선택해야 합니다. 특히 전역 상태 추적기와 결합할 때 그 진가가 드러납니다.
  4. Navigator 2.0 기반의 선언적 라우팅을 사용한다면, 패러다임을 전환하여 라우팅을 결정하는 앱의 상태 자체를 신뢰의 원천(Source of Truth)으로 삼아야 합니다.

여러분의 프로젝트 요구사항과 아키텍처에 가장 적합한 방법을 선택하여, 사용자를 배려하는 더욱 지능적이고 견고한 플러터 애플리케이션을 만들어 나가시길 바랍니다.


0 개의 댓글:

Post a Comment