FCM 알림 탭과 앱 라우팅 연결하기

FCM 알림 탭과 앱 라우팅 연결하기

한눈에 보기

FCM 알림을 탭했을 때 앱이 종료돼 있었는지 background였는지에 따라 진입 API가 다르다. 각 callback에서 곧바로 Navigator.push하지 않고 payload를 작고 버전이 있는 NotificationIntent로 변환한다. 이 intent를 앱 초기화와 인증이 완료될 때까지 보관한 뒤 단일 coordinator가 route로 해석한다. Payload의 resource ID와 권한은 신뢰하지 않고 서버에서 다시 확인하며, 중복 탭·삭제된 리소스·계정 전환을 정상 상태로 처리한다.

목차

알림 Payload는 화면 이동 명령이 아니다

서버가 다음 data payload를 보냈다고 하자.

{
  "type": "ENTRY_DETAIL",
  "entryId": "entry-demo-42"
}

알림 callback에서 바로 화면을 push하면 간단해 보인다.

void handleMessage(RemoteMessage message) {
  Navigator.of(context).pushNamed(
    '/entries/${message.data['entryId']}',
  );
}

하지만 callback이 실행되는 시점에 다음 조건이 준비됐다는 보장이 없다.

BuildContext를 오래 보관해 사용하면 widget이 unmount됐을 수도 있다. cold start에서는 context 자체가 아직 없다.

책임 분리

FCM callback은 “사용자가 어떤 알림을 탭해 앱으로 들어왔다”는 intent를 수집한다. 실제 route 결정은 앱 상태를 아는 navigation coordinator가 한다.

이 글의 message type과 resource ID는 실제 서비스 payload가 아닌 가상 기록 앱 예시다.

앱 상태에 따라 다른 진입 경로

Firebase의 Flutter 공식 문서는 notification interaction을 두 경로로 설명한다.

foreground 수신은 onMessage로 처리하지만 이것은 “system tray 알림을 탭했다”는 event와 같지 않다. foreground에서 local notification을 직접 표시한다면 그 알림 plugin의 tap callback을 같은 intent pipeline에 연결해야 한다.

앱 상태 수신·상호작용 경로 Navigation 시점
Terminated getInitialMessage() 초기화·인증 뒤
Background onMessageOpenedApp resume state 안정화 뒤
Foreground onMessage 또는 local notification tap 앱 내 표시 정책에 따라
Local notification tap plugin callback 같은 intent coordinator

getInitialMessage()는 한 번 소비되는 initial message이므로 startup coordinator에서 수집한다. 여러 화면의 initState()가 각각 호출하지 않는다.

flowchart LR
    A[getInitialMessage] --> D[NotificationIntentParser]
    B[onMessageOpenedApp] --> D
    C[Local notification tap] --> D
    D --> E[PendingIntentStore]
    E --> F{App and Auth ready?}
    F -- No --> E
    F -- Yes --> G[NotificationNavigationCoordinator]
    G --> H[Declarative Router]

Payload에는 최소한의 안정적인 식별자만 넣기

FCM data payload를 Native router의 serialized state처럼 만들지 않는다.

{
  "route": "/entries/42?token=secret&tab=private",
  "title": "민감한 사용자 정보",
  "serializedEntry": "{...전체 객체...}"
}

알림 payload는 OS와 push infrastructure를 거치고 lock screen에 표시될 수 있다. 필요한 최소 식별자와 type만 넣는다.

{
  "schemaVersion": "1",
  "action": "ENTRY_DETAIL_OPEN",
  "resourceId": "entry-public-demo-42",
  "notificationId": "notification-demo-91",
  "sentAtEpochMs": "1769130000000"
}

FCM data map의 값은 문자열로 전달되는 환경을 기준으로 parser가 명시적으로 변환한다.

