티스토리 뷰

개요

  • 구글이 제공하는 Firebase Cloud Messaging(FCM)을 이용하면, 서버와 같은 외부에서 내가 소유한 앱이 설치된 기기로 1개 이상의 메시지를 전송할 수 있다. FCM은 완전히 무제한으로 무료 제공된다. 크로스 플랫폼을 지원하여 Android, iOS, Chrome 기기에 메시지를 전송할 수 있다. 이번 글에서는 Kotlin, Spring Boot 기반 프로젝트에서 FCM을 이용한 메시지 전송 방법을 설명하고자 한다.

관련 용어

  • 메시지를 전송하려면, 대상이 되는 기기 각각을 식별할 수 있는 고유의 식별 문자열이 요구된다. 이를 FCM Token이라고 부른다. (APNs에서는 Device Token이라고 부르는 것과 같은 개념이다.)
  • 앱 이외의 애플리케이션에서 FCM을 이용하여 1개 이상의 기기에 메시지를 전송하려면, 앱 단위의 인증 키 역할을 하는 파일이 필요하다. Firebase 콘솔에 로그인하여 다운로드가 가능하다. Android 앱에서는 google-services.json, iOS 앱에서는 GoogleService-Info.plist 파일을 다운로드할 수 있다.

build.gradle

  • 프로젝트 내 /build.gradle에 아래 내용을 추가한다.
dependencies {
    compile group: 'com.google.firebase', name: 'firebase-admin', version: '6.10.0'
}

Service Account 파일 생성 및 추가

  • FCM의 인증 수단은 여러가지가 있는데, 그 중 구글에서 최우선으로 권장하는 것은 ADC 방식이다. 이 방식을 사용하면, 최신 버전의 FCM HTTP v1 API를 사용할 있다.
  • 우선, Firebase 콘솔에 접속해야 한다. 메시지를 전송할 앱을 선택한 후, 설정(톱니바퀴 모양의 아이콘)을 클릭 > 서비스 계정 클릭 > 새 비공개 키 생성 클릭 > 키 생성 클릭하면 생성된 Service Account JSON 파일을 다운로드할 수 있다.
  • 다운로드한 파일은 프로젝트 내 /src/main/resources에 복사한다. 아래에서 ADC 인증 수단으로 사용할 것이다.

FCM 초기화 서비스 작성

  • 이제 FCM 초기화 서비스를 작성할 차례이다.
package com.jsonobject.example

import com.google.auth.oauth2.GoogleCredentials
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.io.ClassPathResource

@Configuration
class FcmConfig {

    @Bean
    fun someAppDevFirebaseApp(): FirebaseApp {

        val options = FirebaseOptions
                .Builder()
                .setCredentials(GoogleCredentials.fromStream(ClassPathResource("some-app-dev.json").inputStream))
                .build()

        return FirebaseApp.initializeApp(options, "SOME_APP_DEV")
    }

    @Bean
    fun someAppProdFirebaseApp(): FirebaseApp {

        val options = FirebaseOptions
                .Builder()
                .setCredentials(GoogleCredentials.fromStream(ClassPathResource("some-app-prod.json").inputStream))
                .build()

        return FirebaseApp.initializeApp(options, "SOME_APP_PROD")
    }

    @Bean
    fun firebaseAppExecutor(): ListeningExecutorService {

        return MoreExecutors.newDirectExecutorService()
    }
}
  • 위와 같이 각 앱과 환경 단위로 FirebaseApp 싱글턴 빈을 생성할 수 있다. 1개의 앱만 등록할 경우에는 DEFAULT라는 기본 이름으로 생성되는데 위와 같이 각 앱의 이름을 설정할 수 있다. 이렇게 만들어진 빈들은 실제 메시지를 전송할 때 사용하게 된다.
  • 대량의 알림 메시지를 비동기로 전송하는 것은 푸시 서비스 구현에 있어 매우 흔한 패턴이다. FCM은 비동기 전송시 java.util.concurrent.Executor 구현체를 요구한다. 따라서 안정적인 비동기 전송을 위해 com.google.common.util.concurrent.ListeningExecutorService를 싱글턴 빈으로 등록했다.

메시지 전송 테스트

  • 인증 준비는 완료되었다. FCM SDK를 이용하여, 코드를 작성해볼 차례이다.
