10단계
10단계 — 푸시 알림 구현
0회 조회
10단계 — 푸시 알림 구현
9단계까지 PostgreSQL · Redis · Kafka 로 데이터 플랫폼의 뼈대를 잡았습니다. 이번에는 그 위에 사용자에게 직접 닿는 기능 하나를 올립니다 — 푸시 알림. 앱이 닫혀 있어도 메시지를 보내는 일은 결국 OS·브라우저의 푸시 채널을 거쳐야 하고, 서버는 토큰을 저장하고 발송하고 실패를 정리하는 작은 파이프라인이 됩니다. Redis 의 sliding TTL 과 Kafka 의 이벤트가 여기서 다시 등장합니다.
1. Firebase 셋업 — 개요만
코드를 짜기 전에 한 번 해 두는 설정입니다.
- Firebase 콘솔에서 프로젝트를 만들고 클라이언트 앱(Android · iOS · Web)을 등록합니다.
- 서비스 계정 키 (JSON) 를 발급합니다. 이것이 서버가 FCM 을 호출할 자격입니다.
- 이 JSON 은 시크릿입니다. 코드·저장소에 넣지 말고 환경 변수나 시크릿 매니저로 주입합니다.
import { initializeApp, cert } from "firebase-admin/app";
import { getMessaging } from "firebase-admin/messaging";
initializeApp({
credential: cert(JSON.parse(process.env.FCM_SERVICE_ACCOUNT!)),
});
firebase-admin SDK 가 OAuth2 토큰 갱신과 HTTP v1 호출을 감춰 줍니다. 우리가 다룰 표면은 "토큰 + 메시지" 둘뿐입니다.
2. 클라이언트 토큰 등록
푸시는 사용자 동의에서 시작합니다. 클라이언트가 권한을 요청하고, 허용되면 OS·브라우저가 토큰을 발급합니다. 토큰은 앱·기기마다 고유합니다.
// 클라이언트 — 토큰을 받아 서버에 등록
const token = await getToken(messaging, { vapidKey });
await fetch("/api/notifications/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
서버는 이 토큰을 사용자와 묶어 저장합니다. 등록 엔드포인트는 멱등해야 합니다 — 같은 토큰이 다시 와도 중복 적재되지 않게 합니다. 토큰이 OS 에 의해 갱신되면 클라이언트가 다시 등록을 호출하므로, 서버는 들어온 토큰을 그대로 최신값으로 받아들이면 됩니다.
3. 서버 토큰 저장 — Redis sliding TTL
토큰을 어디에 둘지가 다음 결정입니다. 한 사용자가 폰·태블릿·웹을 함께 쓰면 토큰이 여러 개이므로, 사용자 키 아래 토큰 묶음을 둡니다.
const key = `notif:token:${userId}`;
const TTL = 60 * 60 * 24 * 60; // 60일
async function registerToken(userId: string, token: string) {
await redis.sadd(key, token); // Set — 기기별 토큰 모음
await redis.expire(key, TTL); // 쓸 때마다 TTL 갱신 = sliding
}
- sliding TTL — 토큰을 등록하거나 발송에 쓸 때마다
expire로 TTL 을 다시 늘립니다. 60일간 앱을 열지 않은 사용자의 토큰은 자연히 만료돼 사라집니다. 별도 정리 배치가 필요 없습니다. - DB 이중 저장 — Redis 는 휘발성입니다. 같은 토큰을 영구 테이블에도 적재해 두면 Redis 가 비워져도 복구됩니다. Redis 는 캐시, DB 는 원본.
CREATE TABLE IF NOT EXISTS device_tokens (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, token)
);
PRIMARY KEY (user_id, token) 가 같은 기기 토큰의 중복 행을 막습니다. 1단계 강좌의 인덱스, 4단계의 Redis 역할이 그대로 이어집니다.
4. 발송 — 그리고 실패 코드 처리
발송은 사용자의 토큰을 모두 꺼내 메시지를 보내는 일입니다. 핵심은 결과를 읽고 실패 코드별로 다르게 처리하는 데 있습니다.
async function sendToUser(userId: string, title: string, body: string) {
const tokens = await redis.smembers(`notif:token:${userId}`);
if (tokens.length === 0) return;
const res = await getMessaging().sendEachForMulticast({
tokens, // 한 호출 최대 500개
notification: { title, body },
});
res.responses.forEach((r, idx) => {
if (r.success) return;
handleFailure(userId, tokens[idx], r.error);
});
}
실패는 한 갈래가 아닙니다.
| FCM 에러 코드 | 처리 |
|---|---|
UNREGISTERED |
앱 삭제·만료 → 토큰 영구 삭제 |
INVALID_ARGUMENT |
payload 오류 → 재시도 금지, 코드 수정 |
QUOTA_EXCEEDED · UNAVAILABLE |
일시 장애 → 지수 백오프 재시도 |
function handleFailure(userId: string, token: string, error?: { code: string }) {
if (error?.code === "messaging/registration-token-not-registered") {
redis.srem(`notif:token:${userId}`, token); // 죽은 토큰 정리
db.query("DELETE FROM device_tokens WHERE token = $1", [token]);
return;
}
if (error?.code === "messaging/invalid-argument") {
log.error("payload 오류 — 재시도 금지", { token });
return; // 재시도해도 무한 실패
}
enqueueRetry(userId, token); // 일시 장애 → 백오프 재시도
}
위 error.code 값에 주의합니다. firebase-admin Node SDK 는 이 에러들을 messaging/... 접두 문자열로 보고합니다 (예: messaging/registration-token-not-registered). FCM 자체 에러 코드(UNREGISTERED 등)와 값이 다르므로, SDK 코드를 다룰 때는 messaging/ 접두 문자열로 비교해야 합니다 — error.code === "UNREGISTERED" 는 영영 일치하지 않습니다.
죽은 토큰을 방치하면 발송 통계가 망가지고 호출 한도를 낭비합니다. 발송할 때마다 죽은 토큰이 함께 정리되는 흐름을 만들어 둡니다.
5. 일회성 소비 — 중복 발송 막기
가입 환영, 비밀번호 변경 통지 같은 일회성 알림은 한 번만 가야 합니다. 재시도 로직·중복 이벤트가 같은 알림을 두 번 집어 보내는 사고를 막으려면, 발송에 성공한 토큰 항목을 즉시 소비(삭제)합니다.
async function sendOnce(userId: string, title: string, body: string) {
const key = `notif:onetime:${userId}:welcome`;
const claimed = await redis.set(key, "1", "NX", "EX", 86400);
if (!claimed) return; // 이미 누군가 발송함 — 스킵
await sendToUser(userId, title, body);
}
SET ... NX 는 키가 없을 때만 성공합니다. 두 번째 호출은 NX 에 걸려 발송을 건너뜁니다. 반복 알림(채팅·뉴스)은 소비하지 않고 토큰을 유지합니다 — 계속 보내야 하기 때문입니다.
6. 비동기 발송 — 요청을 막지 않기
수천 명에게 보내는 발송을 HTTP 요청 처리 도중에 동기로 돌리면 응답이 그만큼 늦습니다. 발송은 요청에서 분리합니다.
HTTP 요청 → 발송 작업을 큐에 적재 → 즉시 202 응답
↓
워커가 큐에서 꺼내 멀티캐스트 발송 · 실패 토큰 정리
여기서 6단계의 Kafka 가 자연스럽게 들어옵니다. "주문 완료" · "댓글 달림" 같은 도메인 이벤트가 토픽으로 흐르고, 알림 컨슈머가 그 이벤트를 받아 발송합니다. 발송을 토픽 컨슈머로 두면 요청 경로와 완전히 분리되고, 7단계의 멱등 처리로 같은 이벤트가 두 번 와도 알림은 한 번만 갑니다.
작은 규모라면 메시지 큐 없이 @Async(Spring) 나 백그라운드 워커(Node) 만으로도 충분합니다. 요청은 "발송 접수됨"만 빠르게 응답하고, 실제 전송은 뒤에서 진행합니다.
자주 걸리는 자리
- 서비스 계정 키를 코드에 포함 — 시크릿. 환경 변수·시크릿 매니저로 주입
- 죽은 토큰 미정리 —
UNREGISTERED를 무시하면 발송 통계가 망가지고 호출 한도 낭비 - 모든 실패를 재시도 —
INVALID_ARGUMENT는 코드 문제. 재시도해도 무한 실패 - 500개 초과 멀티캐스트 —
sendEachForMulticast는 한 호출 500개 한도. 초과하면 분할 - 일회성 알림 미소비 — 재시도·중복 이벤트로 같은 알림이 두 번.
SET NX로 한 번만 - 요청 스레드에서 동기 발송 — 대량 발송이 응답을 막음. 큐·비동기로 분리
하고픈 말
푸시는 작아 보여도 토큰 저장 · 발송 · 실패 정리 · 중복 방지가 모두 얽힌 작은 파이프라인입니다. 이 강좌에서 익힌 Redis TTL · Kafka 이벤트 · 멱등 처리가 그대로 재료가 됩니다. 기능 하나가 플랫폼의 여러 조각을 어떻게 엮는지 보는 마지막 단계입니다.
Next
- data/09-fcm-push
- data/06-kafka-when
🎉 PostgreSQL 깊게 다루기 + Redis · Kafka 완주를 축하해요
이어서 어떤 걸 배워 볼까요?