필드 역할 신뢰 수준
schemaVersion payload 해석 방식 지원 여부만 확인
action 허용된 navigation 의도 allowlist 검사
resourceId 서버 조회용 opaque ID 권한 근거로 사용 금지
notificationId 중복 처리와 읽음 상태 인증된 서버에서 재검증
sentAtEpochMs 만료·진단 보조 기기 시각만 믿지 않음

사용자 이름, 메시지 원문, token, 내부 DB primary key를 불필요하게 넣지 않는다. OS notification title/body에도 lock screen 노출 정책을 적용한다.

문자열 Map을 Notification Intent로 변환하기

허용한 action을 sealed type으로 변환한다.

sealed class NotificationIntent {
  const NotificationIntent({
    required this.notificationId,
    required this.receivedAt,
  });

  final String notificationId;
  final DateTime receivedAt;
}

final class OpenEntryIntent extends NotificationIntent {
  const OpenEntryIntent({
    required super.notificationId,
    required super.receivedAt,
    required this.entryId,
  });

  final String entryId;
}

final class OpenInboxIntent extends NotificationIntent {
  const OpenInboxIntent({
    required super.notificationId,
    required super.receivedAt,
  });
}

parser는 필드 존재와 형식을 확인한다.

final class NotificationIntentParser {
  NotificationIntent parse(
    Map<String, dynamic> data,
    DateTime receivedAt,
  ) {
    final version = int.tryParse(
      data['schemaVersion']?.toString() ?? '',
    );
    final action = data['action']?.toString();
    final notificationId =
        data['notificationId']?.toString();

    if (version != 1 ||
        notificationId == null ||
        !notificationIdPattern.hasMatch(notificationId)) {
      throw const NotificationPayloadFailure.invalid();
    }

    return switch (action) {
      'ENTRY_DETAIL_OPEN' => OpenEntryIntent(
          notificationId: notificationId,
          receivedAt: receivedAt,
          entryId: parseResourceId(data['resourceId']),
        ),
      'INBOX_OPEN' => OpenInboxIntent(
          notificationId: notificationId,
          receivedAt: receivedAt,
        ),
      _ => throw const NotificationPayloadFailure.unsupported(),
    };
  }
}

알 수 없는 action을 문자열 route로 그대로 실행하지 않는다. 구버전 앱이면 안전한 inbox fallback이나 업데이트 안내를 선택한다.

FCM 진입점을 하나의 Stream으로 합치기

Firebase API 세부사항을 navigation layer에서 숨긴다.

abstract interface class NotificationInteractionSource {
  Future<NotificationIntent?> initialIntent();
  Stream<NotificationIntent> get openedIntents;
}

final class FirebaseNotificationInteractionSource
    implements NotificationInteractionSource {
  FirebaseNotificationInteractionSource({
    required this.messaging,
    required this.parser,
    required this.clock,
  });

  final FirebaseMessaging messaging;
  final NotificationIntentParser parser;
  final Clock clock;

  @override
  Future<NotificationIntent?> initialIntent() async {
    final message = await messaging.getInitialMessage();
    if (message == null) return null;
    return parser.parse(message.data, clock.now());
  }

  @override
  Stream<NotificationIntent> get openedIntents =>
      FirebaseMessaging.onMessageOpenedApp
          .map((message) => parser.parse(
                message.data,
                clock.now(),
              ));
}

parser 오류는 stream 전체를 종료시키지 않고 telemetry에 기록한 뒤 해당 message만 버리도록 adapter에서 처리할 수 있다.

local notification source도 같은 NotificationIntent를 반환한다. navigation coordinator는 FCM인지 local plugin인지 알 필요가 없다.

앱이 준비될 때까지 Intent 보관하기

cold start 흐름에서는 다음 단계가 비동기로 진행된다.

Firebase 초기화
local DB migration
secure session 복원
remote config·필수 버전 확인
router 생성
initial notification 수집

initial message를 일찍 읽되 navigation은 readiness gate 뒤에 실행한다.

final class PendingNotificationIntentStore {
  NotificationIntent? _pending;

  void offer(NotificationIntent intent) {
    _pending ??= intent;
  }

