이번 프로젝트에서 유사 채팅 기능을 구현하면서 푸시 알림(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가지 상황으로 나뉜다.
- Foreground: 앱 켜놓고 보고 있을 때
- Background: 앱 켜져있는데 홈 화면이거나 다른 앱 보고 있을 때
- 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 서버에서 실제로 알림을 쏘는 과정을 정리해보겠다.