Web Push Api
개요
1. 웹 푸시 작동 구조 이해
프레임워크나 라이브러리를 선택하기 전에, 웹 푸시가 어떤 흐름으로 진행되는지 이해하면 구현이 훨씬 쉬워집니다.
- 구독(Subscription): 브라우저(User Agent)가 푸시 서비스(FCM, Mozilla 등)로부터 고유 Endpoint를 받아 서버로 전달합니다.
- 저장(Storage): 서버는 이 구독 정보를 DB에 저장합니다.
- 전송(Pushing): 서버에서 푸시가 필요할 때, 위 라이브러리들을 사용해 푸시 서비스로 암호화된 메시지를 보냅니다.
- 수신(Delivery): 브라우저의 Service Worker가 메시지를 받아 사용자에게 알림을 띄웁니다.
2. 언어별 전용 라이브러리 (가장 추천)
웹 푸시 프로토콜의 복잡한 암호화(VAPID)와 페이로드 처리를 담당하는 핵심 라이브러리들입니다.
| 언어 | 라이브러리 명칭 | 특징 |
|---|---|---|
| Node.js | web-push | 가장 표준적인 라이브러리. VAPID 키 생성 및 알림 전송이 매우 간편함. |
| Python | pywebpush / webpush | 최신 암호화 알고리즘을 지원하며 FastAPI, Django와 연동이 쉬움. |
| PHP | web-push-php | PHP 환경에서 가장 안정적이며 Symfony나 Laravel 번들도 제공함. |
| Java | webpush-java | web-push-libs 그룹에서 관리하는 자바용 표준 구현체. |
React Router v7(구 Remix) 환경에서의 Web Push 구현
React Router v7(구 Remix) 환경이라면 서버 사이드 로직과 클라이언트 사이드 로직을 한 곳에서 관리할 수 있다는 큰 장점이 있네요. Web Push를 구현할 때 가장 궁합이 좋은 조합은 Node.js 기반의 web-push 라이브러리를 사용하는 것입니다.
별도의 무거운 프레임워크를 추가하기보다는, React Router의 action과 loader를 활용해 다음과 같이 구조화하는 것을 추천합니다.
추천 스택 및 구조
- 라이브러리:
web-push(VAPID 인증 및 암호화 전송용) - 데이터베이스: PostgreSQL (Drizzle ORM 활용)
- 클라이언트: Service Worker (브라우저 백그라운드 수신용)
1. 데이터베이스 스키마 (Drizzle ORM)
구독 정보(Subscription)는 브라우저마다 고유하므로, 유저 ID와 연결하여 JSON 형태로 저장해야 합니다.
// schema.ts
import { pgTable, serial, text, jsonb, timestamp, integer } from "drizzle-orm/pg-core";
export const pushSubscriptions = pgTable("push_subscriptions", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull(), // 유저와 연관 관계
endpoint: text("endpoint").notNull().unique(),
keys: jsonb("keys").notNull(), // p256dh, auth 키 저장
createdAt: timestamp("created_at").defaultNow(),
});2. 백엔드 로직 (React Router v7 Action)
구독 정보를 서버로 보낼 때 React Router의 action을 사용하면 처리가 깔끔합니다.
// routes/api.subscribe.ts
import webpush from "web-push";
import { db } from "~/db/server";
import { pushSubscriptions } from "~/db/schema";
// VAPID 설정 (환경 변수)
webpush.setVapidDetails(
"mailto:example@yourdomain.com",
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
export async function action({ request }: ActionFunctionArgs) {
const subscription = await request.json();
const userId = await getUserId(request); // 유저 인증 로직
// Drizzle을 사용해 DB에 구독 정보 저장 또는 업데이트
await db.insert(pushSubscriptions).values({
userId,
endpoint: subscription.endpoint,
keys: subscription.keys,
}).onConflictDoUpdate({ ... });
return { success: true };
}3. 클라이언트 사이드 (Service Worker)
React Router의 public 폴더에 sw.js를 배치하여 알림 수신을 대기합니다.
// public/sw.js
self.addEventListener("push", (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: "/icon.png",
badge: "/badge.png",
};
event.waitUntil(self.registration.showNotification(data.title, options));
});고려해야 할 포인트
- VAPID 키 생성:
npx web-push generate-vapid-keys명령어로 키를 미리 생성해서.env에 보관하세요. - Supabase 활용 시: 만약 Supabase를 메인으로 쓴다면 Edge Functions에서
web-push를 실행할 수도 있습니다. 하지만 React Router v7 서버(Node.js/Bun)가 있다면 굳이 나눌 필요 없이 서버 로직에서 통합 관리하는 것이 drizzle-orm과의 연동성 측면에서 더 유리합니다. - Neon/Self-host: DB 연결은
drizzle-orm이 알아서 하므로 설정 방식은 동일합니다.
이 구조로 가면 별도의 푸시 전용 프레임워크 없이도 매우 견고한 시스템을 구축할 수 있습니다. 혹시 Service Worker 등록 부분이나 브라우저 권한 요청 로직 예시가 더 필요하신가요?
React Router v7(Remix) 환경에서의 Service Worker 등록 및 권한 요청
React Router v7(Remix) 환경에서 Service Worker 등록과 권한 요청은 클라이언트의 진입점인 entry.client.tsx나 메인 root.tsx에서 처리하는 것이 정석입니다.
전체적인 흐름은 다음과 같습니다:
- 브라우저가 Service Worker를 지원하는지 확인.
sw.js파일을 등록.- 유저에게 알림 권한을 요청.
- 권한 승인 시
pushManager를 통해 구독 객체(Subscription)를 생성 후 서버로 전송.
1. 클라이언트 권한 요청 및 구독 로직
이 함수를 useEffect 내부나 특정 "알림 켜기" 버튼의 이벤트 핸들러로 사용하세요.
// app/utils/push-setup.ts
export async function subscribeUserToPush() {
// 1. Service Worker 확인
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.error("이 브라우저는 푸시 알림을 지원하지 않습니다.");
return;
}
try {
// 2. Service Worker 등록 확인
const registration = await navigator.serviceWorker.ready;
// 3. 기존 구독 확인
let subscription = await registration.pushManager.getSubscription();
// 4. 구독이 없다면 새로 생성
if (!subscription) {
const response = await fetch("/api/vapid-public-key"); // 서버에서 공개키 가져오기
const { publicKey } = await response.json();
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
});
// 5. 서버(React Router Action)로 구독 정보 전송
await fetch("/api/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
headers: { "Content-Type": "application/json" },
});
}
return subscription;
} catch (error) {
console.error("푸시 구독 실패:", error);
}
}2. Service Worker 파일 (public/sw.js)
React Router의 public 폴더에 위치시켜야 브라우저가 /sw.js 경로로 직접 접근할 수 있습니다.
// public/sw.js
self.addEventListener('push', function(event) {
if (event.data) {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192x192.png', // 앱 아이콘 경로
badge: '/icons/badge-72x72.png', // 안드로이드 상태바 아이콘
data: {
url: data.url || '/' // 알림 클릭 시 이동할 URL
}
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
}
});
// 알림 클릭 시 페이지 이동 처리
self.addEventListener('notificationclick', function(event) {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});3. 구현 팁 및 주의사항
VAPID Key 변환
pushManager.subscribe에 전달하는 applicationServerKey는 일반 문자열이 아닌 Uint8Array 형태여야 할 때가 많습니다. 아래 변환 함수를 활용하세요.
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}Service Worker 등록 위치
React Router v7 앱의 entry.client.tsx에서 앱이 하이드레이션(Hydration)된 후 등록하는 것이 좋습니다.
// entry.client.tsx
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(reg => {
console.log('SW registered:', reg);
}).catch(err => {
console.log('SW registration failed:', err);
});
});
}보안 (HTTPS)
Web Push API는 HTTPS 환경(또는 localhost)에서만 작동합니다. Supabase나 Neon으로 배포할 때 SSL 설정이 되어 있는지 꼭 확인하세요.
이제 이 구조를 바탕으로 drizzle-orm을 이용해 DB에 endpoint와 keys를 저장하면, 서버 어디서든 web-push 라이브러리로 특정 유저에게 알림을 쏠 수 있습니다! 더 궁금한 로직이 있으신가요?
모노레포 환경에서의 푸시 시스템
모노레포(Monorepo) 환경이라면 앱별로 푸시 시스템을 완전히 따로 만들 필요는 없지만, "공통 비즈니스 로직"과 "앱별 개별 설정"을 영리하게 분리해야 합니다.
결론부터 말씀드리면, 핵심 로직은 1개의 패키지로 공유하되, VAPID 키와 서비스 워커 파일은 앱별로 관리하는 것이 가장 효율적입니다.
1. 추천하는 모노레포 구조
packages/ 폴더에 푸시 전용 공유 라이브러리를 만들고, 각 앱에서 이를 불러다 쓰는 구조입니다.
/packages
/web-push-shared <-- 핵심 로직 공유 (Drizzle 스키마, web-push 유틸리티)
- index.ts
- schema.ts (Drizzle 스키마 정의)
/apps
/web-app-a <-- React Router v7 (App A)
- public/sw.js (App A 전용 서비스 워커)
/web-app-b <-- React Router v7 (App B)
- public/sw.js (App B 전용 서비스 워커)2. 분리해야 할 것 vs 공유해야 할 것
✅ 공유해야 할 것 (Shared Package)
- Drizzle 스키마: 유저의 구독 정보(Subscription)를 저장하는 테이블 구조.
- VAPID 유틸리티:
web-push라이브러리를 래핑한 전송 함수. - 유효성 검사 로직: 프론트엔드에서 서버로 구독 정보를 보낼 때 사용하는 타입 정의(Zod 등).
❌ 앱별로 따로 관리해야 할 것 (Individual App)
- VAPID 키: 앱마다 도메인이 다르거나 알림의 출처를 명확히 하고 싶다면 앱별로
PublicKey/PrivateKey쌍을 따로 발급받는 것이 좋습니다. (관리 편의성을 위해 하나로 통일할 수도 있지만, 보안상 분리가 권장됩니다.) - Service Worker (
sw.js): 서비스 워커는 보안상 자기 도메인의 Root에 있어야 합니다. 앱 A와 앱 B의 도메인이 다르다면 각각의public폴더에 별도의sw.js가 존재해야 합니다. - 알림 디자인: 앱 A는 쇼핑몰, 앱 B는 관리자 페이지라면 알림 클릭 시 이동할 URL이나 아이콘이 달라야 하므로
sw.js내의 로직은 다를 수 있습니다.
3. 구현 전략 (Drizzle + Monorepo)
모노레포의 공통 패키지에서 Drizzle 테이블을 정의할 때, app_name 또는 origin 컬럼을 추가하세요.
// packages/web-push-shared/schema.ts
export const pushSubscriptions = pgTable("push_subscriptions", {
id: serial("id").primaryKey(),
userId: integer("user_id"),
appName: text("app_name"), // "store-app" 또는 "admin-app" 구분
endpoint: text("endpoint").unique(),
keys: jsonb("keys"),
// ...생략
});이렇게 하면 하나의 DB(PostgreSQL)를 쓰더라도 어떤 앱에서 생성된 구독인지 구분할 수 있고, 특정 앱에서만 푸시를 보낼 때 필터링하기 쉽습니다.
4. 핵심 팁: VAPID 키 관리
모노레포 환경에서 환경 변수를 관리할 때 아래와 같이 구성하세요.
- App A (
.env):VAPID_PUBLIC_KEY=AAA...,VAPID_PRIVATE_KEY=BBB... - App B (
.env):VAPID_PUBLIC_KEY=CCC...,VAPID_PRIVATE_KEY=DDD...
서버(React Router v7)에서 푸시를 쏠 때, 공통 라이브러리 함수에 해당 앱의 키를 주입(Injection)하는 방식으로 호출하면 코드 중복 없이 깔끔하게 처리됩니다.
요약
- DB 스키마와 전송 로직은
packages/에서 공통화하세요. - 구독 저장 시 어떤 앱인지 구분하는 컬럼을 추가하세요.
- **서비스 워커(
sw.js)**와 VAPID 키는 각 앱의 도메인/환경별로 관리하세요.
이렇게 구성하면 앱이 10개로 늘어나도 핵심 로직은 건드리지 않고 새로운 앱만 슥슥 추가할 수 있습니다. 모노레포 툴은 혹시 Nx나 Turborepo 중 어떤 걸 사용하시나요? 그에 맞는 패키지 참조 방식도 알려드릴 수 있습니다.
Web Push Api
React Router v7(최근 Remix와 통합됨)과 Neon PostgreSQL을 사용하여 브라우저를 닫아도 알림 예약이 가능한 타이머 서비스의 핵심 샘플을 만들어 보겠습니다.
이 구조는 사용자가 타이머를 시작하면 서버 DB(Neon)에 알림을 저장하고, 지정된 시간에 Web Push를 보내는 방식입니다.
1. 프로젝트 구조 및 준비
- Frontend/Backend: React Router v7 (Framework mode)
- Database: Neon (PostgreSQL)
- Push:
web-push라이브러리
# 관련 라이브러리 설치
npm install web-push lucide-react2. Database 스키마 (Neon)
사용자의 푸시 구독 정보와 타이머 종료 시간을 저장해야 합니다.
CREATE TABLE timer_subscriptions (
id SERIAL PRIMARY KEY,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
target_time TIMESTAMP NOT NULL, -- 알림이 울려야 할 시간
is_sent BOOLEAN DEFAULT FALSE
);3. 서버 사이드 로직 (React Router v7 Action)
사용자가 타이머를 시작하면 실행되는 서버 로직입니다. app/routes/timer.tsx에 위치한다고 가정합니다.
// app/routes/timer.tsx
import { ActionFunctionArgs } from "react-router";
import { neon } from "@neondatabase/serverless";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const subscription = JSON.parse(formData.get("subscription") as string);
const seconds = parseInt(formData.get("seconds") as string);
const targetTime = new Date(Date.now() + seconds * 1000);
const sql = neon(process.env.DATABASE_URL!);
// DB에 푸시 정보와 목표 시간 저장
await sql`
INSERT INTO timer_subscriptions (endpoint, p256dh, auth, target_time)
VALUES (${subscription.endpoint}, ${subscription.keys.p256dh}, ${subscription.keys.auth}, ${targetTime})
`;
return { success: true, targetTime };
}4. 클라이언트 사이드 (React 컴포넌트)
사용자로부터 알림 권한을 받고 구독 객체를 생성합니다.
// app/routes/_index.tsx
import { useFetcher } from "react-router";
import { useState } from "react";
export default function TimerPage() {
const fetcher = useFetcher();
const [seconds, setSeconds] = useState(60);
const startTimer = async () => {
// 1. 서비스 워커 등록 확인
const registration = await navigator.serviceWorker.ready;
// 2. 푸시 구독 생성 (VAPID 키 필요)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: "YOUR_PUBLIC_VAPID_KEY"
});
// 3. 서버로 구독 정보와 시간 전송
fetcher.submit(
{ subscription: JSON.stringify(subscription), seconds: seconds.toString() },
{ method: "post", action: "/timer" }
);
alert("타이머가 서버에 등록되었습니다. 브라우저를 닫아도 알림이 옵니다!");
};
return (
<div className="p-8">
<h1>Neon Web Push 타이머</h1>
<input
type="number"
value={seconds}
onChange={(e) => setSeconds(Number(e.target.value))}
className="border p-2 mr-2"
/>
<button onClick={startTimer} className="bg-blue-500 text-white p-2 rounded">
타이머 시작 (서버 예약)
</button>
</div>
);
}5. 서비스 워커 (public/sw.js)
브라우저가 닫혀 있어도 푸시 이벤트를 듣고 알림을 띄웁니다.
// public/sw.js
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : { title: '타이머 종료', body: '시간이 되었습니다!' };
const options = {
body: data.body,
icon: '/icon.png',
vibrate: [200, 100, 200],
data: { url: '/' }
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});6. 백그라운드 워커 (알림 발송기)
서버에서 주기적으로(예: 1분마다) DB를 확인하여 시간이 다 된 항목에 푸시를 보냅니다. 별도의 Cron Job이나 서버리스 함수로 실행합니다.
// scripts/send-push.js (예시 로직)
const webpush = require('web-push');
const { neon } = require('@neondatabase/serverless');
const sql = neon(process.env.DATABASE_URL);
async function checkAndSend() {
// 현재 시간이 지난 보낼 알림들 조회
const rows = await sql`
SELECT * FROM timer_subscriptions
WHERE target_time <= NOW() AND is_sent = FALSE
`;
for (const row of rows) {
const subscription = {
endpoint: row.endpoint,
keys: { p256dh: row.p256dh, auth: row.auth }
};
try {
await webpush.sendNotification(subscription, JSON.stringify({
title: '⏰ 타이머 종료!',
body: '요청하신 타이머 시간이 완료되었습니다.'
}));
// 발송 완료 표시
await sql`UPDATE timer_subscriptions SET is_sent = TRUE WHERE id = ${row.id}`;
} catch (err) {
console.error("푸시 발송 실패:", err);
}
}
}이 서비스의 작동 원리 요약
- React (클라이언트): 사용자에게 "알림을 받아도 될까요?" 물어보고, 승인되면 고유 주소(Subscription)를 얻습니다.
- React Router Action (서버): 받은 주소와 "몇 초 뒤 알림" 정보를 Neon DB에 저장합니다.
- Neon (DB): 알림 정보를 안전하게 보관합니다. 이제 사용자가 탭을 닫거나 컴퓨터를 꺼도 데이터는 남아 있습니다.
- Backend Worker: 서버에서 시간을 체크하다가 "아, 이 사람이 요청한 시간이 됐네!" 하고 브라우저 푸시 서버로 메시지를 쏩니다.
- Service Worker (브라우저): 내 사이트가 꺼져 있어도 브라우저 프로세스가 푸시를 수신하여 시스템 알림을 띄웁니다.
이렇게 구현하면 React의 라이브 컴포넌트 타이머와 시스템의 백그라운드 알림을 완벽하게 결합할 수 있습니다. 추가로 VAPID 키 생성 방법이나 상세 설정이 필요하시면 말씀해 주세요!