  NotificationIntent? take() {
    final value = _pending;
    _pending = null;
    return value;
  }
}

여러 intent가 들어오면 하나만 보관할지 queue로 둘지 제품 정책이 필요하다. 사용자가 직접 탭한 최신 intent는 우선순위가 높고, background에서 단순 수신한 message는 navigation intent가 아니다.

Future<void> start() async {
  final initial = await interactionSource.initialIntent();
  if (initial != null) {
    pendingStore.offer(initial);
  }

  _subscription = interactionSource.openedIntents.listen(
    pendingStore.offer,
    onError: reportInvalidInteraction,
  );

  await readiness.whenReady;
  await drainPendingIntent();
}

readiness가 실패하면 intent를 무한히 유지하지 않는다. 강제 업데이트·초기화 오류 화면 뒤에 재개할지 만료시킬지 정한다.

인증 상태와 대상 계정을 확인하기

payload의 resource ID는 접근 권한 증명이 아니다. 현재 로그인 사용자의 token으로 서버에 조회하고 403·404를 처리한다.

Future<NavigationResolution> resolve(
  NotificationIntent intent,
  AuthState auth,
) async {
  if (auth case Authenticated()) {
    return switch (intent) {
      OpenEntryIntent(:final entryId) =>
        await resolveEntry(entryId),
      OpenInboxIntent() =>
        const NavigationResolution.inbox(),
    };
  }

  return NavigationResolution.requiresLogin(intent);
}

로그인이 필요하면 intent를 안전한 메모리 또는 제한된 local state에 보관하고 로그인 성공 뒤 재개할 수 있다. payload에 이메일이나 account ID를 넣어 자동 계정 전환의 근거로 사용하지 않는다.

stateDiagram-v2
    [*] --> Received
    Received --> WaitingForApp
    WaitingForApp --> WaitingForAuth
    WaitingForAuth --> Resolving
    Resolving --> Navigated
    Resolving --> NotFound
    Resolving --> Forbidden
    WaitingForAuth --> Expired
    Navigated --> [*]
    NotFound --> [*]
    Forbidden --> [*]
    Expired --> [*]

로그인 화면을 취소하면 pending intent도 정책에 따라 폐기한다. 다른 계정으로 로그인했다면 서버 조회 결과로 접근 가능성을 판단한다.

Declarative Router에서 Intent 소비하기

imperative Navigator.push를 callback마다 호출하기보다 app navigation state를 변경한다.

sealed class AppDestination {
  const AppDestination();
}

final class EntryDestination extends AppDestination {
  const EntryDestination(this.entryId);
  final String entryId;
}

final class InboxDestination extends AppDestination {
  const InboxDestination();
}

coordinator는 resolution을 destination으로 바꾼다.

Future<void> handle(
  NotificationIntent intent,
) async {
  if (!deduplicator.begin(intent.notificationId)) {
    return;
  }

  try {
    final resolution = await resolver.resolve(
      intent,
      authRepository.currentState,
    );

    switch (resolution) {
      case EntryResolved(:final entryId):
        appNavigation.open(
          EntryDestination(entryId),
          source: NavigationSource.notification,
        );
      case InboxResolved():
        appNavigation.open(
          const InboxDestination(),
          source: NavigationSource.notification,
        );
      case RequiresLogin():
        pendingAfterLogin.save(intent);
        appNavigation.openLogin();
      case ResourceNotFound():
        appNavigation.openInboxWithMessage(
          '이미 삭제된 항목입니다.',
        );
      case Forbidden():
        appNavigation.openInboxWithMessage(
          '이 항목을 볼 권한이 없습니다.',
        );
    }
  } finally {
    deduplicator.finish(intent.notificationId);
  }
}

같은 detail을 이미 보고 있다면 stack에 중복 push하지 않고 현재 destination의 데이터를 refresh한다. 선언형 라우팅의 state 구성은 Flutter Navigator와 선언형 라우팅 비교와 연결된다.

Foreground 알림은 별도 표시 정책이 필요하다

