목록으로

FCM 대규모 발송: 무효 토큰은 어떻게 처리해야 하나

13

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회 재시도로 해결 안 되면 기록해두고 나중에 일괄 재시도하는 게 낫다.


배운 것

  1. FCM 에러는 영구적/일시적으로 분류하고 각각 다르게 처리하자.
  2. 무효 토큰은 DB에서 자동으로 정리해야 한다. 안 하면 매일 같은 에러가 쌓인다.
  3. 발송 루프 안에서 개별 DB UPDATE를 하지 말고, Kafka 등으로 비동기 벌크 처리하자.
  4. 단순 문자열을 Kafka로 보낼 때는 StringSerializer를 써야 이중 인용부호 문제가 안 생긴다.