웹 푸시 알림 붙이기
배경
블로그에 좋아요랑 댓글 기능은 진작에 붙여뒀는데 정작 누가 좋아요를 누르거나 댓글을 달아도 제가 알 방법이 없었어요. 어쩌다가 댓글을 발견하거나 좋아요 수가 늘어난 걸 확인하는 식이었죠.
그래서 알림을 받고 싶었어요. 처음엔 슬랙이나 텔레그램 웹훅을 생각했는데, 이왕이면 알림용으로 앱을 추가하지 않고 최대한 자연스럽게 받고 싶더라고요. 그래서 웹 푸시로 정했어요.
현재 블로그 운영 방식상 좋아요랑 댓글은 익명이라 방문자한테는 알림을 보낼 수가 없어요. 그렇다고 이걸 위해 로그인을 필요로 한다거나 이메일 등을 받는 것도 과하다고 생각해요.
따라서 알림은 저(블로그 주인장)한테만 오는 구조면 이 시점에서는 적당하다고 판단했어요.
웹 푸시란
웹 푸시는 웹사이트가 사용자 브라우저로 알림을 보내는 기술이에요. 사용자가 해당 사이트를 열어두지 않아도 백그라운드에서 도는 Service Worker가 알림을 받아서 OS 알림으로 띄워줘요. 네이티브 앱이 보내는 푸시 알림이랑 거의 같은 경험을 웹에서 하는 셈이에요.
그 사이트 탭을 닫고 다른 일을 하고 있어도 브라우저가 백그라운드에 떠 있는 한 알림이 와요. 프로세스가 살아 있으니까요! 대신 아무나 보낼 수 있으면 곤란하니까 사용자가 알림 권한을 직접 허용해야 하고 서버도 자기가 등록된 발신자라는 걸 푸시 서비스에 증명해야 해요. 이 증명을 담당하는 게 VAPID예요.
VAPID(Voluntary Application Server Identification)는 서버가 공개 키와 비공개 키 한 쌍을 만들어두고 푸시를 보낼 때마다 비공개 키로 서명한 토큰을 함께 실어 보내요. 그러면 푸시 서비스(FCM 같은)가 그 사이트의 공개 키로 서명을 확인해서 진짜 등록된 발신자인지 판별해요. 이게 없으면 누군가 구독 endpoint만 알아내도 그 사람한테 마음대로 푸시를 보낼 수 있으니까요.
그럼 실제로 어떻게 동작하는지 단계별로 적어볼게요.
- 브라우저가 푸시 서비스에 구독을 요청해요. 그러면 endpoint URL이랑 암호화에 쓸 공개 키(p256dh)와 인증 시크릿(auth)이 나와요.
- 이 구독 정보를 서버가 저장해요.
- 알림을 보낼 때는 서버가 이
endpoint로 암호화된 페이로드를 POST해요. VAPID라는 방식으로 "나는 등록된 발신자야"를 증명하고요. - 브라우저의 Service Worker가 푸시를 받아서 알림을 띄워요.
여기서 중요한 건 구독이 브라우저나 기기마다 따로라는 거예요. 즉, 크롬에서 구독한 거랑 사파리에서 구독한 건 완전히 별개예요. 크롬·엣지는 구글 FCM, 파이어폭스는 Mozilla, 사파리는 애플 APNs로 각각 다른 endpoint가 발급되거든요.
브라우저에서 구독하기
브라우저가 구독하는 과정은 크게 세 단계예요. 알림 권한을 받고, Service Worker를 등록하고, 구독하는 거예요.
// 1. 알림 권한 요청
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
// 2. Service Worker 등록
const reg = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
// 3. 구독 생성
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});pushManager.subscribe에 넘기는 applicationServerKey가 앞에서 얘기한 VAPID 공개 키예요. userVisibleOnly: true는 "푸시를 받으면 반드시 눈에 보이는 알림을 띄우겠다"는 약속이에요. 이걸 안 지키면 브라우저가 구독을 거부해요. 몰래 백그라운드 추적하는 용도로 못 쓰게 막는 장치죠.
subscribe가 돌려주는 구독 객체를 JSON으로 바꾸면 서버에 저장할 값이 나와요.
sub.toJSON();
// { endpoint: "https://fcm.googleapis.com/...", keys: { p256dh: "...", auth: "..." } }이걸 서버로 보내서 D1에 저장해요. 이 블로그에서는 구독을 저만 하니까 admin 페이지에 "알림 받기" 버튼 하나를 뒀어요.
반대로 실제 알림을 화면에 띄우는 쪽은 Service Worker(public/sw.js)예요. 서버가 보낸 푸시가 도착하면 push 이벤트가 뜨고 여기서 알림을 표시해요.
self.addEventListener("push", (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: "/logo.svg",
data: { url: data.url },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow(event.notification.data.url));
});알림을 클릭하면 notificationclick에서 해당 글로 이동하게 해뒀어요.
구독을 만들어 서버에 넘기고 푸시가 오면 Service Worker가 알림을 띄우는 거죠. 남은 건 서버가 실제 푸시를 보내는 부분이에요.
서버에서 발송하기
관건은 발송이었어요. 웹 푸시 페이로드는 JSON을 그대로 보내는 게 아니라 RFC 8291이라는 규격대로 암호화를 해야 해요.
RFC 8291(Message Encryption for Web Push)은 웹 푸시 메시지를 어떻게 암호화할지 정해둔 표준이에요. 푸시는 브라우저로 바로 가는 게 아니라 FCM 같은 푸시 서비스를 한 번 거치는데 중간에서도 내용을 못 읽게 하는 게 핵심이에요. 그래서 구독할 때 받아둔 공개 키(p256dh)와 인증 시크릿(auth)으로 페이로드를 암호화해서 발신 서버와 구독된 브라우저만 내용을 볼 수 있게 만들어요.
이런 표준 암호화는 물론이고 VAPID 서명 등 웹 푸시 발송을 간단하게 사용할 수 있도록 잘 만들어진 라이브러리가 이미 많아서 그중 하나를 사용하려 했어요.
여기서 첫 번째 이슈가 있었어요. 제일 유명한 web-push 패키지는 Node.js의 crypto 모듈을 쓰는데, 이 블로그는 Cloudflare Pages(next-on-pages)의 Edge Runtime에서 동작해서 Node crypto를 쓸 수 없어요. 예전에 댓글 비밀번호를 해싱할 때도 겪었던 제약이에요.