앱이 foreground일 때 message를 받았다고 자동으로 detail로 이동하면 사용자가 작성 중이던 화면을 잃을 수 있다.

FirebaseMessaging.onMessage.listen((message) {
  final presentation = foregroundPolicy.decide(
    message: message,
    currentDestination: appNavigation.current,
  );

  switch (presentation) {
    case SuppressNotification():
      unreadCounter.refresh();
    case ShowInAppBanner(:final intent):
      inAppBanner.show(intent);
    case ShowLocalNotification(:final content):
      localNotifications.show(content);
  }
});

현재 같은 resource를 보고 있으면 badge만 갱신하고 banner를 생략할 수 있다. local notification을 표시했다면 그 tap callback만 navigation intent로 변환한다. onMessage 수신 자체는 사용자 tap이 아니다.

중복 탭과 재전달을 막기

사용자가 알림을 빠르게 두 번 탭하거나 플랫폼 callback이 재연결되는 경우 동일 route가 여러 번 열릴 수 있다.

final class NotificationDeduplicator {
  final Set<String> _inFlight = {};
  final LruSet<String> _recentlyHandled;

  bool begin(String notificationId) {
    if (_inFlight.contains(notificationId) ||
        _recentlyHandled.contains(notificationId)) {
      return false;
    }
    _inFlight.add(notificationId);
    return true;
  }

  void finish(String notificationId) {
    _inFlight.remove(notificationId);
    _recentlyHandled.add(notificationId);
  }
}

process 재시작까지 중복 제거가 필요하면 최근 ID와 처리 시각을 제한된 local storage에 보관한다. 무기한 저장하지 않고 TTL과 최대 개수를 둔다.

FCM message ID와 업무 notification ID를 구분한다. 서버가 같은 업무 알림을 다시 발행할 수 있다면 업무 ID가 더 안정적인 중복 기준이다.

navigation 자체도 idempotent하게 만든다.

void openEntry(String entryId) {
  if (currentDestination
      case EntryDestination(entryId: final current)
      when current == entryId) {
    refreshCurrentEntry();
    return;
  }
  replaceOrPushEntry(entryId);
}

리소스가 없거나 권한이 바뀐 경우

알림 발송 뒤 사용자가 resource를 삭제하거나 공유 권한을 잃을 수 있다. detail fetch의 404와 403은 앱 오류가 아닌 정상적인 시간 차다.

조회 결과 Navigation
200 detail 열기
401 session refresh 또는 login
403 inbox + 권한 안내
404 inbox + 삭제 안내
timeout cached shell 또는 재시도 UI
5xx 안정적인 error destination

payload에 title과 전체 resource를 넣고 그대로 그리면 삭제·권한 변경을 우회한 stale data를 보여 줄 수 있다. payload는 조회 힌트일 뿐이다.

notification 읽음 처리도 detail navigation과 분리한다. 읽음 API가 실패해도 detail을 열 수 있고, 읽음 요청은 멱등하게 재시도할 수 있다.

메시지 순서와 전달을 신뢰하지 않기

FCM은 application event log가 아니다. 공식 문서도 message 전달 순서를 보장하지 않으며 collapsible message는 이전 message를 새 message로 대체할 수 있다고 설명한다.

알림 A: entry count 2
알림 B: entry count 3

기기 수신 순서: B → A 가능
collapse 사용: A가 전달되지 않을 수 있음

앱 상태를 payload count로 덮어쓰지 않고 서버에서 최신 상태를 동기화한다.

Future<void> onSyncHint(RemoteMessage message) async {
  syncScheduler.requestRun(
    reason: SyncWakeReason.pushHint,
  );
}

각 event가 반드시 처리돼야 한다면 FCM payload 자체가 아니라 서버의 durable event feed나 현재 resource API를 source of truth로 사용한다. push는 “새 데이터가 있을 수 있다”는 wake-up hint다.

FCM action과 universal link가 서로 다른 navigation 코드를 사용하면 동일 화면이 다른 인증·중복 정책을 갖게 된다.

