JunhoCho's Dev Log home dev life interests

Android + Ktor로 배우는 FCM 푸시(1)

25 Jan 2026

[Android/FCM] 알림 서비스 구현 전, 테스트로 시작해 딥다이브까지: FCM 내부 원리 파헤치기

0. 시작하며: “그저 테스트 한 번 해보려 했을 뿐인데…”

최근 프로젝트를 진행하며 알림 기능을 넣기 전, FCM(Firebase Cloud Messaging)이 어떻게 돌아가는지 가볍게 테스트를 시작했습니다.

단순히 공식 문서를 보고 코드를 복사해서 붙여넣을 수도 있었지만, 코드 속에 보이는 생소한 어노테이션들과 데이터 흐름을 추적하다 보니 어느덧 안드로이드의 비동기 처리와 멀티스레드 안정성을 공부하는 딥다이브 여정이 되었습니다. 그 과정에서 배운 핵심 내용들을 정리해 봅니다.


1. 코드 속의 숨은 안전장치: @Volatile과 @GuardedBy

Firebase 라이브러리 내부나 싱글톤 패턴을 구현한 FcmRepositoryImpl을 분석하다 보면 재미있는 어노테이션들이 등장합니다.


2. 수신대기소 : FirebaseMessagingService

(1) FirebaseMessagingService란?

FCM 서버로부터 백그라운드에서 메시지를 수신하고, 토큰이 변경되었을 때 이벤트를 처리하는 안드로이드 서비스(Service) 클래스입니다. 이 클래스는 사용자가 앱을 사용하고 있지 않을 때도(백그라운드 상태) 시스템에 의해 실행되어 메시지를 처리할 수 있게 해줍니다.

(2) 핵심 메서드 2가지

class MyFirebaseMessagingService : FirebaseMessagingService() {
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        val body = remoteMessage.notification?.body ?: remoteMessage.data["body"] ?: "New Message"
        fcmRepository.emitMessage(body)
    }

    override fun onNewToken(token: String) {
        Log.d(TAG, "Refreshed token: $token")
    }
}
onMessageReceived(remoteMessage: RemoteMessage)
onNewToken(token: String)

(3) 왜 이게 ‘서비스(Service)’인가요?

안드로이드에서 서비스는 화면 없이 뒤에서 돌아가는 컴포넌트입니다.


3. 끊김 없는 이벤트 전달: SharedFlow 전략

메시지를 UI(Compose/ViewModel)로 전달하기 위해 MutableSharedFlow를 사용했습니다.

    private val _messageFlow = MutableSharedFlow<String>(
        replay = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )

여기에는 두 가지 전략이 담겨 있습니다.


4. 비즈니스 로직의 중심: FcmRepositoryImpl 설계

FCM 서버와 통신하고 데이터를 가공하여 UI로 전달하는 핵심 로직은 FcmRepositoryImpl에서 담당합니다. 이 클래스에는 라이브러리 설계의 정석이라 불리는 몇 가지 기법이 적용되어 있습니다.

(1) 비동기 Task를 코루틴으로: await()의 활용

Firebase SDK의 많은 메서드는 Task<T> 기반의 콜백 형태입니다. 이를 현대적인 코틀린 코루틴 환경에서 깔끔하게 다루기 위해 await()를 사용했습니다.

override suspend fun getFcmToken(): String {
        return try {
            FirebaseMessaging.getInstance().token.await()
        } catch (e: Exception) {
            "Error: ${e.message}"
        }
    }

이 방식은 코드의 가독성을 높일 뿐만 아니라, 비동기 작업 중 에러가 발생했을 때 try-catch를 통해 예외 처리를 훨씬 직관적으로 할 수 있게 해줍니다.

(2) 안전한 인스턴스 보장: Double-checked locking

Dagger나 Hilt 같은 의존성 주입(DI) 도구가 사용하지 않고 간략하게 앱 전역에서 단 하나의 인스턴스만 보장되도록 싱글톤 패턴을 구현했습니다.

    companion object {
        /**
         * DI(의존성 주입) 도구가 완벽히 설정되지 않은 환경을 위한 싱글톤 인스턴스 접근 메서드입니다.
         * Thread-safe한 이중 잠금(Double-checked locking) 패턴을 사용합니다.
         */
        @Volatile
        private var INSTANCE: FcmRepositoryImpl? = null

        fun getInstance(): FcmRepositoryImpl {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: FcmRepositoryImpl().also { INSTANCE = it }
            }
        }
    }

여기서 사용된 @Volatile은 여러 CPU 캐시 간의 데이터 불일치를 막고 메인 메모리에서 직접 값을 읽게 하여, 멀티스레드 환경에서도 원자적인 인스턴스 생성을 보장합니다.

(3) 관심사의 분리 (SoC)

FcmRepositoryImpl은 내부적으로 MutableSharedFlow를 통해 메시지를 발행(emitMessage)하고, 외부에는 오직 읽기 전용인 Flow만 반환(getMessageFlow)합니다. 이러한 설계는 UI 레이어(Activity/Compose)가 데이터 발행 로직에 직접 관여하지 못하게 차단하고, 오직 데이터의 변화만 감지하도록 하여 데이터의 흐름을 단방향(UDA)으로 유지합니다.


5. 마치며: 테스트 그 이상의 수확

설정화면

처음에는 단순히 “알림이 잘 오나?” 확인하려 시작한 테스트였지만, 그 과정에서 동시성 제어, 서비스 컴포넌트의 역할, 그리고 비동기 작업을 중단 함수로 변환하는 기법까지 깊게 고민해 볼 수 있었습니다.

단순히 기능을 구현하는 것을 넘어, “이 코드가 왜 이렇게 설계되었을까?”를 질문하고 그 해답을 소스 코드 안에서 찾는 과정이 개발자로서 한 단계 더 성장하는 밑거름이 된다는 것을 다시 한번 느꼈습니다.