Thursday, July 16, 2020

플러터(Flutter)에서 현재 화면 경로(Route)를 확인하는 효과적인 방법

플러터(Flutter)로 모바일 애플리케이션을 개발하다 보면, 사용자가 현재 어떤 화면을 보고 있는지 알아야 하는 경우가 자주 발생합니다. 가장 대표적인 예는 푸시 알림(FCM) 처리입니다. 특정 채팅방에 대한 알림을 받았을 때, 사용자가 이미 해당 채팅방 화면을 보고 있다면 중복해서 알림을 띄우는 대신 화면의 데이터만 갱신하는 것이 더 나은 사용자 경험을 제공합니다. 이 외에도 딥링킹(Deep Linking) 처리, 사용자 행동 분석, 특정 화면에서만 수행되어야 하는 백그라운드 작업 등 현재 활성화된 라우트(Route) 정보는 다양한 시나리오에서 필수적입니다.

네이티브 안드로이드 개발에서는 Activity의 생명주기(Lifecycle)를 통해 현재 활성화된 화면을 비교적 명확하게 파악할 수 있지만, 플러터의 위젯 기반 탐색(Navigation) 시스템에서는 조금 다른 접근 방식이 필요합니다. 플러터의 네비게이터(Navigator)는 화면(Route)들을 스택(Stack) 구조로 관리하기 때문에, 단순히 "현재 화면이 무엇인가?"를 묻는 것이 생각보다 까다로울 수 있습니다.

이 글에서는 플러터에서 현재 화면의 경로를 안정적으로 확인하는 두 가지 주요 방법, 즉 NavigatorState 확장(Extension)을 이용한 기법과 플러터의 공식적인 방법인 RouteObserver를 활용하는 심층적인 방법을 소개합니다. 각 방법의 장단점을 비교하고, 실제 프로젝트에서 어떤 상황에 어떤 방법을 선택해야 하는지 명확한 가이드를 제공합니다.

핵심 전제 조건: 라우트에 이름(Name) 부여하기

소개할 모든 방법을 효과적으로 사용하기 위한 가장 중요한 전제 조건은 각 라우트에 고유한 이름을 부여하는 것입니다. 라우트의 이름은 RouteSettings 객체를 통해 설정할 수 있으며, 이 이름을 기준으로 현재 화면을 식별하게 됩니다. 라우트 이름을 설정하는 방법은 주로 두 가지입니다.

1. MaterialApp의 `routes` 속성 사용

간단한 정적 라우팅의 경우, `MaterialApp`의 `routes` 맵을 사용하여 각 경로 이름에 해당하는 위젯을 직접 매핑할 수 있습니다. 이 경우 플러터가 자동으로 `RouteSettings`에 해당 키(경로 이름)를 `name`으로 설정해 줍니다.


MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (context) => HomeScreen(),
    '/details': (context) => DetailScreen(),
    '/settings': (context) => SettingsScreen(),
  },
);

2. onGenerateRoute 콜백 사용

동적으로 라우트 인자(argument)를 전달해야 하는 등 복잡한 라우팅이 필요할 때는 `onGenerateRoute` 콜백을 사용하는 것이 일반적입니다. 이 경우, `MaterialPageRoute`를 생성할 때 `settings` 속성을 명시적으로 전달하여 라우트 이름을 지정해야 합니다.


MaterialApp(
  onGenerateRoute: (settings) {
    if (settings.name == '/') {
      return MaterialPageRoute(
        settings: settings, // settings를 그대로 전달하는 것이 중요!
        builder: (context) => HomeScreen(),
      );
    }
    if (settings.name == '/chat') {
      final args = settings.arguments as ChatScreenArguments;
      return MaterialPageRoute(
        settings: settings, // '/chat'이라는 이름이 여기에 포함됩니다.
        builder: (context) => ChatScreen(roomId: args.roomId),
      );
    }
    // ... 다른 라우트 처리
    return null; // 해당하는 라우트가 없을 경우
  },
);

