Android FCM 토큰 관리 운영 가이드: 서버 동기화 및 만료 처리 방법
앱에 FCM 푸시 기능을 붙일 때, 처음에는 그냥 발급받은 토큰을 서버에 보내서 저장하면 끝나는 줄 알았습니다. 하지만 서비스를 이래저래 직접 운영하다 보니 이게 생각보다 손이 많이 가더군요.
사용자가 앱을 지웠다 다시 깔거나, 기기를 바꾸거나, 혹은 오랫동안 앱을 안 쓰면 토큰이 계속 바뀝니다. 이걸 제대로 관리 안 하면 유효하지 않은 토큰에 계속 푸시를 쏴서 서버 리소스만 낭비되고 발송 성공률 통계도 엉망이 됩니다.
자꾸 놓치는 부분이 생겨서, 실무 운영 중에 막히지 않도록 클라이언트와 서버 측의 FCM 토큰 관리 기준을 메모 형태로 정리해 둡니다.
1. 운영하다 보면 꼭 마주치는 토큰 문제들
처음 구현할 때 대충 넘어가면 나중에 운영계 데이터베이스를 보고 한숨을 쉬게 되는 상황들입니다.
한 번만 저장하고 방치: 최초 실행 때만 토큰을 서버에 보내고 이후 갱신을 안 함.
보안 불감증: 디버깅 편하게 하려고
onNewToken()에서 받은 토큰을 무심코 로그에 그대로 출력함.갱신 시각 누락: 서버 DB에 토큰만 딸랑 저장하고, 이 토큰이 언제 업데이트되었는지(
updated_at등) 기록하지 않음.예외 상황 기준 없음: 로그아웃, 회원 탈퇴, 기기 변경 시 토큰을 어떻게 처리할지 정책이 없음.
실패 응답 방치: FCM 서버가
UNREGISTERED같은 실패 응답을 주는데도 DB에서 안 지우고 계속 발송함.재시도 로직 부재: 네트워크 불안정으로 서버 동기화가 실패했을 때, 재시도 없이 그냥 조용히 묻힘.
다기능 기기 미고려: 사용자 한 명이 폰과 태블릿을 동시에 쓸 수 있다는 점(1:N 관계)을 고려하지 않고 DB를 설계함.
운영 관점에서 FCM 토큰은 단순한 문자열이 아닙니다. 사용자, 기기, 앱 설치 인스턴스, 그리고 갱신 시각이 묶인 유기적인 데이터로 접근해야 합니다.
2. 핵심 개념: FCM 토큰은 '앱 설치 인스턴스' 단위입니다
FCM 등록 토큰은 서버가 특정 사용자가 아니라 특정 기기에 설치된 앱 인스턴스를 식별해 메시지를 보낼 때 쓰는 값입니다. 사용자 계정 ID와는 완전히 별개입니다.
| 항목 | 의미 | 주의할 점 |
| FCM token | 앱 인스턴스에 메시지를 보낼 대상 값 | 로그나 공개된 코드에 절대 남기지 말 것. |
| userId | 서비스 사용자 식별자 | 토큰과 1:N 관계가 될 수 있음을 인지해야 함. |
| deviceId | 앱 내부 기기 식별용 값 | 개인정보 보호 정책(OS 정책 등) 검토 필요. |
| updatedAt | 토큰 마지막 갱신 시각 | 장기 비활성 토큰을 추려내고 정리하는 기준이 됨. |
| notificationEnabled | 사용자의 푸시 수신 동의 상태 | Android OS 권한 및 서비스 마케팅 동의와 분리해서 관리. |
3. 토큰이 바뀌는 주요 상황과 처리 기준
Firebase 공식 문서에서도 안내하듯이, 아래 상황에서는 반드시 토큰을 새로 서버에 동기화하고 타임스탬프를 찍어줘야 합니다.
| 상황 | 앱(클라이언트) 처리 | 서버(백엔드) 처리 |
| 앱 최초 실행 | 현재 토큰을 조회해서 서버로 전송 | 새 토큰 레코드를 생성하고 생성/갱신 시각 기록 |
| onNewToken() 호출 | 변경된 새 토큰을 서버에 동기화 | 기존 토큰을 찾아서 교체하거나 새로 적재 |
| 앱 재설치 | 새 토큰이 발급되므로 서버 전송 | 기존 토큰은 발송 실패 응답이나 만료 정책으로 제거 |
| 로그아웃 | 토큰 연결 상태 해제 요청 | 사용자 ID와 토큰 간의 매핑 관계 해제/비활성화 |
| 회원 탈퇴 | 필요 시 로컬 토큰 데이터 삭제 | DB에서 해당 사용자와 연결된 모든 토큰 삭제 |
| 장기 미사용 기기 | 앱을 다시 열었을 때 현재 토큰 재갱신 | 오래된 타임스탬프를 기준으로 주기적 배치 정리 |
4. Android 클라이언트 구현 예시
onNewToken() 안에서 곧바로 무거운 네트워크 통신(API 호출)을 돌리면 앱이 버벅이거나 비정상 종료될 위험이 있습니다. 저의 경우 WorkManager를 사용해서 백그라운드에서 안전하게 예약 실행되도록 구조를 잡았습니다.
class AppFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
// 토큰이 바뀌면 WorkManager를 통해 비동기로 서버에 동기화 요청을 넘깁니다.
TokenSyncScheduler.enqueue(applicationContext)
}
}
object TokenSyncScheduler {
fun enqueue(context: Context) {
val request = OneTimeWorkRequestBuilder<FcmTokenSyncWorker>()
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS,
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"fcm-token-sync",
ExistingWorkPolicy.REPLACE, // 이미 대기 중인 작업이 있다면 새 작업으로 대체
request,
)
}
}
class FcmTokenSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
return runCatching {
// 최신 토큰 값 가져오기
val token = Firebase.messaging.token.await()
// [메모] Repository 내부에서 세션 인증 상태, 앱 버전, 플랫폼, 타임스탬프를 함께 서버 API로 던집니다.
// tokenRepository.syncToken(token)
Result.success()
}.getOrElse {
// 실패 시 최대 3번까지 지수 백오프 정책으로 재시도합니다.
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
작은 팁: 개발하다 보면 로그캣에 토큰을 냅다 찍어서 복사해 쓰곤 하는데, 운영 빌드나 공용 로그 시스템에 토큰이 평문으로 남으면 보안 취약점이 됩니다. 디버깅용 로그는 무조건 제거하거나 성공/실패 여부 정도만 남기시는 게 안전합니다.
5. 서버 DB 저장 모델 설계 예시
서버단 데이터베이스(MySQL, MariaDB 등)를 설계할 때는 단순히 fcm_token 컬럼 하나로 끝내지 말고, 상태 값과 이력을 추적할 수 있도록 필드를 넉넉히 받아두는 편이 좋습니다.
push_tokens 테이블 구조 예시
id(PK)user_id(사용자 식별 ID, 1:N 관계 고려)platform(예:android,ios)token_hash(중복 검사 및 빠른 인덱싱을 위한 단방향 해시값)token_encrypted(보안을 고려해 암호화된 실제 토큰 값)app_version(앱 버전 추적용)notification_permission_status(OS 알림 권한 획득 여부)last_synced_at(앱에서 마지막으로 토큰을 갱신해 준 시각)last_success_at(실제 푸시 발송 성공 시각)disabled_at(로그아웃, 유효하지 않은 토큰 등으로 차단된 시각)created_atupdated_at
핵심 필드 운영 기준
last_synced_at: 앱이 켜지거나 토큰이 갱신되어 서버로 신호를 보낸 날짜입니다. 이 날짜가 너무 오래되었다면 앱을 지웠거나 기기를 방치한 것으로 보고 유령 토큰으로 간주합니다.
token_hash / token_encrypted: 관리자 화면이나 서비스 로그에 raw 토큰이 노출되는 것을 막기 위함입니다. 보안 정책에 따라 해시 인덱스를 만들어 검색 효율을 높이고 실제 값은 암호화해 저장하는 방식을 추천합니다.
6. 오래된 유령 토큰(Stale Token) 정리 기준
Firebase 공식 문서 지침에 따르면, FCM에 한 달 이상 연결되지 않은 앱 인스턴스는 stale(만료 예정) 상태로 볼 수 있고, Android 기준으로 270일 동안 비활성화된 토큰은 완전히 만료될 수 있습니다.
서버단에서 매일 혹은 매주 일정한 주기로 배치를 돌려 아래 기준에 맞춰 테이블을 청소해 줘야 발송 비용을 아낄 수 있습니다.
| 유효성 판단 조건 | 처리 기준 |
HTTP v1 발송 응답이 UNREGISTERED 인 경우 | 사용자가 앱을 삭제했거나 유효하지 않은 토큰이므로 즉시 DB에서 비활성화 또는 삭제합니다. |
유효한 페이로드인데 INVALID_ARGUMENT 발생 | 토큰 값 자체가 잘못되었을 확률이 높으므로 확인 후 제거합니다. |
last_synced_at이 수개월 이상 지난 경우 | 서비스 성격에 따라 30일~90일 기준으로 장기 미사용 유령 토큰으로 분류해 발송 대상에서 제외합니다. |
| 회원 탈퇴 완료 시 | 유예기간이 지나면 매핑된 토큰 레코드를 완전 삭제합니다. |
| 로그아웃 시 | 정책에 따라 토큰과의 연결을 끊거나 disabled_at 처리를 해 둡니다. |
7. 혼동하기 쉬운 포인트: '토큰 유효성'과 '알림 권한'은 다릅니다
서버에 토큰이 살아있다고 해서 사용자가 무조건 알림을 받는 것은 아닙니다. 아래와 같이 명확히 계층 구조가 나뉩니다.
FCM 등록 토큰: 서버가 구글 FCM 기기 서버로 메시지를 던질 수 있는 '창구'가 열려 있는가?
Android OS 알림 권한: Android 13 이상 등 기기에서 앱이 사용자에게 팝업을 띄울 수 있도록 권한(
POST_NOTIFICATIONS)을 허용했는가?Notification Channel: 사용자가 앱 설정이나 OS 설정에서 특정 알림 종류(예: 공지사항, 이벤트 등)만 쏙 꺼두지는 않았는가?
서비스 수신 동의: 우리 서비스 약관상 마케팅 푸시 수신에 동의했는가?
서버에서 푸시를 발송할 때는 이 4가지 조건이 유기적으로 맞물려야 하므로, 토큰 동기화 API를 호출할 때 OS 권한 상태 정보도 함께 갱신해 주면 정교한 타겟팅 발송을 할 때 아주 유용합니다.
⚠️ 실무 체크리스트 요약
[ ] FCM 토큰 전체를 로그캣이나 툴에 평문으로 막 출력하지 않는가?
[ ] 서버 테이블에 토큰 저장 시
last_synced_at타임스탬프를 함께 남기는가?[ ] 클라이언트
onNewToken()처리 시 네트워크 지연을 막기 위해 WorkManager 같은 백그라운드 위임 구조를 썼는가?[ ] 사용자 한 명이 여러 기기(스마트폰, 태블릿 등)를 쓸 수 있게 DB가 1:N 구조로 열려 있는가?
[ ] FCM 서버 발송 실패 응답(
UNREGISTERED등)을 받으면 DB에서 즉시 상태를 바꾸거나 지워주는가?[ ] 로그아웃 및 회원 탈퇴 시 토큰 관계 처리 프로세스가 잡혀 있는가?
[ ] OS 알림 권한과 서비스 수신 동의 상태를 FCM 토큰 유효성과 분리해서 검증하는가?
마치며
가벼운 토이 프로젝트나 혼자 테스트하는 단계라면 단순 문자열 저장만으로도 푸시가 잘 가는 것처럼 보입니다. 하지만 실제 사용자가 유입되고 서비스를 장기 운영하다 보면 이 토큰 동기화와 만료 처리 로직이 빌드 퀄리티와 서버 비용을 크게 좌우하게 됩니다.
처음 세팅할 때 조금 귀찮더라도 클라이언트 비동기 처리와 서버측 타임스탬프 기반 청소 배치를 꼼꼼하게 잡아두는 것을 강력히 권장합니다.
댓글
댓글 쓰기