Next.js 프로젝트에서 서비스 워커를 사용하여 push 기능을 구현해보았습니다.
프로젝트가 백엔드 프레임워크로 spring을 사용하고 있기도하고 간단하게 push알람이 되는지 여부를 체크하기 위해서 따로 db를 만들지 않았습니다.
NEXT_PUBLIC_VAPID_PUBLIC_KEY=<your-public-vapid-key>
VAPID_PRIVATE_KEY=<your-private-vapid-key>
const webPush = require('web-push');
const vapidKeys = webPush.generateVAPIDKeys();
console.log(vapidKeys);
// next.config.js
/** @type {import('next').NextConfig} */
const runtimeCaching = require('next-pwa/cache');
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
customWorkerDir: 'worker',
runtimeCaching,
});
const nextConfig = withPWA({
// next config
});
module.exports = nextConfig;
runtimeCaching
: PWA가 오프라인 작동을 지원하기 위해 캐싱해주는 역할dest: 'public'
: 서비스 워커 파일과 관련된 파일들을 public 폴더에 저장register: true
: 서비스 워커를 자동으로 등록하도록 설정skipWaiting: true
: 새로운 서비스 워커가 설치되자마자 이전 버전의 서비스 워커를 대체하도록 설정. => 업데이트시 빠르게 적용됨customWorkerDir: 'worker'
: 커스텀 서비스 워커 폴더를 지정// /work/index.js
// 서비스 워커에게 푸시 이벤트를 수신하도록 지시
self.addEventListener('push', (event) => {
console.log('[Service Worker] Push Received.', event.data.text());
const { title, body } = event.data.json();
event.waitUntil(self.registration.showNotification(title, { body }));
});
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] notificationclick');
clients.openWindow(event.notification.data.link);
});
self.addEventListener('install', () => {
console.log('[Service Worker] install');
self.skipWaiting();
});
push
: 푸시 알림이 수신될 때 실행.notificationonclick
: 사용자가 알림을 클릭하면 실행.install
: 서비스워커가 설치되면 실행import { useEffect } from 'react';
function App({ Component, pageProps }) {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered with scope:', registration.scope);
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
}
}, []);
return <Component {...pageProps} />;
}
export default App;
// _document.js
import Document, { Html, Head, Main, NextScript } from 'next/document';
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<link rel='manifest' href='/manifest.json' />
<link rel='apple-touch-icon' href='/icon-192x192.png'></link>
<meta name='theme-color' content='#84A59D' />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
// pages/index.js
import { saveSubscription } from '@/utils/db';
async function sendNotification() {
try {
const response = await fetch('/api/send-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
console.log(data);
} catch (error) {
console.error('Error sending notification:', error);
}
}
async function subscribeUser() {
navigator.serviceWorker.ready.then((registration) => {
registration.pushManager.getSubscription().then((subscription) => {
if (subscription) {
console.log('Already subscribed');
} else {
registration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
})
.then((subscription) => {
// save subscription on DB
fetch('/api/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
});
});
}
});
});
}
export default function Home() {
return (
<div>
<h1>Welcome to your PWA</h1>
<button onClick={subscribeUser}>Subscribe for push notifications</button>
<button onClick={sendNotification}>Send notification</button>
</div>
);
}
{
"theme_color": "#84A59D",
"background_color": "#84A59D",
"display": "standalone",
"scope": "/",
"start_url": "/",
"name": "Green Cherry Business ",
"description": "Green Cherry Business",
"short_name": "Green Cherry Business ",
"icons": [
{
"src": "assets/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}
// api/subscribe.js
import { saveSubscription } from '../../utils/db';
const publicVapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
const privateVapidKey = process.env.VAPID_PRIVATE_KEY;
export default async function handler(req, res) {
if (req.method === 'POST') {
const subscription = req.body;
try {
await saveSubscription(subscription);
res.status(201).json({ message: 'Subscription saved' });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Failed to save subscription' });
}
} else {
res.status(405).json({ message: 'Method not allowed' });
}
}
// api/send-notification.js
import webPush from 'web-push';
import { getSubscriptions } from '../../utils/db';
const publicVapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
const privateVapidKey = process.env.VAPID_PRIVATE_KEY;
webPush.setVapidDetails('mailto:rachaenlee@gmail.com', publicVapidKey, privateVapidKey);
export default async function handler(req, res) {
if (req.method === 'POST') {
const subscriptions = getSubscriptions();
const notificationPayload = {
title: 'Hello from PWA',
body: 'This is a test push notification',
icon: '/icon-192x192.png',
badge: '/icon-192x192.png',
};
try {
console.log(notificationPayload);
for (const subscription of subscriptions) {
await webPush.sendNotification(subscription, JSON.stringify(notificationPayload));
}
res.status(200).json({ message: 'Push notifications sent' });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Failed to send push notifications' });
}
} else {
res.status(405).json({ message: 'Method not allowed' });
}
}
// utils/db.js
const subscriptions = new Set();
export function saveSubscription(subscription) {
subscriptions.add(subscription);
console.log('DB에 저장합니다: ', subscription);
}
export function getSubscriptions() {
console.log('DB를 확인합니다', subscriptions);
return Array.from(subscriptions);
}