// Navigator.pushNamed(context, '/chat', arguments: ChatScreenArguments(roomId: '123'));

이처럼 모든 화면 전환(push) 시점에 `RouteSettings`와 `name`이 올바르게 설정되었다고 가정하고 다음 단계들을 살펴보겠습니다.

방법 1: `NavigatorState` 확장을 이용한 즉시 확인 기법

가장 간단하고 빠르게 현재 라우트를 확인할 수 있는 방법은 `NavigatorState`에 확장 함수를 추가하는 것입니다. 이 방법은 특정 순간에 "현재 최상위 화면이 A가 맞는가?"를 확인하는 일회성 검사에 매우 유용합니다.

NavigatorStateExtension 코드

아래 코드는 `NavigatorState`를 확장하여 `isCurrent`라는 새로운 메서드를 추가합니다. 이 메서드는 주어진 `routeName`이 현재 네비게이션 스택의 최상단에 있는지 여부를 확인합니다.


extension NavigatorStateExtension on NavigatorState {
  /// 주어진 routeName이 현재 화면인지 확인합니다.
  ///
  /// 네비게이션 스택을 순회하며 최상위 라우트의 이름과 일치하는지 검사합니다.
  /// popUntil의 predicate가 true를 반환하면 순회를 계속하고,
  /// 실제로는 아무것도 pop하지 않는 점을 이용한 트릭입니다.
  bool isCurrent(String routeName) {
    bool isCurrent = false;
    // popUntil은 predicate가 false를 반환할 때까지 스택에서 라우트를 제거합니다.
    // 하지만 여기서는 항상 true를 반환하여 실제 pop 동작은 막고,
    // 스택의 각 라우트를 순회하는 목적으로만 사용합니다.
    popUntil((route) {
      // 현재 스택의 최상위(가장 먼저 방문하는) 라우트의 이름과 비교합니다.
      if (route.settings.name == routeName) {
        isCurrent = true;
      }
      // 항상 true를 반환하여 스택에서 라우트가 제거되지 않도록 합니다.
      return true;
    });
    return isCurrent;
  }
}

코드 상세 설명

  • `extension NavigatorStateExtension on NavigatorState`: Dart의 확장(Extension) 기능으로, 기존 `NavigatorState` 클래스에 새로운 기능을 추가합니다. 이를 통해 `navigatorKey.currentState.isCurrent(...)`와 같이 마치 원래 있던 메서드처럼 사용할 수 있습니다.
  • `popUntil((route) { ... })`: 이 메서드는 본래 주어진 조건(predicate)이 `false`가 될 때까지 스택에서 화면을 계속 제거(pop)하는 역할을 합니다. 하지만 이 코드에서는 이 동작을 교묘하게 활용합니다.
  • `return true;`: `popUntil`의 콜백 함수가 `true`를 반환하면 "아직 멈출 조건이 아니니 계속 스택을 탐색하라"는 의미가 됩니다. 따라서 콜백 본문에서 어떤 일이 일어나든 항상 `true`를 반환하면, `popUntil`은 스택의 모든 라우트를 순회하기만 할 뿐 실제로 아무것도 제거하지 않습니다.
  • `if (route.settings.name == routeName)`: 순회 중인 `route`의 `settings.name`이 우리가 찾고자 하는 `routeName`과 일치하는지 확인합니다. `popUntil`은 스택의 최상단(top)부터 순회하므로, 이 조건이 처음 만족될 때 `isCurrent` 플래그를 `true`로 설정하면 됩니다. 하지만 이 코드는 전체 스택을 순회하므로, 정확히는 스택 '안에' 해당 라우트가 있는지 확인하는 용도에 더 가깝습니다. 최상위 라우트만 확인하려면 약간의 수정이 필요할 수 있습니다. (아래 개선된 버전 참고)

사용 예시: FCM 메시지 핸들러

이 확장 함수를 사용하려면 `MaterialApp`에 `GlobalKey`를 설정해야 합니다. 이 키를 통해 앱 어디서든 `NavigatorState`에 접근할 수 있습니다.


