목록으로

수십만 명에게 푸시를 보내려면

6

수십만 명에게 푸시를 보내려면

2025-01-09 ~ 2026-03-09

문제 상황

관리자가 전체 사용자(수십만 명)에게 이벤트 푸시 알림을 보내야 한다. Firebase Cloud Messaging(FCM)을 쓰고 있는데, 단건 발송으로는 시간이 너무 오래 걸린다.

게다가 요구사항이 까다롭다.

  1. 발송 도중 취소할 수 있어야 한다
  2. 밤 7시 이후에는 발송을 멈춰야 한다 (정보통신망법)
  3. 서버가 중간에 죽어도 이어서 발송할 수 있어야 한다
  4. 어떤 사용자에게 실패했는지 기록해야 한다

단순히 for문으로 돌리면 해결할 수 없는 문제들이다.


Firebase Multicast API

Firebase는 한 번에 최대 500개 토큰에 동시 발송하는 sendEachForMulticast() API를 제공한다. → 멀티캐스트(Multicast)란 하나의 메시지를 여러 수신자에게 동시에 보내는 방식이다. 일일이 한 명씩 보내는 것(유니캐스트)과 달리 한 번의 호출로 여러 명에게 전달할 수 있다. 500건을 한 번의 HTTP 호출로 처리하니까, 단건 발송보다 훨씬 빠르다.

30만 명 ÷ 500건 = 600번의 API 호출
vs
30만 명 × 1건 = 30만 번의 API 호출

그래서 사용자 목록을 500명씩 잘라서 배치로 보내는 구조를 만들었다.


shouldStop — 매 배치마다 "멈출까?" 물어보기

핵심 아이디어는 500건을 보내기 전에 매번 "지금 멈춰야 하나?"를 체크하는 것이다.

private static final int BATCH_SIZE = 500;

for (int i = 0; i < users.size(); i += BATCH_SIZE) {
    // 매 배치 전에 중단 체크
    if (shouldStop.get()) {
        log.info("발송 중단됨: {}/{}", i, users.size());
        break;
    }

    List<String> batchTokens = users.subList(i, Math.min(i + BATCH_SIZE, users.size()));
    BatchResponse response = FirebaseMessaging.getInstance()
            .sendEachForMulticast(buildMessage(batchTokens, title, body));
    // 결과 처리...
}

shouldStopSupplier<Boolean> 타입이다. → Supplier<Boolean>은 Java의 함수형 인터페이스로, 파라미터 없이 호출하면 Boolean 값을 반환하는 "조건 판별기" 역할을 한다. 람다식으로 구현할 수 있어서, 조건 로직을 외부에서 자유롭게 주입할 수 있다. 호출하는 쪽에서 "무엇을 체크할지"를 주입한다.

Supplier<Boolean> shouldStop = () -> {
    // 1. 관리자가 취소 버튼을 눌렀는가?
    if (pushPolicy.shouldStop(requestId)) return true;
    // 2. 지금 밤 7시~아침 9시인가?
    return pushPolicy.checkNightTimePolicy(policy) != CONTINUE;
};

이 설계의 장점은 FcmMulticastService는 "왜 멈추는지" 몰라도 된다는 것이다. 멈춰야 하는지만 물어보고, 멈춰야 하면 멈춘다. 취소 로직이 바뀌거나, 야간 정책이 바뀌어도 FCM 발송 코드는 수정할 필요가 없다.


야간 발송 제한 정책

정보통신망법에 따르면 광고성 푸시는 밤 9시아침 8시에 발송하면 안 된다. 우리는 더 보수적으로 밤 7시아침 9시로 잡았다.

문제는 "밤 7시에 어떻게 할 것인가"다. 세 가지 정책을 만들었다.

정책동작
NONE24시간 발송 (비광고 알림)
STOP_TODAY7시에 중단, 완료 처리 (나머지 미발송)
RESUME_NEXT_DAY7시에 일시정지, 다음날 9시에 자동 재개

RESUME_NEXT_DAY가 가장 까다로웠다. "지금까지 보낸 데까지 기억하고, 내일 이어서 보내라"는 뜻이니까.

public NightTimeAction checkNightTimePolicy(NightTimePolicy policy) {
    int hour = LocalTime.now().getHour();
    boolean isNightTime = hour >= 19 || hour < 9;

    if (!isNightTime) return CONTINUE;

    return switch (policy) {
        case NONE -> CONTINUE;           // 무시
        case STOP_TODAY -> STOP;          // 중단 후 완료 처리
        case RESUME_NEXT_DAY -> PAUSE;   // 일시정지
    };
}

일시정지된 요청은 재개 스케줄러가 매일 아침 9시에 자동으로 재개하도록 개발했다.


FCM 에러 분류와 재시도

500건을 보내면 일부는 성공하고 일부는 실패한다. Firebase는 토큰별로 성공/실패를 알려주는데, 실패 이유가 다양하다.

영구 에러 — 재시도해도 안 되는 것

  • UNREGISTERED: 앱이 삭제되었거나 토큰이 만료됨
  • INVALID_ARGUMENT: 토큰 형식 자체가 잘못됨

일시적 에러 — 잠시 후 다시 하면 될 수 있는 것

  • INTERNAL: Firebase 내부 오류
  • UNAVAILABLE: Firebase 서버 과부하
  • QUOTA_EXCEEDED: 발송 쿼터 초과 (쿼터란 일정 시간 내에 사용할 수 있는 API 호출 횟수의 상한선을 말한다)

일시적 에러는 1초 대기 후 1회 재시도한다.

if (!transientRetryUsers.isEmpty()) {
    Thread.sleep(1000);  // 1초 대기
    BatchResponse retryResponse = FirebaseMessaging.getInstance()
            .sendEachForMulticast(buildMessage(retryTokens, title, body));
    // 재시도 결과 반영
    totalSuccess += retryResponse.getSuccessCount();
    totalFail -= retryResponse.getSuccessCount();
}

1회만 재시도하는 이유는, 대규모 발송 중에 과도한 재시도를 하면 전체 발송 시간이 너무 길어지기 때문이다. 영구 에러로 판단된 토큰은 별도 실패 기록 테이블에 저장하고 넘어간다.


전체 흐름 요약

관리자가 "전체 발송" 클릭
  ↓
푸시 서비스: 30만 사용자를 5000명씩 조회
  ↓ (반복)
  ├── 5000명 알림함 INSERT (DB 배치)
  ├── pushkey 있는 사용자만 필터링
  ├── FCM 발송 서비스: 500명씩 FCM 발송
  │   ├── shouldStop 체크 → 멈춰야 하면 중단
  │   ├── 영구 에러 → 실패 기록
  │   ├── 일시적 에러 → 1초 후 재시도
  │   └── 발송 결과 DB 업데이트
  └── 다음 5000명 조회
  ↓
완료 / 일시정지 / 취소
  ↓
Telegram으로 결과 알림

배운 것

  • Supplier 패턴으로 "중단 조건"을 주입하면 발송 로직과 정책 로직을 깔끔하게 분리할 수 있다
  • FCM 에러를 영구/일시적으로 분류해서 재시도 전략을 다르게 가져가야 한다
  • 대규모 발송은 "멈출 수 있는가"가 "빠른가"보다 더 중요하다 — 한번 시작하면 멈출 수 없는 시스템은 운영이 불가능하다