[next-pwa] push알람

Rachaen·2023년 4월 24일
2

Next.js 프로젝트에서 서비스 워커를 사용하여 push 기능을 구현해보았습니다.
프로젝트가 백엔드 프레임워크로 spring을 사용하고 있기도하고 간단하게 push알람이 되는지 여부를 체크하기 위해서 따로 db를 만들지 않았습니다.

.env.local

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);
  • push 기능을 위해서 위의 코드를 이용해서 public key와 private key를 만들어서 .env.local에 저장

Frontend

next.config.js

// 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

// /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: 서비스워커가 설치되면 실행

_app.js

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

// _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;
  • 웹 앱 manifest 파일 지정

pages/index.js

// 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>
  );
}
  • 구독버튼 클릭하여 push알림 구독
  • 알림 전송하는 버튼을 클릭하여 알림 받기
  • subscribeUser: 사용자가 푸시 알림을 구독. 서비스 워커가 준비되면 pushManager를 사용하여 사용자를 구독시키고, 구독 정보를 서버에 전송하여 저장
  • sendNotification: 서버에서 푸시 알림을 전송

manifest.json

{
  "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"
    }
  ]
}

Backend

api/subscribe.js

// 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

// 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' });
  }
}
  • API endpoint 서버에서 실행되어 모든 구독자에게 푸시 알림을 전송
  • web-push 라이브러리를 사용하여 VAPID 자격증명 설정

utils/db.js

// 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);
}
  • 실제 데이터베이스를 사용하지 않고 Set에 저장하여서 테스트 해보았습니다.
profile
개발을 잘하자!

0개의 댓글