// 메시지를 수신할 특정 기기의 토큰 명시
val token = "e7eeDLt72A4:APA91bHV9a500H_hlufkMuQfZOiuFt8X-L8wOTlYLpsap8gdICYh_wb3MUBCPDWjNRAbpSxlozh3E71GJTGJi7Zq8ASFXhx01dwH7ks7Ob6__h3uP0Nhw6KT0kKdDy8Jdnd-KhFg0q20"

val notification = Notification("test_title", "test_body")
val message = Message.builder()
    .setToken(token)
    .setNotification(notification)
    .putData("data_1", "abc")
    .putData("data_2", "def")
    .build()

// 메시지 전송
// 성공시 전송된 Message ID 문자열을 응답
// 실패시 FirebaseMessagingException 예외 발생
val response = FirebaseMessaging.getInstance(someAppDevFirebaseApp).send(message)

// projects/firebase-jsonobject/messages/0:1568016179081916%95c1611595c16115
println(response)
  • Firebase 라이브러리를 이용하면 title, body, image는 최종적으로 notification 오브젝트에, 커스텀 프라퍼티는 data 오브젝트에 담겨 전송된다. 안드로이드 앱의 경우 백그라운드 상태에서 onMessageReceived 메써드를 타지 않아 notification 오브젝트를 획득하지 못하는 이슈가 있다. 이 경우 title, body, image를 커스텀 프라퍼티에 보내고 앱에서는 이 정보를 획득하면 해결된다. [관련 링크] 코드 작성 예는 아래와 같다.
val messageBuilder = Message.builder().setToken(token)
messageBuilder.putAllData(data)
messageBuilder.putData("title", title)
messageBuilder.putData("body", body)
val message = messageBuilder.build()

비동기 전송

  • FCM은 단건 전송 및 최대 100개 단위의 배치 전송을 각각 동기와 비동기 방식으로 전송할 수 있다. 메시지의 대량 전송에 있어 비동기는 필수 요소라고 할 수 있다. 메시지 단건을 비동기 전송하는 예는 아래와 같다. (Kotlin은 펑션 자체를 메써드의 파라메터로 전달할 수 있어 콜백 메써드를 다루기가 용이하다.)
val apiFuture = FirebaseMessaging.getInstance(firebaseApp).sendAsync(message)
ApiFutures.addCallback(apiFuture, object : ApiFutureCallback<String> {
    override fun onSuccess(result: String) {
        // 성공시 처리 로직
    }

    override fun onFailure(t: Throwable) {
        // 실패시 처리 로직
    }
}, firebaseAppExecutor)

멀티캐스트 전송

  • FCMtopic이라는 개념을 통해 멀티캐스트 전송을 지원한다. 특정 앱에서 코드를 통해 특정 토픽을 구독해두고, 서버에서 특정 토픽으로 메시지를 전송하면 해당 토픽을 구독하는 모든 앱으로 메시지가 멀티캐스트된다. 토픽을 활용하면, 모든 기기의 FCM Token을 알야아 하는 부담을 덜 수 있는 장점이 있다.
  • APNs의 경우 토픽 개념이 존재하지 않아, 멀티캐스트시 대상 기기의 Device Token을 전부 알아야 한다. Firebase 프로젝트에 iOS 앱을 통합하고, FCM으로 메시징을 일원화하면 편리하게 토픽 기능을 이용할 수 있다.

예외 케이스 정리

  • send() 실행시 정상 케이스는 결과 문자열을, 실패 케이스에서는 FirebaseMessagingException 예외가 발생한다.
  • 토큰의 형식이 유효하지 않으면 The registration token is not a valid FCM registration token 예외가 발생한다.
  • 토큰의 형식은 유효하지만 존재하지 않는 토큰일 경우 Requested entity was not found. 예외가 발생한다. 이 경우, 토큰이 변경되었는지 확인해봐야 한다.
  • 토큰의 형식은 유효하지만 대상 앱에 해당하는 토큰이 아닐 경우 SenderId mismatch 예외가 발생한다. 다른 앱의 인증키를 이용하여 발송한 것은 아닌지 확인해봐야 한다.

참고 글

댓글
댓글쓰기 폼