안드로이드 앱 개발에서 푸시 알림은 사용자의 재방문과 참여를 유도하는 핵심 기능입니다. Firebase Cloud Messaging(FCM)은 구글이 제공하는 강력하고 편리한 푸시 알림 솔루션이지만, 그 내부 동작 방식의 복잡성 때문에 많은 개발자가 어려움을 겪습니다. 특히 앱이 사용자에 의해 화면에 보이는 포그라운드(Foreground) 상태, 홈 버튼을 눌러 잠시 내려둔 백그라운드(Background) 상태, 그리고 최근 앱 목록에서 스와이프하여 완전히 종료한 종료(Terminated) 상태에 따라 FCM 메시지 수신 방식이 달라지는 점은 혼란을 가중시키는 주범입니다. 이 글에서는 이러한 상태별 FCM 동작 원리를 명확하게 파헤치고, 어떤 상황에서도 푸시 알림을 놓치지 않고 안정적으로 처리할 수 있는 가장 확실하고 실용적인 방법을 제시합니다.
FCM의 두 얼굴: Notification 메시지와 Data 메시지
FCM 문제를 해결하는 첫걸음은 FCM이 보내는 메시지에 두 가지 주요 유형, 즉 'Notification 메시지'와 'Data 메시지'가 있다는 사실을 이해하는 것입니다. 이 둘의 차이점을 아는 것이 모든 문제 해결의 열쇠입니다. 서버에서 FCM으로 메시지를 보낼 때, 페이로드(payload)의 구성에 따라 메시지 유형이 결정됩니다.
- Notification 메시지:
notification
이라는 정해진 키(key)를 사용하는 메시지입니다.title
,body
,sound
,icon
등 사용자에게 보여줄 알림의 UI 요소를 직접 지정합니다. 이 메시지의 가장 큰 특징은 '간편함'과 '자동 처리'입니다. - Data 메시지:
data
라는 정해진 키를 사용하는 메시지입니다. 이 키 안에는 개발자가 자유롭게 정의한 키-값(key-value) 쌍을 담을 수 있습니다. 이 메시지의 특징은 '유연성'과 '앱 제어'에 있습니다.
문제는 이 두 가지 메시지 유형이 앱의 상태(포그라운드, 백그라운드, 종료)에 따라 완전히 다르게 동작한다는 점입니다. 이로 인해 개발자는 딜레마에 빠지게 됩니다.
앱 상태별 동작 방식 비교: 왜 둘 중 하나만으론 부족한가?
아래 표는 각 메시지 타입이 앱의 세 가지 상태에서 어떻게 처리되는지를 명확하게 보여줍니다. 이 표를 보면 왜 하나의 타입만으로는 모든 시나리오를 만족시키기 어려운지 바로 알 수 있습니다.
앱 상태 | Notification 메시지 수신 시 | Data 메시지 수신 시 |
---|---|---|
포그라운드 (Foreground) 앱이 화면에 활성화된 상태 |
✅ onMessageReceived() 호출됨.개발자가 직접 알림을 생성해야 함. (시스템이 자동으로 보여주지 않음) |
✅ onMessageReceived() 호출됨.개발자가 수신한 데이터를 바탕으로 자유롭게 처리 가능. |
백그라운드 (Background) 앱이 보이지 않지만 프로세스는 살아있는 상태 |
❌ onMessageReceived() 호출되지 않음.시스템 트레이(상단 알림 바)가 자동으로 알림을 생성하여 표시함. |
✅ onMessageReceived() 호출됨.(단, 기기의 Doze 모드나 앱 대기 모드 등 배터리 최적화 정책의 영향을 받을 수 있음) |
종료 (Terminated) 사용자가 앱을 완전히 종료한 상태 |
❌ onMessageReceived() 호출되지 않음.시스템 트레이가 자동으로 알림을 생성하여 표시함. 사용자가 알림을 탭하면 앱이 새로 시작됨. |
❌ onMessageReceived() 호출되지 않음.메시지가 유실될 가능성이 매우 높음. 사실상 수신을 보장할 수 없음. |
이 표가 말해주는 핵심 딜레마는 다음과 같습니다.
- 앱이 꺼져있을 때도 알림을 확실히 받게 하려면
Notification
메시지를 써야 합니다. 하지만 이 경우, 앱이 백그라운드에 있을 때onMessageReceived()
가 호출되지 않아 앱 내부적으로 데이터를 처리하거나 커스텀 알림을 만드는 것이 불가능합니다. - 모든 상황에서 앱이 메시지 데이터를 제어하고 싶다면
Data
메시지를 써야 합니다. 하지만 이 경우, 사용자가 앱을 완전히 종료(Terminated)하면 메시지 수신 자체를 보장할 수 없게 됩니다. 이는 치명적인 단점입니다.
결국, 어떤 한 가지 방법만으로는 모든 요구사항을 충족시킬 수 없습니다. 그렇다면 해결책은 무엇일까요?
완벽한 해결책: Notification과 Data를 혼합한 '하이브리드 메시지'
정답은 두 가지 메시지의 장점만을 결합하는 것입니다. 즉, FCM 페이로드에 notification
객체와 data
객체를 모두 포함하여 보내는 것입니다. 이것이 바로 '하이브리드 메시지' 전략이며, 현재 안드로이드 FCM을 다루는 가장 표준적이고 강력한 방법입니다.