// main.dart 또는 앱의 진입점
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey, // 네비게이터 키 설정
      // ... routes, onGenerateRoute 등
    );
  }
}

// FCM 메시지를 처리하는 함수 (예: 별도의 서비스 클래스)
void handleFcmMessage(Map<String, dynamic> messageData) {
  // 메시지 데이터에서 타겟 라우트 이름을 추출했다고 가정
  final String targetRoute = messageData['target_route']; // 예: '/chat/123'

  // isCurrent 확장 함수를 사용하여 현재 화면인지 확인
  if (navigatorKey.currentState?.isCurrent(targetRoute) ?? false) {
    print("사용자가 이미 '$targetRoute' 화면에 있습니다. 알림을 표시하지 않고 UI만 갱신합니다.");
    // eventBus나 Provider 등을 통해 UI 갱신 신호를 보낼 수 있음
  } else {
    print("사용자가 다른 화면에 있습니다. 푸시 알림을 표시합니다.");
    // 시스템 트레이에 알림을 표시하는 로직
  }
}

`isCurrent` 메서드의 한계와 개선

위의 `isCurrent` 구현은 스택 전체를 순회하므로, 만약 `/home` 위에 `/chat`이 쌓여있는 상태에서 `isCurrent('/home')`을 호출해도 `true`를 반환합니다. 즉, "현재 스택에 존재하는가?"를 확인하는 것입니다. "현재 사용자에게 보이는 최상위 화면인가?"를 더 정확히 확인하려면 다음과 같이 수정할 수 있습니다.


extension BetterNavigatorStateExtension on NavigatorState {
  /// 현재 사용자에게 보이는 최상위 화면의 이름과 일치하는지 확인합니다.
  String? get currentRouteName {
    String? currentRouteName;
    popUntil((route) {
      currentRouteName = route.settings.name;
      return true; // 순회만 하고 pop은 하지 않음
    });
    return currentRouteName;
  }

  bool isCurrentTop(String routeName) {
    return currentRouteName == routeName;
  }
}

// 사용 예시
// if (navigatorKey.currentState?.isCurrentTop('/chat/123') ?? false) { ... }

이 개선된 버전은 `popUntil`을 이용해 최상위 라우트의 이름만 가져온 뒤 비교하므로, 의도에 더 정확하게 부합합니다.

방법 2: `RouteObserver`를 이용한 반응형 추적

앞선 확장 메서드 방식이 특정 시점의 스냅샷을 확인하는 '요청(pull)' 방식이라면, `RouteObserver`는 라우트 변경이 발생할 때마다 알려주는 '구독(push)' 방식입니다. 이는 더 복잡한 상태 관리나 실시간 추적이 필요할 때 훨씬 강력하고 안정적인, 플러터가 공식적으로 권장하는 방법입니다.

`RouteObserver`는 `Navigator`의 상태 변화(push, pop, replace 등)를 감지하고, 이를 구독하는 `RouteAware` 객체들에게 알려주는 역할을 합니다.

1단계: `RouteObserver` 설정

먼저, 앱 전체에서 사용할 `RouteObserver` 인스턴스를 생성하고 `MaterialApp`의 `navigatorObservers` 리스트에 추가합니다.


