본문으로 바로가기
기술

푸시 알림 통합 가이드 — Web Push·FCM·APNs를 하나의 백엔드로

A
AlwaysCorp 기술팀· 기술·개발 콘텐츠 전문
||11분 읽기
#푸시알림#FCM#APNs#WebPush#VAPID#ServiceWorker#iOS#Android#백엔드#Notification

왜 통합이 어려운가

알림 하나 보내는 데 세 가지 다른 시스템을 다뤄야 합니다. iOS는 APNs(Apple Push Notification service), Android는 FCM(Firebase Cloud Messaging), 브라우저는 표준 Web Push. 각각 인증 방식·페이로드 형식·재시도 정책이 다릅니다. 한쪽만 만들면 한쪽 사용자가 알림을 못 받고, 따로따로 만들면 운영 부담이 두 배 세 배가 됩니다.

핵심 통찰은 단순합니다. 백엔드는 하나의 API(POST /push/send)로 추상화하고, 내부에서 사용자별 디바이스 토큰을 조회해 각 채널로 fan-out하는 구조입니다.

통합 푸시 백엔드 아키텍처 — 하나의 API에서 세 채널로 분기

데이터 모델 — 하나만 외우면 된다

CREATE TABLE device_tokens (
  id            uuid PRIMARY KEY,
  user_id       uuid NOT NULL REFERENCES users(id),
  platform      text NOT NULL CHECK (platform IN (''ios'',''android'',''web'')),
  token         text NOT NULL,
  locale        text,
  app_version   text,
  created_at    timestamptz NOT NULL DEFAULT now(),
  last_seen_at  timestamptz,
  UNIQUE (platform, token)
);
CREATE INDEX ON device_tokens(user_id);

이 테이블 하나면 모든 추적이 가능합니다. 사용자가 로그아웃하면 토큰을 삭제하고, 푸시 전송 시 Unregistered·InvalidToken 응답을 받으면 즉시 정리합니다. 청소를 게을리하면 6개월 뒤 토큰의 30~40%가 죽은 채로 남아 매일 헛고생을 합니다.

Web Push — VAPID 키만 있으면 끝

Web Push는 정말 깔끔한 표준입니다. 서비스 워커가 등록되고, VAPID 공개키로 구독을 받고, 백엔드가 비공개키로 서명한 요청을 푸시 서비스(Mozilla·Google이 운영)로 보냅니다. 별도의 SDK 없이 web-push npm 패키지 하나면 됩니다.

// 클라이언트
const reg = await navigator.serviceWorker.register("/sw.js");
const sub = await reg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: VAPID_PUBLIC_KEY,
});
await fetch("/api/push/register", { method: "POST", body: JSON.stringify(sub) });
// 서버
import webpush from "web-push";
webpush.setVapidDetails("mailto:[email protected]", VAPID_PUBLIC, VAPID_PRIVATE);
await webpush.sendNotification(subscription, JSON.stringify({
  title: "새 댓글",
  body: "당신의 글에 댓글이 달렸어요",
  url: "/posts/123",
}));

서비스 워커의 push 이벤트에서 페이로드를 파싱하고 showNotification을 호출하면 끝입니다. iOS Safari 16.4+가 PWA 컨텍스트에서 Web Push를 지원하면서 마지막 퍼즐도 채워졌습니다.

FCM — Android와 iOS 둘 다

FCM은 양면성이 있습니다. Android는 FCM 자체가 채널이고, iOS는 FCM이 내부적으로 APNs로 전달해주는 어댑터 역할입니다. iOS-only 앱이 아니라면 FCM 한 군데로 통일하는 게 가장 단순합니다.

서버 키는 더 이상 사용 안 됩니다(2024년 폐기). Service Account JSON으로 OAuth2 액세스 토큰을 발급받아 HTTP v1 API를 호출합니다.

const accessToken = await getGoogleAccessToken();  // OAuth2
await fetch(`https://fcm.googleapis.com/v1/projects/${PROJECT}/messages:send`, {
  method: "POST",
  headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    message: {
      token: deviceToken,
      notification: { title, body },
      data: { url: "/posts/123" },
      android: { priority: "HIGH" },
      apns: { headers: { "apns-priority": "10" } },
    },
  }),
});

APNs 직접 — 정말 필요할 때만

iOS-only이고 FCM 어댑터를 거치고 싶지 않다면 APNs HTTP/2 직접 호출이 가능합니다. p8 키로 ES256 JWT를 만들어 헤더에 붙입니다. 라이브러리는 node-apn 또는 @parse/node-apn이 표준입니다. 다만 인증 갱신·연결 풀 관리 부담이 있어 보통은 FCM이 노력 대비 효율이 좋습니다.

페이로드 통일 — 사용자가 만지는 건 한 가지

플랫폼별 페이로드 키가 다 다른데, 백엔드는 그걸 다 신경쓰지 말고 공통 인터페이스에서 변환합니다.

type PushMessage = {
  title: string;
  body: string;
  data?: Record<string, string>;
  url?: string;
  badge?: number;
  icon?: string;
};

async function send(userId: string, msg: PushMessage) {
  const tokens = await db.deviceTokens.findMany({ where: { userId } });
  await Promise.allSettled(tokens.map((t) => {
    if (t.platform === "web")     return sendWebPush(t.token, msg);
    if (t.platform === "android") return sendFcm(t.token, msg);
    if (t.platform === "ios")     return sendApns(t.token, msg);
  }));
}

Promise.allSettled가 핵심입니다. 한 채널이 죽어도 다른 채널은 멈추지 않습니다.

운영 체크리스트

  • Quiet hours — 사용자 타임존 기준 23~07시 차단
  • Rate limit — 사용자당 분당 N건, 24시간 M건 상한
  • Click trackingdata.url에 추적 파라미터(?utm_source=push) 자동 부착
  • A/B test — 제목 A/B를 분기해서 오픈율 비교
  • Deliverability monitoring — APNs BadDeviceToken·FCM Unregistered 응답 즉시 토큰 삭제

다음 단계

푸시는 잘 보내는 것보다 언제·왜 보내는지 결정하는 게 진짜 어려운 일입니다. 트랜잭션(주문 완료 등)은 실시간, 마케팅은 사용자 활성 시간대 큐잉, 알람·뉴스는 사용자 토픽 구독 모델로. 운영하면서 오픈율·언인스톨률을 함께 보면 자연스럽게 균형점이 잡힙니다.

A

AlwaysCorp 기술팀

기술·개발 콘텐츠 전문

얼웨이즈 블로그에서 유용한 정보와 인사이트를 공유합니다.