푸시 알림: 보냈다고 도착한 건 아니다
푸시 발송과 도착의 분리, 만료 토큰 정리, 대량 발송 배치 전략. 비동기 알림 파이프라인의 함정을 정리한다.
푸시 알림: 보냈다고 도착한 건 아니다
푸시 알림 발송 기능을 다룬 주가 있었다. 가장 먼저 깨야 할 환상은 “API가 200을 줬으니 사용자가 받았다”는 생각이다. 푸시는 본질적으로 비동기이고, 발송과 도착 사이엔 여러 실패 지점이 있다. 알림 파이프라인을 제대로 설계하려면 이 구간을 분리해서 봐야 한다.
발송 ≠ 도착
전형적인 푸시 경로는 이렇다.
flowchart LR
A[앱 서버] -->|발송 요청| B[푸시 게이트웨이<br/>FCM/APNs]
B -->|전달| C[디바이스 OS]
C -->|표시| D[사용자]
B -.수락 응답.-> A
게이트웨이가 주는 응답은 “요청을 수락했다“는 의미지, “디바이스에 도착했다“가 아니다. 그 사이에서 일어나는 일:
- 토큰이 만료/무효 → 게이트웨이가 거부.
- 디바이스가 오프라인 → 게이트웨이가 잠시 보관 후 폐기(TTL).
- 사용자가 알림 권한 끔 → OS가 조용히 버림.
그래서 도착·열람을 알고 싶으면 별도의 수신 확인(앱이 받은 시점에 서버로 ack) 또는 딥링크 클릭 추적이 필요하다. 발송 로그만으로 도달률을 논하면 안 된다.
토큰 관리
푸시 토큰은 영구적이지 않다. 앱 재설치, OS 업데이트, 장기간 미사용 시 무효화된다. 무효 토큰으로 계속 발송하면 게이트웨이가 에러를 반환하고, 누적되면 발송 효율과 평판이 떨어진다.
원칙은 게이트웨이의 응답을 보고 토큰을 정리하는 것이다. UNREGISTERED·InvalidRegistration 같은 영구 실패 응답이 오면 그 토큰을 즉시 비활성화한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void send(List<DeviceToken> tokens, PushMessage msg) {
for (List<DeviceToken> chunk : partition(tokens, 500)) { // 멀티캐스트 배치 단위
BatchResponse res = pushClient.sendMulticast(chunk, msg);
for (int i = 0; i < res.getResponses().size(); i++) {
SendResult r = res.getResponses().get(i);
DeviceToken t = chunk.get(i);
if (r.isPermanentlyFailed()) {
tokenRepository.deactivate(t.getId()); // 무효 토큰 제거
} else if (r.isRetryable()) {
retryQueue.enqueue(t, msg); // 일시 실패는 재시도 큐로
}
}
}
}
대량 발송 배치
수십만 명에게 한 번에 보낼 때 토큰을 하나씩 동기 호출하면 끝나지 않는다. 두 가지가 핵심이다.
- 멀티캐스트 묶음: 게이트웨이는 한 요청에 여러 토큰을 받는 멀티캐스트 API를 제공한다(보통 수백 개 단위 상한). 묶어 보낸다.
- 발송과 생성의 분리: 알림을 만드는 트랜잭션과 실제 발송을 분리한다. 알림 이벤트를 큐에 넣고, 워커가 꺼내 배치 발송한다. 발송이 느리거나 게이트웨이가 죽어도 원 트랜잭션은 영향받지 않는다.
운영 함정
- 재시도 무한 루프: 영구 실패(무효 토큰)를 재시도 가능 실패로 오분류하면 같은 토큰을 끝없이 재시도한다. 영구/일시 실패를 응답 코드로 정확히 구분해야 한다.
- 발송을 요청 스레드에서 동기 처리: API 응답이 게이트웨이 지연에 묶인다. 발송은 비동기 큐로 빼고, 사용자 요청은 “접수됨”만 빠르게 응답한다.
핵심 요약
- 게이트웨이의 200은 “수락”이지 “도착”이 아니다 — 도달 확인은 앱 ack로 따로 받는다.
- 영구 실패 응답이 오면 해당 토큰을 즉시 비활성화한다.
- 대량 발송은 멀티캐스트 + 비동기 큐로, 발송을 원 트랜잭션에서 분리한다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.