codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
에듀›PostgreSQL 깊게 다루기 + Redis · Kafka›10단계

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

← 9단계

9단계 — Kafka 토픽 설계

🎉 PostgreSQL 깊게 다루기 + Redis · Kafka 완주를 축하해요

이어서 어떤 걸 배워 볼까요?

다음: 공공데이터 크롤러 만들기 →전체 강좌 둘러보기