[Android] FCM 수신 백그라운드 작업 WorkManager로 안전하게 분리하기 (실무 적용 코드)
앱을 개발하고 운영하다 보면 FCM(Firebase Cloud Messaging)을 연동할 일이 참 많습니다. 푸시 메시지가 올 때 단순히 알림만 띄우면 상관없는데, 메시지를 신호탄 삼아 서버에서 상세 데이터를 다시 긁어오거나 로컬 DB를 동기화해야 하는 경우가 꼭 생기더군요.
저의 경우도 처음에는 FirebaseMessagingService.onMessageReceived() 안에서 가볍게 네트워크 요청을 보냈다가, 앱이 백그라운드에 있을 때 작업이 툭 끊기거나 실패 시 재시도가 안 돼서 골치를 썩은 적이 있습니다. 자꾸 까먹기도 하고, 실무에서 실수하기 쉬운 부분이라 정돈해서 기록해 둡니다.
1. 운영하다 보면 자주 하는 실수 (내가 겪은 상황)
FCM 수신 처리에서 문제가 생기는 코드는 보통 아래와 같은 상황을 간과할 때 발생합니다.
onMessageReceived()안에서 긴 네트워크 요청(API 호출)을 바로 실행함실패하면 다시 시도해야 하는 작업을 일회성 처리로 대충 끝냄
앱이 백그라운드 상태일 때 OS 제약으로 작업이 중간에 강제 종료되는 상황을 고려 안 함
넘어온 data payload를 검증하지 않고 그대로 Worker 인풋으로 넘김
로그에 FCM 토큰, 서버 URL, 사용자 식별자(ID)를 그대로 남겨 보안 필터에 걸림
알림을 표시하는 UI 책임과 데이터 동기화 책임을 한 함수에서 모두 처리함
운영 관점에서는 FCM 수신 콜백을 "모든 작업을 수행하는 시작점"이 아니라, "작업을 분류하고 안전한 곳으로 위임하는 진입점"으로 보는 것이 좋습니다.
2. 해결 방법: 즉시 처리와 위임 처리 나누기
작업 성격에 따라 FCM 콜백에서 바로 끝낼지, WorkManager로 넘길지 확실히 나눠야 유지보수가 편합니다. 제가 실무에서 잡은 기준은 이렇습니다.
| 작업 종류 | 처리 위치 | 이유 |
| 단순 알림 표시 | onMessageReceived() | 짧고 즉시 끝나는 작업입니다. |
| payload 데이터 검증 | onMessageReceived() | 잘못된 메시지는 진입점에서 빠르게 차단해야 합니다. |
| 상세 데이터 동기화 | WorkManager | 추가 네트워크 요청과 실패 시 재시도가 필요합니다. |
| 로컬 DB 갱신 | WorkManager | 파일 I/O나 DB 처리는 시간이 길어질 수 있습니다. |
| 이미지/첨부 다운로드 | WorkManager | 대용량 및 네트워크 조건(Wi-Fi 연결 등) 고려가 필요합니다. |
| 토큰 서버 등록 | WorkManager 또는 Repository | 네트워크가 튈 때 실패 시 백오프(Backoff) 재시도가 필수적입니다. |
핵심은 FCM 콜백에서 "무엇을 해야 하는지"만 빠르게 판단하고, 시간이 걸리거나 실행이 보장되어야 하는 로직은 Worker로 던지는 것입니다.
3. 실제 적용 코드 (실무 구조)
보안을 위해 실제 서버 URL이나 토큰, 사용자 식별자는 제외하고 구조 중심으로 작성한 코드입니다. copy-paste 해서 프로젝트 환경에 맞게 변형해 쓰시면 됩니다.
1) FCM Callback 예시 (AppFirebaseMessagingService)
진입점에서는 넘어온 데이터를 최소한의 허용 목록(Allowlist)과 정규식으로 검증한 뒤, 무거운 작업은 WorkManager 큐에 넣고 바로 빠져나옵니다. onNewToken()에서도 토큰 값을 로그에 남기지 않고 동기화 작업만 위임합니다.
class AppFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
val pushType = data["type"]
val contentId = data["contentId"]
// 1. 진입점 데이터 검증
if (!isValidPushType(pushType) || !isValidContentId(contentId)) {
return
}
// 2. 작업 분류 및 위임
when (pushType) {
"notice" -> showSimpleNotification(contentId.orEmpty())
"sync" -> enqueueSyncWork(contentId.orEmpty())
"refresh" -> enqueueRefreshWork(contentId.orEmpty())
}
}
override fun onNewToken(token: String) {
// 실제 토큰은 로그에 남기지 않고 Worker나 Repository 내부에서 안전하게 읽어 서버와 동기화합니다.
enqueueTokenSync()
}
private fun enqueueSyncWork(contentId: String) {
val input = workDataOf("contentId" to contentId)
val request = OneTimeWorkRequestBuilder<PushSyncWorker>()
.setInputData(input)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS,
)
.build()
WorkManager.getInstance(this).enqueue(request)
}
private fun enqueueRefreshWork(contentId: String) {
val input = workDataOf("contentId" to contentId)
val request = OneTimeWorkRequestBuilder<PushRefreshWorker>()
.setInputData(input)
.build()
WorkManager.getInstance(this).enqueue(request)
}
private fun enqueueTokenSync() {
val request = OneTimeWorkRequestBuilder<TokenSyncWorker>()
.build()
WorkManager.getInstance(this).enqueue(request)
}
private fun showSimpleNotification(contentId: String) {
// 검증된 contentId를 기반으로 기본 알림만 즉시 표시합니다.
}
private fun isValidPushType(value: String?): Boolean {
return value in setOf("notice", "sync", "refresh")
}
private fun isValidContentId(value: String?): Boolean {
return value != null && value.matches(Regex("^[0-9A-Za-z_-]{1,60}$"))
}
}
2) Worker 예시 (PushSyncWorker)
실제 백그라운드에서 네트워크를 타거나 DB를 건드리는 로직을 담당합니다. FCM 콜백에서 한 번 검증했더라도 Worker는 독립적으로 예약 실행될 수 있으므로, 인풋 값을 방어적으로 한 번 더 검증해 주는 게 안전합니다.
class PushSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
// Worker 내부에서 인풋 데이터 재검증
val contentId = inputData.getString("contentId")
?.takeIf { it.matches(Regex("^[0-9A-Za-z_-]{1,60}$")) }
?: return Result.failure()
return runCatching {
// Repository를 통해 상세 데이터를 가져오고 로컬 DB를 갱신하는 비즈니스 로직 적용
// repository.syncContent(contentId)
Result.success()
}.getOrElse {
// 실패 시 최대 3번까지 재시도 정책 적용
if (runAttemptCount < 3) {
Result.retry()
} else {
Result.failure()
}
}
}
}
4. 중복 작업 제어 및 제약 조건 설정
동일한 콘텐츠에 대한 동기화 요청이 짧은 시간에 여러 번 튀어서 들어올 때가 있습니다. 이럴 때는 unique work 정책을 활용해 중복 작업을 묶어주면 서버와 단말기 리소스를 아낄 수 있어 유용합니다.
private fun enqueueUniqueContentSync(context: Context, contentId: String) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // 네트워크 연결 상태 제약 추가
.build()
val request = OneTimeWorkRequestBuilder<PushSyncWorker>()
.setConstraints(constraints)
.setInputData(workDataOf("contentId" to contentId))
.build()
// 고유 키 값을 주어 동일 작업이 대기 중일 때 교체(REPLACE)하도록 처리
WorkManager.getInstance(context).enqueueUniqueWork(
"push-sync-$contentId",
ExistingWorkPolicy.REPLACE,
request,
)
}
상황에 따라 무조건 기존 작업을 깨우는 REPLACE 대신, 기존 작업을 유지하는 KEEP 전략이나 큐에 쌓는 방식이 맞을 수 있으니 서비스 성격에 맞게 선택하시면 됩니다.
5. 실무 운영 체크리스트
운영 환경에 배포하기 전에 아래 리스트는 꼭 한 번씩 체크해 보시는 걸 권장합니다.
[ ]
onMessageReceived()에서는 빠른 검증과 분류만 수행하는가?[ ] 오래 걸리는 네트워크/DB 작업은
WorkManager로 확실히 분리했는가?[ ] 푸시 payload의 type과 contentId를 allowlist와 정규식으로 안전하게 검증하는가?
[ ] Worker 내부의
inputData도 방어적으로 다시 검증하는가?[ ] FCM 토큰, 서버 URL, 사용자 식별자를 로그나 코드 스니펫에 그대로 노출하지 않았는가?
[ ] 실패 시 재시도가 필요한 작업에 알맞은 backoff 정책(지수 백오프 등)을 두었는가?
[ ] 네트워크 상태 등 필요한 Constraints(제약 조건)를 적절히 설정했는가?
[ ] 알림 표시(UI)와 데이터 동기화(Business) 책임을 분리했는가?
6. 짧은 마무리
WorkManager는 만능 즉시 실행 도구가 아니라, "시스템 상태를 고려해 언젠가 반드시 끝나도록 보장하는 백그라운드 예약 관리 도구"입니다. 단순히 알림만 띄우고 끝나는 가벼운 앱이라면 굳이 이렇게 구조를 복잡하게 짤 필요 없이 콜백에서 바로 끝내는 게 낫습니다.
하지만 알림 수신 후 대용량 데이터를 동기화해야 하거나 백그라운드 안정성이 최우선인 비즈니스 앱이라면, 처음부터 진입점과 실행 부를 명확히 분리해 두어야 나중에 OS 버전이 올라가거나 백그라운드 제약이 강해질 때 CS(고객 문의)를 줄일 수 있습니다. 작업하실 때 참고하세요.
댓글
댓글 쓰기