FCM 대규모 발송: 무효 토큰은 어떻게 처리해야 하나
FCM 대규모 발송: 무효 토큰은 어떻게 처리해야 하나
→ FCM(Firebase Cloud Messaging)은 구글이 제공하는 모바일 푸시 알림 서비스다. 토큰은 각 기기를 식별하기 위해 FCM이 발급하는 고유 문자열이다.
2026-02-24
발단
매일 수만 건의 FCM 푸시를 보내고 있었다. 그런데 발송 실패율이 점점 올라갔다.
로그를 까보니 원인의 대부분이 무효 토큰이었다.
UNREGISTERED — 앱을 삭제한 사용자
SENDER_ID_MISMATCH — 다른 프로젝트의 토큰
INVALID_ARGUMENT — 형식이 잘못된 토큰
문제는 이 무효 토큰들이 DB에 계속 남아있다는 것이었다. 매번 같은 토큰으로 발송을 시도하고 매번 실패하고 있었다.
FCM 에러의 두 종류
FCM이 리턴하는 에러는 크게 두 종류다.
영구적 에러 (다시 보내도 실패)
UNREGISTERED — 토큰이 더 이상 유효하지 않음 (앱 삭제, 재설치 등으로 기존 토큰이 폐기된 경우)
SENDER_ID_MISMATCH — 토큰이 다른 Firebase 프로젝트 소유
INVALID_ARGUMENT — 토큰 형식 자체가 잘못됨
→ 재시도해봐야 소용없다. DB에서 토큰을 비활성 처리해야 한다.
일시적 에러 (잠시 후 다시 시도하면 될 수 있음)
INTERNAL — FCM 서버 내부 오류
UNAVAILABLE — FCM 서버가 일시적으로 응답할 수 없는 상태 (서버 점검, 과부하 등)
QUOTA_EXCEEDED — 발송 한도 초과 (일정 시간 내 너무 많은 메시지를 보낸 경우)
→ 1~2회 재시도가 합리적이다.
설계: 무효 토큰 자동 정리 파이프라인
처음에는 FCM 에러가 나면 그 자리에서 바로 DB를 UPDATE 하려 했다.
// 안 좋은 방법 — 발송 루프 안에서 DB UPDATE
for (String token : tokens) {
try {
fcm.send(message, token);
} catch (InvalidTokenException e) {
userRepository.disableToken(token); // 발송 루프가 느려짐
}
}
문제: 10만 건 발송 중 3만 건이 무효면 3만 번의 개별 UPDATE가 발송 루프 안에서 실행된다. 너무 느리다.
해결: Kafka를 통한 비동기 처리
→ Kafka(Apache Kafka)는 대량의 메시지를 빠르게 주고받을 수 있는 분산 메시지 큐 시스템이다. 보내는 쪽(프로듀서)이 메시지를 발행하면 받는 쪽(컨슈머)이 나중에 꺼내 처리한다.
FCM 발송 루프
└── 무효 토큰 발견 → Kafka 토픽에 발행 (비동기)
별도 컨슈머
└── Kafka에서 무효 토큰 수신 → 벌크 UPDATE
// 발송 쪽 — Kafka에 무효 토큰 발행만 하고 넘어감
private void handleFcmError(String token, FirebaseMessagingException e) {
if (isPermanentError(e)) {
kafkaTemplate.send("invalid-token-topic", token);
} else if (isTransientError(e)) {
retryOnce(token);
}
}
// 별도 컨슈머 — 모아서 벌크 처리
@KafkaListener(topics = "invalid-token-topic")
public void handleInvalidTokens(List<String> tokens) {
jdbcTemplate.batchUpdate(
"UPDATE user SET push_token = 'INVALID' WHERE push_token = ?",
tokens // 벌크!
);
}
그런데 여기서 삽질이 하나 있었다
Kafka 메시지를 JSON으로 직렬화(객체를 네트워크로 전송하거나 저장할 수 있는 문자열/바이트 형태로 변환하는 것)했더니 토큰이 이중 인용부호로 감싸져서 저장됐다.
기대: abc123token
실제: "abc123token" ← JsonSerializer가 String을 JSON 문자열로 만듦
JsonSerializer는 객체를 JSON으로 변환하는데, String도 JSON 문자열로 감싸버린다.
// Before — JsonSerializer (이중 인용부호 문제)
kafkaTemplate.send(topic, token); // "abc123" → "\"abc123\""
// After — StringSerializer
stringKafkaTemplate.send(topic, token); // "abc123" → "abc123"
단순 문자열을 보낼 때는 StringSerializer를 쓰자.
재시도 로직
일시적 에러에는 1회 재시도를 넣었다.
private void sendWithRetry(Message message, String token) {
try {
fcm.send(message, token);
} catch (FirebaseMessagingException e) {
if (isTransientError(e)) {
try {
Thread.sleep(1000);
fcm.send(message, token); // 1회 재시도
} catch (Exception retryEx) {
recordFailure(token, retryEx);
}
} else if (isPermanentError(e)) {
publishInvalidToken(token);
}
}
}
재시도 횟수를 1회로 제한한 이유: 대량 발송 중에 무한 재시도를 하면 전체 처리 시간이 폭발한다. 1회 재시도로 해결 안 되면 기록해두고 나중에 일괄 재시도하는 게 낫다.
배운 것
- FCM 에러는 영구적/일시적으로 분류하고 각각 다르게 처리하자.
- 무효 토큰은 DB에서 자동으로 정리해야 한다. 안 하면 매일 같은 에러가 쌓인다.
- 발송 루프 안에서 개별 DB UPDATE를 하지 말고, Kafka 등으로 비동기 벌크 처리하자.
- 단순 문자열을 Kafka로 보낼 때는
StringSerializer를 써야 이중 인용부호 문제가 안 생긴다.