Android FCM 알림 샘플앱 개발가이드
2023.08.03 작성완료
안드로이드에서 알림(notification)을 받는 과정 구현 기록.
FCM 소개
이미지 출처 및 Firebase 공식 문서 - FCM 아키텍쳐 개요
: https://firebase.google.com/docs/cloud-messaging/fcm-architecture?hl=ko
FCM(Firebase Cloud Messaging)은 무료로 메세지를 안정적으로 보낼 수 있는 교차 플랫폼 메시징 솔루션이다.
(구글의 메세지 PUSH 메시지 기능으로 GCM(Google Cloud Messaging), FCM(Firebase Cloud Messaging)이 있다. Android와 IOS Mobile만 지원하던 GCM과 달리 FCM은 Mobile Web까지 확장되어 지원한다. 또한 GCM 서비스가 2019년 종료됨에 따라 FCM이 사실상 대표적인 메세지 PUSH 서비스가 되었다.)
FCM 메세지 정보
FCM은 2가지의 메시지 유형이 있다.
- 알림 메시지 : 종종 “표시 메시지”로 간주된다. FCM SDK에서 자동으로 처리된다.
- 데이터 메시지 : 클라이언트 앱에서 처리한다.
알림 메시지인 경우, Admin SDK 또는 FCM 프로토콜을 사용하여 알림 메시지를 보낸다.
{ "message":{ "token":"bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...", "notification":{ "title":"Portugal vs. Denmark", "body":"great match!" } } } |
다음 형식과 같이 보냈을 때
- 앱이 백그라운드 상태이면 알림 메시지가 알림 목록으로 전송된다.
- 앱이 포그라운드 상태이면 콜백 함수가 메시지를 처리한다.
알림에 대한 사용자 반응 분석
FCM은 메시지 전송에 관한 통계를 확인할 수 있는 3가지 도구를 제공한다.
- Firebase Console 메시지 전송 보고서 및 알림 유입경로 분석
- Firebase Cloud Messaging Data API에서 집계된 Android SDK 전송 측정항목
- Google BigQuery로 종합적인 데이터 내보내기
(자세한 내용은 Firebase 공식 문서를 참고)
https://firebase.google.com/docs/cloud-messaging/understand-delivery?hl=ko&platform=ios
등록 토큰 관리
Push 알림을 보내기 위해서는 서버에서 사용자의 FCM 토큰을 잘 관리해야 한다.
따라서 FCM API를 사용하는 모든 앱에서 따라야 하는 몇 가지 기본 규칙이 있다.
- 서버에 등록 토큰을 저장한다. 서버의 중요한 역할은 각 클라이언트의 토큰을 추적하고 활성 토큰의 업데이트된 목록을 유지하는 것이다. 코드와 서버에 토큰 타임스탬프를 구현하고 이 타임스탬프를 정기적으로 업데이트해야한다.
- 오래된 토큰을 제거한다. 잘못된 토큰 응답의 명백한 경우에 토큰을 제거하는 것 외에도 토큰이 오래되었다는 다른 징후를 모니터링해야 할 수도 있다.
- FCM 등록 토큰 검색 및 저장
앱을 처음 시작할 때 FCM SDK는 클라이언트 앱 인스턴스에 대한 등록 토큰을 생성한다. 앱은 초기 시작 시 이 토큰을 검색하고 타임스탬프와 함께 앱 서버에 저장해야 한다.
또한 다음과 같이 토큰이 변경될 때마다 토큰을 서버에 저장하고 타임스탬프를 업데이트하는 것이 중요하다.
- 앱이 새 기기에서 복원된다.
- 사용자가 앱을 제거/재설치한다.
- 사용자가 앱 데이터를 지운다.
위의 경우처럼 토큰이 변경됐다면 기존 토큰은 만료되어 버린다. 만료된 토큰으로 알림을 보내려 한다면, FCM 백엔드에서는 잘못된 토큰에 대한 응답을 감지하고 토큰이 잘못되었다는 것을 응답으로 전달한다.
- FCM 백엔드에서 잘못된 토큰 응답 감지
FCM에서 잘못된 토큰 응답을 감지하면, 해당 토큰을 시스템에서 삭제하여 응답해야 한다. HTTP v1 API를 사용하는 경우 이러한 오류 메시지는 전송 유형이 유효하지 않은 토큰을 대상으로 했음을 나타낼 수 있다.
- UNREGISTERED (HTTP 404)
- INVALID_ARGUMENT (HTTP 400)
오류 코드에 대한 자세한 내용은 Firebase 공식 문서를 참고.
https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode?hl=ko
- 정기적으로 토큰 업데이트
토큰이 최신값인지 확인하는 것은 간단하지 않다. 따라서 토큰이 부실해질 수 있다고 생각되는 기간을 채택하는 것이 좋다.
Firebase에서 권장하는 기간은 두달이다. 만약 2개월이 지나면 해당 토큰은 비활성 장치의 토큰일 가능성이 높다. 그렇지 않으면 활성 장치가 토큰을 새로 고쳤을 것이다.
서버의 모든 등록 토큰을 주기적으로 검색하고 업데이트하는 것이 좋다. 이를 위해서는 다음과 같은 과정이 필요하다.
- 클라이언트 앱에 앱 로직을 추가하여 적절한 API 호출
- 토큰 변경 여부에 관계없이 정기적으로 토큰의 타임스탬프를 업데이트 서버 로직을 추가한다.
위와 같은 방식으로 토큰을 주기적으로 업데이트하면 효율적인 토큰 관리가 가능하다.
- Topic에서 오래된 토큰 구독 취소
오래된 FCM 토큰을 제거하기 위해 topic 관리를 하는것도 좋다. 여기에는 두 단계가 포함됩니다.
- 앱은 한 달에 한 번 또는 등록 토큰이 변경될 때마다 Topic을 다시 구독해야 한다.
- 앱 인스턴스가 2개월(또는 자체 비활성 기간) 동안 유휴 상태인 경우 Firebase Admin SDK를 사용하여 topic에서 구독을 취소하여 FCM 백엔드에서 토큰/topic 매핑을 삭제해야 한다.
FCM 활용하기
먼저 Firebase에 안드로이드 애플리케이션을 등록해야 한다.
https://minchanyoun.tistory.com/99
다음 링크를 보고 따라하면 된다.
안드로이드 스튜디오에서 Tools -> Firebase를 클릭해 Firebase 기능 목록을 연다.
그 중 Cloud Messaging을 찾아 Set up Firebase Messaging을 누르면
다음과 같이 연동 과정이 뜬다.
1, 2번까지 해주고 3번부터는 코드를 삽입하여 설정하는 과정이다.
FCM 구현을 위한 코드
작업한 파일은 다음과 같다.
- AndroidManifest.xml
- MainActivity.kt
- MyFirebaseMessagingService.kt
- activity_main.xml
- view_custom_notification.xml (뷰 커스텀, 생략 가능)
- build.gradle(Project 수준)
- build.gradle(app 수준)
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <uses-permission android:name="android.permission.INTERNET"/> <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.FCMtestApp" tools:targetApi="31"> <!-- [START fcm_default_channel] --> <meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="@string/default_notification_channel_id" /> <!-- [END fcm_default_channel] --> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <service android:exported="false" android:name=".MyFirebaseMessagingService"> <intent-filter> <action android:name="com.google.firebase.MESSAGING_EVENT" /> </intent-filter> </service> </application> </manifest> |
MainActivity.kt
package com.eeeun.fcmtestapp import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.util.Log import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import com.google.firebase.messaging.FirebaseMessaging import android.Manifest class MainActivity : AppCompatActivity() { companion object { const val TAG = "MainActivity" } private val tvToken: TextView by lazy { findViewById(R.id.tv_token) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) initFirebase() askNotificationPermission() } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) setIntent(intent) } private fun initFirebase() { FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> if (task.isSuccessful) { tvToken.text = task.result Log.d(TAG, "initFirebase: FCM Token is ${task.result.toString()}") } } } // This is only necessary for API level >= 33 (TIRAMISU) private fun askNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ) { // FCM SDK (and your app) can post notifications. } else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { // TODO: display an educational UI explaining to the user the features that will be enabled // by them granting the POST_NOTIFICATION permission. This UI should provide the user // "OK" and "No thanks" buttons. If the user selects "OK," directly request the permission. // If the user selects "No thanks," allow the user to continue without notifications. } else { // Directly ask for the permission requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } } // Declare the launcher at the top of your Activity/Fragment: private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { isGranted: Boolean -> if (isGranted) { // FCM SDK (and your app) can post notifications. } else { // TODO: Inform user that that your app will not show notifications. } } } |
MyFirebaseMessagingService.kt
package com.eeeun.fcmtestapp import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage class MyFirebaseMessagingService : FirebaseMessagingService() { companion object { const val TAG = "MessagingService" private const val CHANNEL_NAME = "Push Notification" private const val CHANNEL_DESCRIPTION = "Push Notification 을 위한 채널" private const val CHANNEL_ID = "Channel Id" } /* 토큰 생성 메서드 */ override fun onNewToken(token: String) { super.onNewToken(token) } /* 메세지 수신 메서드 */ override fun onMessageReceived(remoteMessage: RemoteMessage) { super.onMessageReceived(remoteMessage) val title = remoteMessage.notification!!.title val message = remoteMessage.notification!!.body Log.d(TAG, "onMessageReceived() - remoteMessage : $remoteMessage") Log.d(TAG, "onMessageReceived() - from : ${remoteMessage.from}") Log.d(TAG, "onMessageReceived() - notification : ${remoteMessage.notification?.body}") Log.d(TAG, "onMessageReceived() - title : $title") Log.d(TAG, "onMessageReceived() - message : $message") sendNotification(title, message) } /* 알림 생성 메서드 */ private fun sendNotification( title: String?, message: String? ) { val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager //Oreo(26) 이상 버전에는 channel 필요 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT ) channel.description = CHANNEL_DESCRIPTION notificationManager.createNotificationChannel(channel) } //알림 생성을 위한 권한(POST_NOTIFICATIONS)이 있는지 확인, 없으면 return if (ActivityCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { // TODO: Consider calling // ActivityCompat#requestPermissions // here to request the missing permissions, and then overriding // public void onRequestPermissionsResult(int requestCode, String[] permissions, // int[] grantResults) // to handle the case where the user grants the permission. See the documentation // for ActivityCompat#requestPermissions for more details. return } NotificationManagerCompat.from(this) .notify((System.currentTimeMillis()/100).toInt(), createNotification(title, message)) } /* 알림 설정 메서드 */ private fun createNotification( title: String?, message: String? ): Notification { val id = 0 val intent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } val pendingIntent = PendingIntent.getActivity( this, id, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_background) .setContentTitle(title) .setContentText(message) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setContentIntent(pendingIntent) //알림 눌렀을 때 실행할 Intent 설정 .setAutoCancel(true) //클릭 시 자동으로 삭제되도록 설정 return notificationBuilder.build() } } |
activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="20dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="Firebase Token" android:textSize="20sp" android:textStyle="bold" /> <TextView android:id="@+id/tv_token" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginHorizontal="30dp" android:layout_marginTop="15dp" android:text="Loading..." android:textIsSelectable="true" android:textSize="16sp" /> </LinearLayout> |
view_custom_notification.xml
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:orientation="vertical"> <TextView android:id="@+id/tv_custom_title" style="@style/TextAppearance.Compat.Notification.Title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Title" android:textSize="25sp" /> <TextView android:id="@+id/tv_custom_message" style="@style/TextAppearance.Compat.Notification" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="Message" android:textSize="20sp" /> </LinearLayout> |
build.gradle (project 수준)
buildscript { dependencies { classpath 'com.google.gms:google-services:4.3.15' } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.0.2' apply false id 'com.android.library' version '8.0.2' apply false id 'org.jetbrains.kotlin.android' version '1.8.20' apply false // Add the dependency for the Google services Gradle plugin id("com.google.gms.google-services") version "4.3.15" apply false } |
build.gradle (app 수준)
plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' // Add the Google services Gradle plugin id("com.google.gms.google-services") } android { namespace 'com.eeeun.fcmtestapp' compileSdk 33 defaultConfig { applicationId "com.eeeun.fcmtestapp" minSdk 24 targetSdk 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } } dependencies { implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.9.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.google.firebase:firebase-messaging:23.2.0' implementation 'com.google.firebase:firebase-bom:32.2.0' implementation 'com.google.firebase:firebase-core:21.1.1' implementation 'com.google.firebase:firebase-messaging-ktx' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' // Import the Firebase BoM implementation(platform("com.google.firebase:firebase-bom:32.2.0")) // TODO: Add the dependencies for Firebase products you want to use // When using the BoM, don't specify versions in Firebase dependencies implementation("com.google.firebase:firebase-analytics-ktx") // Add the dependencies for any other desired Firebase products // https://firebase.google.com/docs/android/setup#available-libraries } apply plugin: 'com.google.gms.google-services' |
Firebase에서 테스트 메세지 보내기
Firebase에서 Messaging -> 새 캠페인 만들기 -> 알림을 선택하면
다음과 같은 화면이 뜬다. 제목과 내용을 입력하고 “테스트 메시지 전송”을 누르면
다음과 같이 기기 토큰을 선택하고 “테스트” 버튼을 누름으로써 알림 전송 테스트가 가능하다.
이때 기기 토큰은
FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> if (task.isSuccessful) { tvToken.text = task.result Log.d(TAG, "##-----------initFirebase: FCM Token is ${task.result.toString()}") } } |
해당 코드를 통해 알 수 있다.
(로그로 FCM 토큰을 찍어 활용한 케이스)
테스트 결과 앱 포그라운드, 백그라운드 둘 다 알림이 전송되는 것을 확인하였다.
삽질기
안드로이드 13부터는 알림 표시를 위한 런타임 권한 요청을 반드시! 설정해주어야 한다.
해당 코드를 활용하면 된다. 없으면 절대 알림이 가지 않으니 주의…
// Declare the launcher at the top of your Activity/Fragment: private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { isGranted: Boolean -> if (isGranted) { // FCM SDK (and your app) can post notifications. } else { // TODO: Inform user that that your app will not show notifications. } } // This is only necessary for API level >= 33 (TIRAMISU) private fun askNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ) { // FCM SDK (and your app) can post notifications. } else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { // TODO: display an educational UI explaining to the user the features that will be enabled // by them granting the POST_NOTIFICATION permission. This UI should provide the user // "OK" and "No thanks" buttons. If the user selects "OK," directly request the permission. // If the user selects "No thanks," allow the user to continue without notifications. } else { // Directly ask for the permission requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } } |
https://firebase.google.com/docs/cloud-messaging/android/client?hl=ko
공식 문서 참고.
해당 글 작성 시 참고글
FCM 아키텍처 개요 | Firebase 클라우드 메시징
Android 앱에서 메시지 수신 | Firebase 클라우드 메시징
[Project] 프로젝트 삽질기1 (feat FCM 공식문서)
[Android/Kotlin] FCM 푸시 Push 알림 구현하기
https://firebase.google.com/docs/cloud-messaging/manage-tokens?hl=ko
https://firebase.google.com/docs/cloud-messaging/android/client?hl=ko