왜 통합이 어려운가
알림 하나 보내는 데 세 가지 다른 시스템을 다뤄야 합니다. iOS는 APNs(Apple Push Notification service), Android는 FCM(Firebase Cloud Messaging), 브라우저는 표준 Web Push. 각각 인증 방식·페이로드 형식·재시도 정책이 다릅니다. 한쪽만 만들면 한쪽 사용자가 알림을 못 받고, 따로따로 만들면 운영 부담이 두 배 세 배가 됩니다.
핵심 통찰은 단순합니다. 백엔드는 하나의 API(POST /push/send)로 추상화하고, 내부에서 사용자별 디바이스 토큰을 조회해 각 채널로 fan-out하는 구조입니다.
데이터 모델 — 하나만 외우면 된다
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 tracking —
data.url에 추적 파라미터(?utm_source=push) 자동 부착 - A/B test — 제목 A/B를 분기해서 오픈율 비교
- Deliverability monitoring — APNs
BadDeviceToken·FCMUnregistered응답 즉시 토큰 삭제
다음 단계
푸시는 잘 보내는 것보다 언제·왜 보내는지 결정하는 게 진짜 어려운 일입니다. 트랜잭션(주문 완료 등)은 실시간, 마케팅은 사용자 활성 시간대 큐잉, 알람·뉴스는 사용자 토픽 구독 모델로. 운영하면서 오픈율·언인스톨률을 함께 보면 자연스럽게 균형점이 잡힙니다.