FCM 푸시 알림 연동기 (1) – 앱(Frontend) 설정 및 토큰 발급

이번 프로젝트에서 유사 채팅 기능을 구현하면서 푸시 알림(FCM)은 피할 수 없는 산이었다. 보통 개발 블로그들을 보면 프론트와 백엔드가 섞여 있어서 막상 적용하려니 헷갈리는 경우가 많았다. 그래서 이번 구현 과정은 앱(Frontend)과 서버(Backend) 설정으로 나누어 정리해두려 한다.

오늘은 그 첫 번째, 플러터 앱에서의 설정과 토큰 관리 이야기다.

1. 상황

채팅 앱인데 알림이 안 오면 시체다. 단순히 “구글링해서 나온 코드 복붙”으로 끝날 줄 알았는데, 생각보다 챙겨야 할 디테일이 많았다. 특히 앱이 꺼져있을 때(Terminated)켜져있지만 백그라운드일 때, 그리고 iOS에서의 토큰 발급 타이밍 문제 등 신경 쓸게 한두 가지가 아니었다.

2. 기본 설정 (pubspec.yaml)

일단 총부터 챙겨야지. firebase_messaging을 사용했다.

dependencies:
  firebase_core: ^4.3.0
  firebase_messaging: ^16.1.0

버전을 명시하는 습관이 있는데, 메이저 업데이트가 잦은 패키지라 호환성 때문에 고정해두는 편이다.

3. 앱 진입점 잡기 (main.dart)

가장 중요했던 건 백그라운드 핸들러 설정이다. 앱이 꺼져있거나 백그라운드 상태일 때 메시지를 받으려면, main() 함수 실행 전에 핸들러가 등록되어야 한다.

여기서 헤맸던 점 (삽질 포인트)

처음에 무심코 클래스 내부 함수로 핸들러를 만들었다가 알림이 안 와서 한참 찾았다. 알고 보니 백그라운드 핸들러는 반드시 최상위 레벨(Top-level) 함수여야 하고, @pragma('vm:entry-point') 어노테이션을 붙여줘야 VM이 백그라운드에서 이 함수를 찾아서 실행할 수 있다.

// main.dart

// 1. 이게 없으면 백그라운드 수신 불가 (최상위 함수여야 함)
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 백그라운드에선 Firebase도 별도로 초기화 필요
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  Get.log("Background message: ${message.messageId}");
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // ... 기타 초기화 ...

  // Firebase 초기화
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  
  // 2. 핸들러 등록
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  
  // 3. 권한 요청 (Android 13+, iOS 필수)
  final messaging = FirebaseMessaging.instance;
  await messaging.requestPermission(
    alert: true,
    badge: true,
    sound: true,
  );
  
  runApp(const MyApp());
}

4. 토큰 관리의 핵심 (GlobalService.dart)

설정이 끝났으면 서버가 “내 폰”을 식별할 수 있도록 주민등록증(FCM 토큰)을 발급받아 서버에 알려줘야 한다. 이 로직은 앱 전역에서 쓰는 GlobalService에 몰아넣었다.

iOS APNS의 설정 (Race Condition)

안드로이드는 그냥 getToken() 하면 바로 나오는데, iOS가 문제였다. iOS는 FCM 토큰을 얻기 전에 APNS 토큰이 먼저 생성되어야 한다. 근데 이 APNS 토큰 생성 속도가 네트워크 환경이나 기기 상태에 따라 미묘하게 느릴 때가 있다. APNS 토큰이 없는 상태에서 getToken()을 호출하면 null이 떨어지거나 에러가 난다.

그래서 좀 투박하지만, 확실한 방법(Polling)을 썼다.

// GlobalService.dart

Future<void> registerFcmToken() async {
  try {
    // 권한 확실하게 다시 체크
    NotificationSettings settings = await FirebaseMessaging.instance.requestPermission(...);

    // [iOS 이슈] APNS 토큰이 준비될 때까지 기다려준다.
    if (GetPlatform.isIOS) {
       // 실제 코드엔 여기에 1~2초 간격으로 loop 돌며 apnsToken 확인하는 로직 추가함
       // 이게 없으면 FCM 토큰 발급 실패 확률이 꽤 높음
    }

    final token = await FirebaseMessaging.instance.getToken();
    
    if (token != null) {
      Get.log('FCM Token get success');
      // [중요] 토큰을 내 계정 정보와 매핑하기 위해 서버로 전송
      await HomeApi.to.reqUpdateFcmToken(token);
    }
  } catch (e) {
    Get.log('Token Register Error: $e', isError: true);
  }
}

5. 알림 받았을 때 & 클릭했을 때 처리

알림은 크게 3가지 상황으로 나뉜다.

  1. Foreground: 앱 켜놓고 보고 있을 때
  2. Background: 앱 켜져있는데 홈 화면이거나 다른 앱 보고 있을 때
  3. Terminated: 앱 아예 종료 상태

포그라운드 처리 (onMessage)

앱을 보고 있을 때 굳이 상단 배너까지 띄울 필요가 있을까? 해서 지금은 로직만 넣어두고 배너 UI는 주석 처리해뒀다. 특히 “현재 보고 있는 채팅방” 알림은 안 띄우는 게 UX상 맞다고 판단했다.

// onInit 내부
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    // 1. 현재 채팅방에 있는지 확인
    bool isCurrentChatRoom = false; // 실제 로직은 현재 라우트와 파라미터 체크
    
    // 2. 다른 방 알림이면 스낵바 띄우기 (현재는 시끄러울까봐 주석 처리)
    if (!isCurrentChatRoom && message.notification != null) {
        // Get.snackbar(...) 
    }
});

클릭 후 이동 (Interacted Message)

사용자가 알림을 ‘톡’ 눌렀을 때 해당 채팅방으로 이동시켜주는 부분이다. setupInteractedMessage() 함수를 만들어 초기화 시점에 호출한다.

Future<void> setupInteractedMessage() async {
  // 1. 앱이 완전히 꺼져있다가 알림 눌러서 켜진 경우
  RemoteMessage? initialMessage = await FirebaseMessaging.instance.getInitialMessage();
  if (initialMessage != null) _handleMessage(initialMessage);

  // 2. 백그라운드에 있다가 알림 눌러서 켜진 경우
  FirebaseMessaging.onMessageOpenedApp.listen(_handleMessage);
}

void _handleMessage(RemoteMessage message) {
  // payload(data)에 심어둔 타입과 방 번호를 꺼내서 이동
  if (message.data['type'] == 'CHAT') {
    Get.toNamed(Routes.CHAT_ROOM, arguments: {
      'roomId': message.data['roomId']
    });
  }
}

정리

프론트엔드 작업은 “권한 받고 -> 토큰 따서 -> 서버에 주고 -> 리스너 등록” 이 과정을 순서대로 꼬이지 않게 배치하는 게 핵심이었다. 특히 iOS APNS 토큰 대기 로직은 잊을만하면 터지는 이슈라 꼭 방어 코드를 넣어두는 것을 추천한다.

다음 글에서는 이렇게 받은 토큰을 가지고 Spring Boot 서버에서 실제로 알림을 쏘는 과정을 정리해보겠다.

댓글 남기기