// main.dart 또는 앱의 상태 관리자
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` 믹스인(mixin)을 추가합니다. `RouteAware`는 다음과 같은 생명주기 메서드를 제공합니다.

  • didPush(): 이 라우트가 스택에 푸시되었을 때 호출됩니다. (즉, 화면이 나타났을 때)
  • didPop(): 이 라우트가 스택에서 팝되었을 때 호출됩니다. (즉, 화면이 사라졌을 때)
  • didPushNext(): 이 라우트 위에 다른 라우트가 푸시되었을 때 호출됩니다. (즉, 화면이 다른 화면에 의해 가려졌을 때)
  • didPopNext(): 이 라우트 바로 위의 라우트가 팝되었을 때 호출됩니다. (즉, 가려졌던 화면이 다시 나타났을 때)

이제 특정 화면에서 이 생명주기를 활용하는 예시를 보겠습니다.


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 {
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 2. 현재 위젯을 RouteObserver에 구독
    // ModalRoute.of(context)는 현재 위젯에 대한 라우트 정보를 담고 있습니다.
    final route = ModalRoute.of(context);
    if (route != null) {
      routeObserver.subscribe(this, route as PageRoute);
    }
  }

  @override
  void dispose() {
    // 3. 위젯이 제거될 때 구독 해제
    routeObserver.unsubscribe(this);
    super.dispose();
  }

  @override
  void didPush() {
    // 이 화면으로 들어왔을 때
    print("ChatScreen (roomId: ${widget.roomId}) is now visible.");
    // 현재 화면 정보를 전역 상태로 관리할 수 있음
    GlobalRouteTracker.instance.setCurrentRoute('/chat/${widget.roomId}');
  }

  @override
  void didPop() {
    // 이 화면에서 나갔을 때
    print("Leaving ChatScreen (roomId: ${widget.roomId}).");
    // 현재 화면 정보를 null 또는 이전 화면으로 설정
    GlobalRouteTracker.instance.clearCurrentRoute('/chat/${widget.roomId}');
  }
  
  @override
  void didPopNext() {
    // 위의 화면이 사라지고 이 화면이 다시 보일 때
    print("ChatScreen (roomId: ${widget.roomId}) is visible again.");
    GlobalRouteTracker.instance.setCurrentRoute('/chat/${widget.roomId}');
  }

  @override
  void didPushNext() {
    // 이 화면 위에 다른 화면이 표시될 때
    print("ChatScreen (roomId: ${widget.roomId}) is covered by another route.");
    GlobalRouteTracker.instance.clearCurrentRoute('/chat/${widget.roomId}');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Chat Room ${widget.roomId}')),
      body: Center(child: Text('This is the chat screen.')),
    );
  }
}

3단계: 전역 라우트 추적 서비스 구현

FCM 핸들러와 같이 위젯 컨텍스트 외부에서 현재 라우트 정보가 필요할 때는, `RouteAware`를 활용하여 현재 라우트 이름을 저장하는 전역 서비스(싱글턴 패턴 등)를 만드는 것이 매우 효과적입니다. 위 예시의 `GlobalRouteTracker`를 실제로 구현해 보겠습니다.


/// 앱의 현재 활성 라우트를 추적하는 싱글턴 클래스
class GlobalRouteTracker {
  // 싱글턴 인스턴스
  GlobalRouteTracker._privateConstructor();
  static final GlobalRouteTracker instance = GlobalRouteTracker._privateConstructor();

  String? _currentRouteName;

  String? get currentRouteName => _currentRouteName;

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

  /// RouteAware의 didPop, didPushNext에서 호출되어 현재 라우트를 해제합니다.
  /// 안전을 위해 이전 라우트 이름이 일치할 때만 null로 설정합니다.
  void clearCurrentRoute(String oldRouteName) {
    if (_currentRouteName == oldRouteName) {
      print("Global Tracker: Clearing current route $oldRouteName");
      _currentRouteName = null;
    }
  }
}

// FCM 핸들러에서 사용 예시
void handleFcmMessage(Map<String, dynamic> messageData) {
  final String targetRoute = messageData['target_route']; // 예: '/chat/123'

  if (GlobalRouteTracker.instance.currentRouteName == targetRoute) {
    print("사용자가 이미 '$targetRoute' 화면에 있습니다. (Checked via RouteObserver)");
  } else {
    print("사용자가 다른 화면에 있습니다. (Checked via RouteObserver)");
  }
}

이 패턴을 사용하면, 각 화면은 `RouteAware`를 통해 자신의 가시성(visibility)이 변경될 때마다 `GlobalRouteTracker`의 상태를 업데이트합니다. 그리고 FCM 핸들러와 같은 외부 서비스는 이 `GlobalRouteTracker`의 `currentRouteName` 속성을 읽기만 하면 되므로, 앱의 상태를 안정적이고 반응적으로 추적할 수 있습니다.

두 방법 비교 및 선택 가이드

| 특징 | `NavigatorState` 확장 | `RouteObserver` + `RouteAware` | | :--- | :--- | :--- | | **방식** | 요청 기반 (Pull) | 이벤트 기반 (Push / Reactive) | | **설정 복잡도** | 낮음 (확장 코드 추가, GlobalKey 설정) | 중간 (Observer 등록, Mixin 추가, 구독/해제) | | **정확성** | 구현에 따라 다름. 최상위 라우트만 확인하도록 주의 필요. | 높음. 플러터의 공식적인 라우트 생명주기를 따름. | | **성능** | 호출 시마다 스택 순회. 스택이 깊으면 비효율적일 수 있음. | 라우트 변경 시에만 콜백 호출. 지속적인 추적에 효율적. | | **주요 용도** | 간단한 일회성 확인. "버튼 클릭 시 현재 화면이 X인가?" | 전역 상태 관리, FCM/딥링크 처리, 화면별 분석 로깅 등. | | **추천** | 간단한 프로젝트나 빠른 프로토타이핑에 적합. | **대부분의 프로덕션 앱에 권장되는 안정적인 방법.** |

Navigator 2.0 (Router API) 환경에서는?

이 글에서 설명한 방법들은 주로 Navigator 1.0 (Imperative API)을 기준으로 합니다. 만약 `go_router`, `Beamer` 등과 같은 선언적(Declarative) 라우팅 라이브러리, 즉 Navigator 2.0을 사용하고 있다면 접근 방식이 근본적으로 달라집니다.

Navigator 2.0에서는 네비게이션 스택 자체가 앱의 상태(State)로부터 빌드됩니다. 따라서 "현재 라우트가 무엇인가?"를 묻는 대신, "현재 네비게이션 상태가 무엇인가?"를 확인해야 합니다. 예를 들어, `go_router`를 사용한다면 현재 경로를 확인하는 것은 매우 간단합니다.


// go_router 사용 시
final String currentLocation = GoRouter.of(context).location; // 예: '/chat/123?mode=edit'

if (currentLocation.startsWith('/chat/123')) {
  // 현재 채팅방에 있음
}

이처럼 선언적 라우팅에서는 라우팅 로직이 중앙 상태 관리 시스템과 통합되어 있으므로, 해당 상태를 직접 읽는 것이 가장 정확하고 자연스러운 방법입니다.

결론

플러터에서 현재 활성화된 화면을 확인하는 것은 앱의 사용자 경험과 안정성을 높이는 데 중요한 역할을 합니다. 정리하자면 다음과 같습니다.

  1. 가장 중요한 것은 `RouteSettings`를 통해 모든 라우트에 고유한 이름을 부여하는 것입니다. 이것이 모든 방법의 출발점입니다.
  2. 간단한 일회성 확인이 필요하다면 `NavigatorState` 확장(Extension)은 빠르고 편리한 해결책이 될 수 있습니다.
  3. 하지만 푸시 알림 처리, 전역 상태 관리 등 안정적이고 지속적인 추적이 필요한 대부분의 실제 애플리케이션에서는 `RouteObserver`와 `RouteAware`를 사용하는 것이 정석이자 가장 강력한 방법입니다.
  4. Navigator 2.0 기반의 라이브러리를 사용한다면, 네비게이터 스택을 직접 쿼리하기보다는 라우팅을 결정하는 애플리케이션의 상태를 직접 확인하는 것이 올바른 접근법입니다.

자신의 프로젝트 요구사항과 아키텍처에 맞는 방법을 선택하여, 더 똑똑하고 사용자 친화적인 플러터 앱을 만들어 보시기 바랍니다.


0 개의 댓글:

Post a Comment