이미지: Notification 페이로드와 Data 페이로드가 함께 구성된 FCM 메시지
이렇게 두 속성을 함께 보내면, FCM은 앱의 상태에 따라 다음과 같이 영리하게 동작합니다.
- 앱이 포그라운드(Foreground) 상태일 때:
Data
메시지를 보냈을 때와 동일하게 동작합니다. 즉,onMessageReceived()
메소드가 항상 호출됩니다.RemoteMessage
객체에는getNotification()
과getData()
메소드를 통해 양쪽의 데이터를 모두 담고 있습니다. 이 상태에서는 시스템이 자동으로 알림을 띄우지 않으므로, 개발자는onMessageReceived()
내에서 수신한 데이터를 기반으로 사용자에게 알림을 직접 생성하여 보여줄지, 아니면 다른 로직을 수행할지 온전히 결정할 수 있습니다. - 앱이 백그라운드(Background) 또는 종료(Terminated) 상태일 때:
Notification
메시지를 보냈을 때와 동일하게 동작합니다.onMessageReceived()
는 호출되지 않지만, 안드로이드 시스템이 페이로드의notification
부분을 해석하여 시스템 트레이에 자동으로 알림을 표시해줍니다. 이것이 핵심입니다! 앱이 꺼져 있어도 알림 자체는 보장됩니다.
그리고 가장 중요한 부분은 사용자가 이 알림을 탭했을 때입니다. 사용자가 알림을 탭하여 앱을 실행하면, 이 메시지에 함께 담겨 있던data
페이로드의 내용이 앱을 시작시키는Intent
의 엑스트라(Extras)에 담겨서 전달됩니다.
결과적으로 이 하이브리드 방식을 사용하면, 어떤 앱 상태에서든 사용자는 시각적인 알림을 놓치지 않고 받을 수 있으며, 알림을 통해 앱에 진입했을 때 개발자는 data
페이로드에 담긴 추가 정보를 활용하여 특정 페이지로 이동시키거나(딥링킹), 특정 데이터를 보여주는 등 정교한 후속 조치를 구현할 수 있게 됩니다.
단계별 실전 구현 가이드
이제 이론을 알았으니, 실제 코드로 이 하이브리드 전략을 구현하는 방법을 단계별로 알아보겠습니다.
1단계: 서버에서 하이브리드 FCM 메시지 발송하기
먼저, 앱으로 푸시를 보내는 서버 측에서 페이로드 구성을 올바르게 해야 합니다. 다음은 curl
을 이용한 전형적인 하이브리드 메시지 요청 예시입니다.
curl -X POST --header "Authorization: key=YOUR_SERVER_KEY" \
--header "Content-Type: application/json" \
https://fcm.googleapis.com/fcm/send \
-d '{
"to": "YOUR_DEVICE_FCM_TOKEN",
"priority": "high",
"notification": {
"title": "새로운 게시글 알림",
"body": "당신이 관심 있는 주제에 새로운 글이 올라왔어요!",
"sound": "default",
"channel_id": "new_posts_channel"
},
"data": {
"type": "new_post",
"post_id": "12345",
"author": "dev_master"
}
}'
위 JSON 페이로드를 자세히 살펴봅시다.
to
: 메시지를 받을 대상 기기의 FCM 토큰입니다.priority
: "high"로 설정하면 안드로이드 시스템이 메시지를 즉시 전달하도록 시도합니다. 특히 백그라운드 상태의 앱을 깨워야 할 때 중요합니다. (Doze 모드 등에서 우선순위를 높여줍니다)notification
객체: 사용자에게 직접 보여질 부분입니다.title
과body
는 필수적이며,sound
나 안드로이드 8.0 이상을 위한channel_id
등을 지정할 수 있습니다. 이 부분이 있기 때문에 앱이 꺼져있어도 시스템이 알림을 생성할 수 있습니다.data
객체: 앱이 내부적으로 사용할 데이터입니다. 여기에는 화면 이동에 필요한 게시글 ID(post_id
)나 알림 유형(type
) 등 비즈니스 로직에 필요한 정보를 자유롭게 담을 수 있습니다.
2단계: 안드로이드 `FirebaseMessagingService` 설정하기
안드로이드 프로젝트에서 FCM 메시지를 수신하려면 `FirebaseMessagingService`를 상속받는 서비스 클래스가 필요합니다. 이 클래스의 `onMessageReceived` 메소드에서 포그라운드 상태의 메시지를 처리합니다.
`MyFirebaseMessagingService.kt`
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
class MyFirebaseMessagingService : FirebaseMessagingService() {
private val TAG = "FCM_Service"
/**
* onMessageReceived()는 다음 두 가지 경우에 호출됩니다.
* 1. 앱이 포그라운드 상태일 때: Data 메시지, Notification 메시지 모두 여기서 처리됩니다.
* 2. 앱이 백그라운드 상태일 때: "Data" 메시지만 여기서 처리됩니다.
*
* 하이브리드 메시지(notification + data)는 포그라운드에서는 이 메소드를 호출하지만,
* 백그라운드에서는 호출하지 않고 시스템 트레이가 알림을 직접 처리합니다.
*/
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
// 메시지에 데이터 페이로드가 포함되어 있는지 확인합니다.
// data 페이로드는 백그라운드에서도 일부 처리 로직을 위해 사용될 수 있습니다.
if (remoteMessage.data.isNotEmpty()) {
Log.d(TAG, "Data Payload: " + remoteMessage.data)
// 여기서 데이터에 따른 특정 백그라운드 작업을 수행할 수 있습니다.
// (예: 데이터 동기화)
}
// 메시지에 알림 페이로드가 포함되어 있는지 확인합니다.
// 포그라운드 상태에서만 이 부분을 통해 알림을 생성합니다.
// 백그라운드에서는 시스템이 자동으로 처리하므로 이 코드가 실행되지 않습니다.
remoteMessage.notification?.let {
Log.d(TAG, "Notification Message Body: ${it.body}")
sendNotification(it.title, it.body, remoteMessage.data)
}
}
/**
* 디바이스 토큰이 갱신될 때마다 호출됩니다.
* 새로운 토큰을 서버로 전송하는 로직을 구현해야 합니다.
*/
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "Refreshed token: $token")
// sendRegistrationToServer(token)
}
/**
* 포그라운드 상태에서 수신한 Notification 메시지를 이용해
* 사용자에게 보여줄 실제 알림을 생성합니다.
*/
private fun sendNotification(title: String?, body: String?, data: Map<String, String>) {
val intent = Intent(this, MainActivity::class.java).apply {
// 알림을 통해 액티비티를 시작할 때, data 페이로드의 정보를 함께 전달합니다.
// 이를 통해 MainActivity에서 어떤 푸시를 통해 앱이 실행되었는지 알 수 있습니다.
data.forEach { (key, value) ->
putExtra(key, value)
}
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
// PendingIntent는 다른 애플리케이션(여기서는 안드로이드 시스템)이 내 앱의 인텐트를 실행할 권한을 줍니다.
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE)
val channelId = "new_posts_channel" // 서버에서 보낸 channel_id와 일치시켜야 합니다.
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notification) // 알림 아이콘
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true) // 사용자가 알림을 탭하면 자동으로 사라지게 함
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent) // 알림을 탭했을 때 실행될 인텐트
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// 안드로이드 8.0 (오레오) 이상에서는 Notification Channel을 생성해야 합니다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(channelId,
"새 게시글 알림",
NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(0, notificationBuilder.build())
}
}
그리고 `AndroidManifest.xml`에 이 서비스를 등록하는 것을 잊지 마세요.
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<application ...>
<service
android:name=".MyFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
3단계: 시작 액티비티에서 데이터 처리하기 (딥링킹 구현)
마지막으로, 백그라운드나 종료 상태에서 사용자가 알림을 탭했을 때 전달되는 데이터를 처리하는 부분입니다. 이 로직은 보통 앱의 진입점인 `MainActivity` 또는 알림 클릭 시 열리도록 지정한 액티비티에 위치합니다.
핵심은 `onCreate()`와 `onNewIntent()` 두 메소드 모두에서 Intent를 처리하는 것입니다.
onCreate()
: 앱이 종료된 상태에서 알림을 탭하여 새로 시작될 때 호출됩니다.onNewIntent()
: 앱이 이미 백그라운드에 실행 중인 상태에서 알림을 탭하여 다시 포그라운드로 올라올 때 호출됩니다.
`MainActivity.kt`
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
class MainActivity : AppCompatActivity() {
private val TAG = "MainActivity"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 앱이 시작될 때 전달된 Intent를 처리합니다.
// (앱이 완전히 종료된 상태에서 푸시를 통해 실행된 경우)
handleNotificationIntent(intent)
}
/**
* 액티비티가 이미 실행 중인 상태에서 새로운 Intent를 받았을 때 호출됩니다.
* (앱이 백그라운드에 있을 때 푸시를 통해 다시 활성화된 경우)
*/
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// 새로운 Intent를 현재 액티비티의 Intent로 설정하고 처리합니다.
if (intent != null) {
setIntent(intent)
handleNotificationIntent(intent)
}
}
/**
* 알림을 통해 전달된 Intent에서 'data' 페이로드의 값을 추출하고
* 그에 맞는 동작을 수행하는 공통 메소드
*/
private fun handleNotificationIntent(intent: Intent) {
// Intent의 extra에 데이터가 있는지 확인
if (intent.hasExtra("post_id")) {
val postId = intent.getStringExtra("post_id")
val type = intent.getStringExtra("type")
Log.d(TAG, "Notification Clicked! Type: $type, Post ID: $postId")
if (type == "new_post" && postId != null) {
// 'new_post' 타입의 알림이라면, 해당 게시글 ID를 가지고
// PostDetailActivity로 이동합니다.
val detailIntent = Intent(this, PostDetailActivity::class.java).apply {
putExtra("EXTRA_POST_ID", postId)
}
startActivity(detailIntent)
}
}
}
}
이렇게 구현하면 `MainActivity`는 알림을 통해 실행될 때마다 `post_id`가 있는지 확인하고, 있다면 해당 게시글 상세 페이지로 사용자를 즉시 안내할 수 있습니다. 이것이 바로 딥링킹의 기본 원리입니다.
자주 겪는 문제와 고급 팁 (FAQ)
Q1: 중국 제조사(Xiaomi, Huawei 등) 기기에서 백그라운드 알림이 오지 않습니다.
A: 일부 제조사는 배터리 수명을 늘리기 위해 매우 공격적인 백그라운드 앱 종료 정책을 사용합니다. 이 때문에 FCM의 기본 동작이 방해받을 수 있습니다.
- 해결책 1 (사용자 안내): 사용자에게 직접 앱의 배터리 최적화 설정에서 '제한 없음'으로 변경하거나, '자동 시작' 권한을 허용하도록 안내하는 것이 가장 현실적인 방법입니다.
- 해결책 2 (제조사 푸시 연동): 안정적인 푸시가 비즈니스에 매우 중요하다면, 해당 제조사가 제공하는 별도의 푸시 SDK(예: HMS Push Kit)를 FCM과 함께 연동하는 복잡한 방법을 고려해야 할 수도 있습니다.
Q2: `notification` 페이로드의 `click_action`은 무엇인가요? 꼭 필요한가요?
A: `click_action`은 사용자가 알림을 탭했을 때 `MainActivity`가 아닌 특정 액티비티를 직접 열고 싶을 때 사용합니다. `AndroidManifest.xml`에 해당 액션 이름을 가진 `
FCM 페이로드에서는 <activity android:name=".NotificationTargetActivity">
<intent-filter>
<action android:name="OPEN_NOTIFICATION_TARGET" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
"click_action": "OPEN_NOTIFICATION_TARGET"
와 같이 설정합니다. 하지만 이 방법보다 위 가이드에서 설명한 것처럼 `MainActivity`에서 Intent 데이터를 분석하여 분기 처리하는 방식이 더 유연하고 관리가 용이하여 널리 사용됩니다.
Q3: 포그라운드일 때 수신한 알림은 어떻게 테스트하나요?
A: 간단합니다. 앱을 실행하여 화면이 켜진 상태에서 FCM 메시지를 발송하면 됩니다. onMessageReceived
가 호출되고 sendNotification
메소드에서 생성한 커스텀 알림이 나타나는지 확인하면 됩니다.
Q4: 백그라운드 및 종료 상태는 어떻게 정확히 테스트하나요?
A:
- 백그라운드 테스트: 앱을 실행한 후 홈 버튼을 누르거나 다른 앱으로 전환하여 앱이 보이지 않는 상태로 만듭니다. 그 후 FCM을 발송합니다. 시스템 트레이에 알림이 나타나고, 탭했을 때 `onNewIntent` 로직이 잘 동작하는지 확인합니다.
- 종료 상태 테스트: 안드로이드의 최근 앱 목록을 열고 테스트할 앱을 위로 스와이프하여 완전히 종료합니다. 또는 `adb shell am force-stop com.your.package.name` 명령어를 사용해 강제 종료할 수 있습니다. 이 상태에서 FCM을 발송하고, 알림을 탭했을 때 앱이 새로 시작되며 `onCreate`의 Intent 처리 로직이 잘 동작하는지 확인합니다.
결론: 하이브리드 메시지로 FCM 완전 정복
안드로이드 FCM의 복잡한 동작 방식은 처음에는 혼란스러울 수 있습니다. 하지만 notification
과 data
메시지의 특징을 명확히 이해하고, 이 둘을 결합한 '하이브리드 메시지' 전략을 사용하면 모든 시나리오에 효과적으로 대응할 수 있습니다. 이 방법을 통해 우리는 앱이 꺼져있을 때도 시스템의 힘을 빌려 사용자에게 알림을 안정적으로 전달하고, 사용자가 알림에 반응했을 때는 함께 전달된 데이터를 이용해 풍부하고 매끄러운 사용자 경험(UX)을 제공할 수 있습니다. 본 가이드에서 제시한 원리와 코드를 바탕으로 여러분의 앱에 강력하고 안정적인 푸시 알림 기능을 구현하시길 바랍니다.
0 개의 댓글:
Post a Comment