[WEB] web push, 이렇게 쉬운거였어?
#web#web-push2023.07.28
사용자들에게 푸쉬 알림을 보내고 싶은데.. 난 앱 개발자가 아닌데..
언제 또 앱 개발 공부를 하지..
앱 개발 안해도 푸쉬 알림을 보낼 수 있습니다!
웹 푸쉬를 활용하면 브라우저의 푸쉬 기능을 입맛대로 사용할 수 있습니다!
🔑웹 푸쉬 구현에 앞서..
웹 푸쉬 구현에 앞서 실습 환경은 아래와 같습니다.
다른 프레임워크라고 하더라도 기본적인 구조는 같으니 이해하시기에 어렵지 않으실 겁니다!
- 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에서 확인하실 수 있습니다.
끝까지 읽어주셔서 감사합니다.