그래서 Web Crypto API 기반으로 동작하는 @pushforge/builder를 골랐어요. 사용법도 깔끔했어요.
const { endpoint, headers, body } = await buildPushHTTPRequest({
privateJWK,
subscription,
message: {
payload: { title: "새 좋아요", body: "..." },
adminContact: "mailto:...",
},
});
await fetch(endpoint, { method: "POST", headers, body });로컬에서 실제 구독으로 테스트해봤더니 FCM이 201 Created를 반환했어요. 알림도 잘 떴고요. 모든 게 순조롭다고 생각했어요.
시행착오
로컬에선 되는데 배포하면 안 와요
배포하고 댓글을 달았는데 알림이 안 왔어요.
발송은 ctx.waitUntil 안에서 처리하고 실패해도 catch에서 조용히 넘어가게 해뒀거든요. 응답을 지연시키지 않으려고 그렇게 짰는데 이것 때문에 뭐가 잘못됐는지 화면에는 아무것도 안 나왔어요. 그나마 catch에서 로그를 남겨둔 게 다행이었죠.
wrangler pages deployment tail로 실시간 로그를 켜고 좋아요를 한 번 눌러봤어요.
POST /api/likes - Ok
(error) {"at":"push/send","message":"Illegal invocation: function called with incorrect `this` reference..."}
Illegal invocation 에러가 원인이었어요. 로컬 node에서는 201이 떴는데, 배포 환경에서만 이 에러가 난 거예요.
Illegal invocation의 정체
이 에러는 Cloudflare Workers에서 종종 나오는데 함수가 자기 this를 잃어버렸을 때 나요.
원인은 번들링이었어요. next-on-pages는 코드를 esbuild로 번들해서 Cloudflare에 올리는데 이 과정에서 crypto.subtle 같은 전역 Web API를 변수나 객체 프로퍼티에 담아두면 원래 this 연결이 끊겨요. crypto.subtle.importKey(...)처럼 바로 호출하면 괜찮은데, const s = crypto.subtle; s.importKey(...)처럼 담아서 호출하면 실패하는 거예요.
// @pushforge/builder 내부
export const crypto = {
getRandomValues(array) { ... },
subtle: isomorphicCrypto.subtle, // 여기
};subtle을 객체 프로퍼티에 담아두고, 사용처에서 crypto.subtle.importKey(...)로 호출해요. 로컬 node에서는 문제가 없는데 esbuild 번들 위에서는 여기서 에러가 발생하는 거예요.
처음에는 pnpm patch로 라이브러리를 직접 고쳐봤어요. subtle 메서드를 래퍼로 감싸보고, 아예 globalThis.crypto.subtle을 직접 호출하도록 바꿔보기도 했어요. 그런데 로컬에서 빌드한 번들엔 패치가 들어가는데 배포한 번들에선 에러가 그대로였어요. 한참 뒤에야 원인을 찾았어요. package.json에 packageManager로 pnpm 버전을 고정해두지 않으면 Cloudflare가 patch를 반영하지 않는 기본 pnpm으로 빌드하고 있었던 거예요. 그래서 버전을 고정했더니 이번엔 CI에서 pnpm 버전이 충돌했어요.
globalThis로 직접 호출하는 방향은 맞았어요. 정작 고치면 되는 건 한 줄인데 그걸 라이브러리 안에서 배포까지 동작시키려고 pnpm 버전이나 CI 설정을 건드는 게 배보다 배꼽이 더 크다고 생각했어요. 게다가 이렇게 적용한 patch는 라이브러리를 올리거나 빌드 환경이 바뀌면 또 깨질 가능성이 높았고요.
결국 직접 구현했어요
방향을 바꿨어요. 라이브러리를 걷어내고 발송 로직을 직접 짜기로 했어요. 어차피 필요한 건 두 가지예요.
- VAPID JWT 서명: 발신자 신원을 증명하는 토큰 (ES256)
- 페이로드 암호화: RFC 8291 규격의 aes128gcm (GCM 모드로 128비트 AES 대칭키 암호화)
그리고 에러를 없앨 규칙을 추가했어요. 모든 crypto 호출을 globalThis.crypto.subtle로 인라인 직접 호출한다.
VAPID 토큰은 이런 식으로 만들어요.
async function vapidAuth(endpoint, privateJwk, subject) {
const { protocol, host } = new URL(endpoint);
const header = bytesToB64url(utf8(JSON.stringify({ typ: "JWT", alg: "ES256" })));
// 내용: 수신 푸시 서비스(aud), 만료 시각(exp, 12시간), 발신자 연락처(sub)
const exp = Math.floor(Date.now() / 1000) + 12 * 3600;
const payload = bytesToB64url(
utf8(JSON.stringify({ aud: `${protocol}//${host}`, exp, sub: subject })),
);
const signingInput = `${header}.${payload}`;
// 비공개 키를 ES256 서명용으로 불러오기
const key = await globalThis.crypto.subtle.importKey(
"jwk", privateJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"],
);
// 서명 생성
const sign = new Uint8Array(
await globalThis.crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" }, key, utf8(signingInput),
),
);
// Authorization 헤더 값 (t=서명된 토큰, k=공개 키)
return `vapid t=${signingInput}.${bytesToB64url(sign)}, k=${VAPID_PUBLIC_KEY}`;
}이게 곧 JWT예요. 헤더.내용.서명을 점으로 이은 문자열이죠. 헤더엔 서명 방식(ES256)을 적고 내용엔 세 가지를 담아요. aud는 이 토큰을 받을 푸시 서비스 주소(endpoint의 origin), exp는 만료 시각(12시간 뒤), sub는 문제가 생겼을 때 푸시 서비스가 연락할 발신자 주소예요. 앞의 헤더와 내용을 이어 비공개 키로 서명하면 토큰이 완성돼요.
푸시 서비스는 함께 보낸 공개 키(k)로 이 서명을 확인해요. 서명이 맞고 aud가 자기 주소면 등록된 발신자로 인정하고요. aud에 endpoint 주소를 지정해두니 토큰이 유출되어도 다른 푸시 서비스에선 쓸 수 없어요.
아래는 페이로드 암호화 코드예요. 여기서도 crypto.subtle은 전부 globalThis로 직접 불러요.
// ECDH 공유 시크릿
const shared = new Uint8Array(
await globalThis.crypto.subtle.deriveBits(
{ name: "ECDH", public: uaKey }, asKeys.privateKey, 256,
),
);
// HKDF로 콘텐츠 암호화 키(CEK)와 nonce 유도
const cek = await hkdf(salt, ikm, utf8("Content-Encoding: aes128gcm\0"), 16);
const nonce = await hkdf(salt, ikm, utf8("Content-Encoding: nonce\0"), 12);
// AES-128-GCM 암호화
const ciphertext = new Uint8Array(
await globalThis.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce }, cekKey, plaintext,
),
);코드가 짧아 보여도 단계마다 이유가 있어요.
- ECDH 공유 시크릿: ECDH는 양쪽이 자기 비공개 키와 상대 공개 키를 조합해 같은 시크릿을 얻는 키 교환 방식이에요. 서버는 발송할 때마다 임시 키쌍을 새로 만들어서 임시 비공개 키와 구독자 공개 키로 공유 시크릿을 계산해요. 브라우저가 자기 비공개 키와 서버 임시 공개 키로 계산해도 똑같은 값이 나와서 이 시크릿은 서버와 그 브라우저만 알아요.
- HKDF로 키 도출: HKDF는 하나의 시크릿에서 용도에 맞는 키를 안전하게 도출하는 표준 함수예요. 공유 시크릿을 암호화 키로 바로 쓰지는 않아요. 구독할 때 받은
auth시크릿을 더해 한 번 메시지마다 새로 생성한salt로 또 한 번 HKDF를 적용해 실제 암호화 키(cek)와 nonce(암호화마다 한 번만 쓰는 값)를 만들어요. 구독마다 메시지마다 키가 달라져요. - AES-128-GCM: 이 키로 페이로드를 암호화해요. GCM이라 암호문에 무결성 태그가 붙어서 중간에서 한 바이트라도 변조되면 브라우저가 바로 알아채요.
마지막으로 서버 임시 공개 키와 salt를 암호문 앞 헤더에 덧붙여 보내요. 브라우저는 이것으로 같은 공유 시크릿과 키를 복원해 복호화해요.
이걸 로컬 node로 다시 검증했어요. 실제 구독으로 요청을 보내보니 201 Created로 응답이 잘 왔어요. 배포하고 좋아요를 눌렀더니 이번엔 알림이 왔어요.
전체 구조
흐름을 정리하면 이렇게 돼요.
구독:
admin 페이지 → Service Worker 등록 → pushManager.subscribe(VAPID 공개 키)
→ { endpoint, keys } → D1(push_subscriptions)에 저장
발송:
좋아요/댓글 → notifyActivity()
→ 구독 전체 조회 → VAPID 서명 + 페이로드 암호화 → fetch(endpoint)
→ 브라우저 SW가 알림 표시
- 구독 저장: Cloudflare D1 (
push_subscriptions테이블) - 발송: API 라우트에서
ctx.waitUntil로 백그라운드 처리 - 표시:
public/sw.js(Service Worker)
VAPID 키는 한 번만 만들어두면 돼요. 공개 키는 브라우저가 구독할 때 쓰고 비공개 키는 서버가 서명할 때 써요.
내 활동엔 알림이 안 오게
배포하고 일반 브라우저에서 테스트를 해보니 알림은 잘 오는데 뭔가 어색했어요. 제가 직접 좋아요를 누르거나 댓글을 달아도 저한테 알림이 오는 거예요. 내 활동을 내가 알림 받는 건 좀 이상하잖아요. (시크릿 창에서 보낸 건 알림이 오는 게 맞지만)
좋아요랑 댓글은 쿠키에 저장해둔 방문자 ID로 누가 눌렀는지 구분해요. 그래서 구독할 때도 같은 방문자 ID(visitor_id)를 함께 저장해두면, 좋아요·댓글이 들어올 때 그 사람이 구독자 본인인지 비교할 수 있어요. 본인이면 발송을 통째로 건너뛰고요.
const subs = await db.select().from(pushSubscriptions);
// actor가 구독자 중 하나면 = 내 활동이면, 발송 안 함
if (actorVisitorId && subs.some((s) => s.visitorId === actorVisitorId)) return;알림 설정은 꼭 확인해야 해요
사실 직접 구현으로 넘어가기 전에 라이브러리로 로컬 테스트가 201이었을 때 정작 알림이 안 뜬 적이 있어요. 발송은 성공했다는데 화면에는 아무것도 안 왔죠. 한참 코드를 의심하다가 알고 보니 시스템 설정에서 크롬 알림이 꺼져 있었어요. FCM이 201로 받아도 OS가 배너를 막으면 안 뜨는 거였어요.
다행히 많은 시간이 지체되지는 않았지만 이런 기능은 설정과도 밀접한 관련이 있기 때문에 꼭 미리 확인해두어야 해요.
정리
알림을 받고 싶다는 게 이 글의 시작이었어요. 지금 블로그 수준에서는 주인장인 저 혼자 받으면 충분한데 Web Push API로 딱 그만큼만 알맞게 구현할 수 있었어요.
과정은 순탄하진 않았어요. 라이브러리가 로컬에서는 멀쩡한데 배포 환경에서만 Illegal invocation으로 실패했고 원인을 찾는 데 시간이 제일 많이 걸렸어요. Edge에서 번들되는 코드는 crypto.subtle이나 fetch 같은 전역 API를 변수에 담으면 this가 끊겨서 결국 globalThis로 직접 부르는 게 답이었어요. 하지만 이 규칙을 안 지키는 라이브러리는 아무리 잘 만들었어도 이 환경에선 쓰기 어려웠어요.
그래서 라이브러리를 걷어내고 직접 짰는데 오히려 좋았어요. 직접 구현하면서 웹 푸시 API가 어떤 방식으로 동작하는지 제대로 알 수 있었고 RFC 8291과 같은 규격들을 통해 안전하게 동작할 수 있다는 것도 배울 수 있었어요.
웹에서 알림이 필요한 시점이라면 웹 푸시 API도 추천드려요!