FCM payload
ENTRY_DETAIL_OPEN + resourceId
          ↓
AppDestination.entry(id)
          ↑
Universal link
https://app.example.invalid/entries/id

각 source는 자기 입력을 검증된 AppDestination으로 변환하고 이후 resolver와 router를 공유한다.

Future<void> openDestination(
  AppDestination destination, {
  required NavigationSource source,
}) {
  return destinationCoordinator.open(
    destination,
    source: source,
  );
}

deep link와 universal link의 플랫폼 설정은 딥링크와 유니버설 링크의 차이에서 이어서 다룬다.

Background Handler의 책임 제한하기

background message handler는 UI context가 없고 별도 isolate나 제한된 실행 환경일 수 있다. 직접 navigation하지 않는다.

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(
  RemoteMessage message,
) async {
  await Firebase.initializeApp();

  if (message.data['action'] == 'SYNC_HINT') {
    await backgroundSyncHints.record(
      messageId: message.messageId,
      receivedAt: DateTime.now(),
    );
  }
}

background handler에서 큰 DB migration, 무제한 network loop, UI plugin 호출을 수행하지 않는다. 실행 시간과 platform 제한을 고려해 작은 durable hint만 남기고 app resume의 scheduler가 처리하게 할 수 있다.

알림 tap interaction과 background data 수신을 같은 것으로 취급하지 않는다.

테스트해야 할 앱 상태 행렬

시작 상태 입력 기대 결과
Terminated notification tap initial intent 보관 후 한 번 이동
Background notification tap resume 뒤 이동
Foreground message receive 정책에 따른 banner, 자동 이동 금지
Foreground in-app banner tap 같은 coordinator로 이동
로그인 복원 중 tap auth ready까지 대기
비로그인 tap login 뒤 intent 재개
다른 계정 login tap server 권한 재검증
같은 알림 두 번 tap 중복 callback route 한 번
이미 같은 detail tap 중복 push 대신 refresh
삭제된 resource tap inbox + 안내
알 수 없는 schema tap 안전한 fallback
강제 업데이트 필요 tap update 완료 전 이동 금지

실제 기기에서는 notification permission, Android notification channel, iOS APNs 설정까지 포함한다. cold start test는 debugger 연결 여부에 따라 timing이 달라질 수 있어 release/profile build에서도 확인한다.

관측 가능한 알림 이동 만들기

전체 payload를 로그에 남기지 않고 단계별 결과를 기록한다.

notification_interaction source=terminated action=ENTRY_DETAIL_OPEN
notification_intent_wait reason=auth_restoring
notification_resolve result=success destination=entry-detail
notification_navigation result=deduplicated

유용한 필드:

제외할 필드:

initial message가 있었는데 navigation log가 없다면 parser, readiness, auth, resolver 중 어느 단계에서 끝났는지 찾을 수 있어야 한다.

구현 체크리스트

Payload

진입점

Router와 상태

검증과 운영

마무리

FCM 알림 탭은 곧바로 화면을 push하라는 명령이 아니다. 사용자가 특정 정보에 관심을 표현하며 앱으로 들어온 navigation intent다. terminated와 background에서는 수집 API가 다르고, foreground message 수신은 tap과도 다르다.

각 진입점을 하나의 NotificationIntent로 정규화하고 앱 초기화와 인증이 준비될 때까지 보관한다. 단일 coordinator가 현재 계정으로 resource를 다시 조회한 뒤 선언형 router의 destination을 바꾼다. 중복 notification ID와 이미 열린 route는 idempotent하게 처리한다.

Push payload의 순서, 전달, 권한 정보는 source of truth가 아니다. 최소 식별자만 전달하고 최신 상태와 권한은 서버에서 확인한다. 이 구조를 사용하면 앱이 어느 상태에서 시작해도 같은 navigation 정책과 실패 처리를 거쳐 예측 가능한 화면에 도착한다.

관련 노트

참고 자료