Monday, June 24, 2019

안드로이드 FCM 푸시 알림, 놓치지 않는 비결: 앱 상태별 동작 원리와 완벽 구현 가이드

안드로이드 앱 개발에서 푸시 알림은 사용자의 재방문과 참여를 유도하는 핵심 기능입니다. 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() 호출되지 않음.
메시지가 유실될 가능성이 매우 높음. 사실상 수신을 보장할 수 없음.

이 표가 말해주는 핵심 딜레마는 다음과 같습니다.

  1. 앱이 꺼져있을 때도 알림을 확실히 받게 하려면 Notification 메시지를 써야 합니다. 하지만 이 경우, 앱이 백그라운드에 있을 때 onMessageReceived()가 호출되지 않아 앱 내부적으로 데이터를 처리하거나 커스텀 알림을 만드는 것이 불가능합니다.
  2. 모든 상황에서 앱이 메시지 데이터를 제어하고 싶다면 Data 메시지를 써야 합니다. 하지만 이 경우, 사용자가 앱을 완전히 종료(Terminated)하면 메시지 수신 자체를 보장할 수 없게 됩니다. 이는 치명적인 단점입니다.

결국, 어떤 한 가지 방법만으로는 모든 요구사항을 충족시킬 수 없습니다. 그렇다면 해결책은 무엇일까요?

완벽한 해결책: Notification과 Data를 혼합한 '하이브리드 메시지'

정답은 두 가지 메시지의 장점만을 결합하는 것입니다. 즉, FCM 페이로드에 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 객체: 사용자에게 직접 보여질 부분입니다. titlebody는 필수적이며, 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`에 해당 액션 이름을 가진 ``를 정의해야만 동작합니다.

<activity android:name=".NotificationTargetActivity">
    <intent-filter>
        <action android:name="OPEN_NOTIFICATION_TARGET" />
        <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
</activity>
FCM 페이로드에서는 "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의 복잡한 동작 방식은 처음에는 혼란스러울 수 있습니다. 하지만 notificationdata 메시지의 특징을 명확히 이해하고, 이 둘을 결합한 '하이브리드 메시지' 전략을 사용하면 모든 시나리오에 효과적으로 대응할 수 있습니다. 이 방법을 통해 우리는 앱이 꺼져있을 때도 시스템의 힘을 빌려 사용자에게 알림을 안정적으로 전달하고, 사용자가 알림에 반응했을 때는 함께 전달된 데이터를 이용해 풍부하고 매끄러운 사용자 경험(UX)을 제공할 수 있습니다. 본 가이드에서 제시한 원리와 코드를 바탕으로 여러분의 앱에 강력하고 안정적인 푸시 알림 기능을 구현하시길 바랍니다.


0 개의 댓글:

Post a Comment