-
web push, 이렇게 쉬운거였어?Developer 2024. 1. 29. 10:34728x90
사용자들에게 푸쉬 알림을 보내고 싶은데..
난 앱 개발자가 아닌데..
언제 또 앱 개발 공부를 하지..
앱 개발 안해도 푸쉬 알림을 보낼 수 있습니다!
웹 푸쉬를 활용하면 브라우저의 푸쉬 기능을 입맛대로 사용할 수 있습니다!🔑 웹 푸쉬 구현에 앞서..
웹 푸쉬 구현에 앞서 실습 환경은 아래와 같습니다.
다른 프레임워크라고 하더라도 기본적인 구조는 같으니 이해하시기에 어렵지 않으실 겁니다!- Vue (v3.3.4)
- Node
- Firebase firestore
( 모바일 기준 )
웹 푸쉬는 카카오 브라우저 및 네이버 브라우저에서는 동작하지 않습니다.
적절한 조치를 취해 기본 브라우저(삼성, 크롬, 사파리)로 유도해야합니다.
IOS의 경우, 16.4버전 이상부터 푸쉬 기능이 지원되며 PWA로 구현하여 앱을 다운로드 후 푸쉬 기능이 지원됩니다.📃 구독 부탁드립니다.
갑자기 구독이요..?
웹 푸쉬는 구독을 한 사용자에게 토큰값을 얻어서 보내야 합니다.
구독 버튼을 만들어 봅시다.구독 버튼을 만들기 위해서는 Service Worker와 PushManager를 사용해야합니다.
Vue 프로젝트의
/public
폴더에service-worker.js
파일을 만들어줍니다.// 웹 푸쉬 수신 시 self.addEventListener("push", (event) => { const text = event.data.text(); event.waitUntil( self.registration.showNotification("웹 푸쉬!", { body: text, data: { url: "https://github.io/ParkBeomMin/WebPushExample", }, }) ); }); // 푸쉬 알림 클릭 시 self.addEventListener("notificationclick", function (event) { event.notification.close(); event.waitUntil(clients.openWindow(event.notification.data.url)); });
push와 notificationclick 이벤트를 등록시켜줍니다.
event.data.text()를 통해 푸쉬 알림에 보여줄 데이터를 가져오고 showNotification의 body값에 뿌려줍니다.
showNotification의 첫번째 인자는 푸쉬알림의 타이틀니니다.
url 부분은 notificationclick 이벤트에서 푸쉬 클릭 시 이동할 url 경로입니다.이제
HomeView.vue
파일로 이동하여 구독 버튼과 service worker 파일을 등록하고 구독을 할 수 있는 기능을 구현해보겠습니다.<template> <div> <button @click="requestPermission">{{ buttonText }}</button> </div> </template>
버튼은 위와 같이 만들고, buttonText는 구독과 구독해지를 위해 변화될 수 있도록 했습니다.
... const buttonText = ref(''); onMounted(async () => { // Service Worker 등록 코드 if ('serviceWorker' in navigator) { const workerFile = '/service-worker.js'; try { const registration = await navigator.serviceWorker.register(workerFile); if (registration) { const subscription = await registration.pushManager.getSubscription(); if (subscription) { buttonText.value = '구독 해지하기'; } else { buttonText.value = '구독하기'; } } } catch (e) { console.error(e.message); } } else { console.error('Service Worker in navigator error'); } }); ...
이제 service worker가 등록이 되었으니, 구독 요청 기능을 구현해보겠습니다.
... const requestPermission = async () => { try { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (subscription) { // 이미 구독이 되어있다면 해지하기 // TODO: DB에 구독 해지 정보 보내기 buttonText.value = '구독하기'; subscription.unsubscribe(); } else { // 구독이 되어있지 않으면 구독하기 const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidKey.value, }); // TODO: DB에 구독 정보 보내기 console.log('subscription => ', subscription.toJSON()); buttonText.value = '구독 해지하기'; } } catch (e) { console.error(e.message); } }; ...
요청은 service worker가 등록되고 준비가 된 이후 pushManager의 getSubscription()를 통해 구독정보를 가져옵니다.
구독이 되어있다면 해지, 되어있지 않다면 구독을 합니다.
뜬금없이vapidKey.value
이 친구가 나타났는데 푸쉬 발송을 위한 키값입니다. 이 키값은 서버단에서 만들어야합니다.이제 Node 프로젝트로 이동합니다.
npm install -g web-push
web-push generate-vapid-keys
======================================= Public Key: BAHc42Ge9Ku-Hgup-66JXrkbsWuwDTUTnoh0Y5UyQFyS04UbP7NF02ZfOMMDf2ujLTMIfKlQ4cx4Thz8ek6hze8 Private Key: TdolN_-xYH9ARuWRDULgaXO-EFgadIM39FhCSttwswc =======================================
위 명령어를 통해 web-push를 설치하고, vapid key 값을 발급받습니다.
발급받은 vapid key 값 중 Public Key를 위에서 언급되었던vapidKey.value
에 넣어줍니다.const vapidKey = ref( "BAHc42Ge9Ku-Hgup-66JXrkbsWuwDTUTnoh0Y5UyQFyS04UbP7NF02ZfOMMDf2ujLTMIfKlQ4cx4Thz8ek6hze8" );
이제 구독 버튼을 눌러보면 아래와 같이 알림 요청과 구독 정보를 받아올 수 있습니다.
이 구독 정보를 가지고 다시 Node 프로젝트로 이동합니다.
npm install --save web-push
라이브러리 설치 후 vapid키와 구독정보를 포함해 푸쉬 발송 로직을 만들어줍니다.
const webpush = require("web-push"); const vapidKeys = { publicKey: "BAHc42Ge9Ku-Hgup-66JXrkbsWuwDTUTnoh0Y5UyQFyS04UbP7NF02ZfOMMDf2ujLTMIfKlQ4cx4Thz8ek6hze8", privateKey: "TdolN_-xYH9ARuWRDULgaXO-EFgadIM39FhCSttwswc", }; webpush.setVapidDetails( "mailto:bmpark@jinhak.com", vapidKeys.publicKey, vapidKeys.privateKey ); webpush.sendNotification( { endpoint: "https://fcm.googleapis.com/fcm/send/cRngP9o7apw:APA91bG5_i-BS2WBUSehlWxe4Pr2PLhugyvCtIcNgFSs2RcSSth60wmC61R9SH-Iq3tFpO1tqprXcFFze4ZduL-MsSGWP9DJvm7jEbWB3nM40Ui99VFNPsnoHUx-emEceevzR6vwATMn", keys: { auth: "BjJjTUVFQi9UCBH-VqqVAg", p256dh: "BDDJ_YGSawW1NowpbJ1Cl0N8JiFtsSuMBjjtWCly7lBrf4wnsrJP7xlVTqBKhhaMIP3RwkCfb5oSSwDVh1fbYp4", }, }, "웹푸쉬발송!" );
이제
node index.js
로 서버를 실행시키면 웹 푸쉬 발송을 확인할 수 있습니다!💻 RESTFul한 WebPush로!
위에서 단순히 웹 푸쉬가 동작하는 것까지 했으니, 이제 db도 연결하고 서버 api로 웹 푸쉬가 발송될 수 있도록 해보겠습니다.
Node 프로젝트로 이동합니다.npm install express --save
npm install firebase-admin --save
firebase console에서 키값 파일도 다운로드 받아놓습니다.
firebase project > 프로젝트 설정 > 서비스 계정 > 새 비공개 키 생성이제 기본적인 셋팅은 완료가 되었고, 구조를 잡고 구현을 합니다.
├── routes │ ├── index.js │ └── webPush.js ├── firebase-account-file.json ├── firebase.js ├── index.js ├── webPush.controller.js └── package.json
기존
index.js
의 webPush 기능들은webPush.controller.js
파일로 변경했습니다.
이제 각 파일에 대해 파헤쳐보겠습니다.먼저
index.js
파일은 기본적인 express 라우팅 처리를 해줍니다.const express = require("express"); const app = express(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); const index = require("./routes/index"); app.use("/", index); app.listen(3000, () => console.log("WebPush Server On 3000 Port"));
webPush.controller.js
에서는 vapidkey와 push를 보내는 함수를 export시켜줍니다.
vapidKey는 서버에서 발급받은 키를 고정으로 프론트와 같게 사용해야하기 때문에 Vapid값을 보내주는 함수를 만들었습니다.
dev, production 환경에서 각각 달라지므로 cross-env를 활용해 config값으로 셋팅하여 사용해도 좋습니다!const webpush = require("web-push"); const vapidKeys = { publicKey: "BAHc42Ge9Ku-Hgup-66JXrkbsWuwDTUTnoh0Y5UyQFyS04UbP7NF02ZfOMMDf2ujLTMIfKlQ4cx4Thz8ek6hze8", privateKey: "TdolN_-xYH9ARuWRDULgaXO-EFgadIM39FhCSttwswc", }; webpush.setVapidDetails( "mailto:club20608@gmail.com", vapidKeys.publicKey, vapidKeys.privateKey ); const getVapidKey = () => { return vapidKeys.publicKey; }; const push = ({ data, tokens }) => { tokens.forEach(async (token) => { try { await webpush.sendNotification(token, data.message); } catch (e) { console.error(e); } }); }; module.exports = { getVapidKey, push };
firebase.js
에서는 firebase를 초기화하고 firestore db와 통신하는 기능들을 구현합니다.
토큰을 db에 추가/삭제하고 가져와서 발송처리를 합니다.const { initializeApp, applicationDefault, cert, } = require("firebase-admin/app"); const { getFirestore } = require("firebase-admin/firestore"); const { push } = require("./webPush.controller"); const serviceAccount = require("./firebase-account-file.json"); initializeApp({ credential: cert(serviceAccount), }); const db = getFirestore(); const setToken = async ({ endpoint, keys }) => { let isExist = false; const q = db.collection("token").where("endpoint", "==", endpoint); const querySnapshot = await q.get(); querySnapshot.forEach((doc) => { if (doc.id) { isExist = true; } }); if (!isExist) { const today = new Date(); db.collection("token").add({ endpoint, keys, regDate: today, }); } }; const deleteToken = async ({ endpoint, keys }) => { const q = db.collection("token").where("endpoint", "==", endpoint); // query(collection(db, 'token'), where('endpoint', '==', true)); const querySnapshot = await q.get(); querySnapshot.forEach((doc) => { if (doc.id) { db.doc(`token/${doc.id}`).delete(); } }); }; const sendMessage = async () => { const registrationTokens = []; const docs = await db.collection("token").get(); // 디비에 등록된 토큰 가져오기 docs.forEach((result) => { registrationTokens.push({ ...result.data() }); }); const message = { data: { message: "웹푸쉬!", }, tokens: registrationTokens.filter((r) => r.endpoint), }; try { push(message); } catch (e) { console.log(e); } return; }; module.exports = { setToken, sendMessage, deleteToken, };
routes/index.js
에서는 webPush 경로로 라우팅 해줍니다.const express = require("express"); const router = express.Router(); router.use(express.urlencoded({ extended: false })); router.use(express.json()); const webPush = require("./webPush.js"); router.use("/webPush", webPush); module.exports = router;
마지막으로
routes/webPush.js
에서 각 api들을 구현해줍니다.const express = require("express"); const { setToken, deleteToken, sendMessage } = require("../firebase"); const { getVapidKey } = require("../webPush.controller"); const router = express.Router(); router.use(express.urlencoded({ extended: false })); router.use(express.json()); router.get("/", async (req, res) => { res.json({ rtCode: "S", vapidKey: getVapidKey() }); }); router.post("/", (req, res) => { const { endpoint, keys } = req.body; setToken({ endpoint, keys }); res.json({ rtCode: "S" }); }); router.post("/delToken", (req, res) => { const { endpoint } = req.body; deleteToken({ endpoint: decodeURIComponent(endpoint) }); res.json({ rtCode: "S" }); }); router.post("/send", (req, res) => { sendMessage(); res.json({ rtoCode: "S" }); }); module.exports = router;
이제 Vue 프로젝트로 이동하여 api 호출을 구현합니다.
npm i --save axios
main.ts
로 이동하여 axios를 글로벌하게 사용할 수 있게 등록해줍니다.... import axios from 'axios'; const app = createApp(App); app.config.globalProperties.$axios = axios; app.use(router); app.mount('#app');
이제
vite.config.ts
로 이동하여 api 서버가 제대로 호출될 수 있도록 server 설정을 해줍니다.export default defineConfig({ ... server: { port: 3001, cors: true, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, ... });
페이지 랜딩 시 vapidKey 값을 받아와서 셋팅될 수 있도록 합니다.
import { onMounted, getCurrentInstance, ref, nextTick } from 'vue'; const instance = getCurrentInstance(); const vapidKey = ref(''); onMounted(async () => { const ds = await (instance?.proxy as any).$axios.get('/api/webPush'); vapidKey.value = ds.data.vapidKey; ... })
requestPermission
함수에서 TODO로 남겨놓았던 부분에도 토큰값이 셋팅될 수 있도록 추가해줍니다.const requestPermission = async () => { try { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (subscription) { // 이미 구독이 되어있다면 해지하기 await (instance?.proxy as any).$axios.post(`/api/webPush/delToken`, subscription); buttonText.value = '구독하기'; subscription.unsubscribe(); } else { // 구독이 되어있지 않으면 구독하기 const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidKey.value, }); console.log('subscription => ', subscription.toJSON()); await (instance?.proxy as any).$axios.post('/api/webPush', subscription); buttonText.value = '구독 해지하기'; } } catch (e) { console.error(e.message); } };
이제 구독 버튼 클릭 시 DB에 토큰이 저장되고 모든 셋팅이 끝났습니다.
postman 프로그램으로/webPush/send
api를 호출하면 웹 푸쉬가 정상적으로 오는 것을 확인할 수 있습니다!😮 IOS는요??
IOS는 처음에 말씀드린 것과 같이 사파리 16.4 버전 이상에서 동작이 가능하며 PWA로 만들어야합니다.
PWA로 만드는 것은 기존 웹사이트에 Manifest만 등록해주면 됩니다!Vue 프로젝트로 이동하여
public/manifest.json
파일을 만들어줍니다.
icon 파일들은 favicon-generator사이트에서 만들어주면 편리합니다.{ "short_name": "웹푸쉬", "name": "웹푸쉬", "start_url": "/", "id": "webpush", "display": "standalone", "theme_color": "#ffc107", "backgroun_color": "#ffc107", "icons": [ { "src": "/android-icon-36x36.png", "sizes": "36x36", "type": "image/png", "density": "0.75" }, { "src": "/android-icon-48x48.png", "sizes": "48x48", "type": "image/png", "density": "1.0" }, { "src": "/android-icon-72x72.png", "sizes": "72x72", "type": "image/png", "density": "1.5" }, { "src": "/android-icon-96x96.png", "sizes": "96x96", "type": "image/png", "density": "2.0" }, { "src": "/android-icon-144x144.png", "sizes": "144x144", "type": "image/png", "density": "3.0" }, { "src": "/android-icon-192x192.png", "sizes": "192x192", "type": "image/png", "density": "4.0" } ] }
그리고
index.html
로 가서 head태그 안에 mainfest파일을 등록해줍니다.<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="manifest" href="/manifest.json" /> <link rel="icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite App</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.ts"></script> </body> </html>
이제 실행을 시켜보면 아래와 같이 앱을 다운로드 받을 수 있습니다.
IOS 환경에서는 '홈화면에 추가'를 통해 앱이 설치가 되고, 구독버튼을 눌러 웹 푸쉬 기능을 사용할 수 있습니다.
모든 소스코드는 WebPushExample와 WebPushServerExample에서 확인하실 수 있습니다.
728x90'Developer' 카테고리의 다른 글
팀즈 WebHook으로 에러 알림받기 (0) 2024.02.02 git actions로 ssh 접속하여 자동 배포 하기 (2) 2024.02.01 Vue3 Teleport, Pinia랑 찰떡궁합?! (0) 2024.01.29 Placeholder 줄바꿈 적용하기 (0) 2024.01.26 MAC nginx 설치하기 (0) 2024.01.14