ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • web push, 이렇게 쉬운거였어?
    Developer 2024. 1. 29. 10:34
    728x90

    사용자들에게 푸쉬 알림을 보내고 싶은데..
    난 앱 개발자가 아닌데..
    언제 또 앱 개발 공부를 하지..


    앱 개발 안해도 푸쉬 알림을 보낼 수 있습니다!
    웹 푸쉬를 활용하면 브라우저의 푸쉬 기능을 입맛대로 사용할 수 있습니다!

    🔑 웹 푸쉬 구현에 앞서..

    웹 푸쉬 구현에 앞서 실습 환경은 아래와 같습니다.
    다른 프레임워크라고 하더라도 기본적인 구조는 같으니 이해하시기에 어렵지 않으실 겁니다!

    • Vue (v3.3.4)
    • Node
    • Firebase firestore

    ( 모바일 기준 )
    웹 푸쉬는 카카오 브라우저 및 네이버 브라우저에서는 동작하지 않습니다.
    적절한 조치를 취해 기본 브라우저(삼성, 크롬, 사파리)로 유도해야합니다.
    IOS의 경우, 16.4버전 이상부터 푸쉬 기능이 지원되며 PWA로 구현하여 앱을 다운로드 후 푸쉬 기능이 지원됩니다.

    📃 구독 부탁드립니다.

    갑자기 구독이요..?

    웹 푸쉬는 구독을 한 사용자에게 토큰값을 얻어서 보내야 합니다.
    구독 버튼을 만들어 봅시다.

    구독 버튼을 만들기 위해서는 Service WorkerPushManager를 사용해야합니다.

    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 환경에서는 '홈화면에 추가'를 통해 앱이 설치가 되고, 구독버튼을 눌러 웹 푸쉬 기능을 사용할 수 있습니다.


    모든 소스코드는 WebPushExampleWebPushServerExample에서 확인하실 수 있습니다.

    728x90
Designed by Tistory.