Hyunseok
현재 사이트는 2024년 11월 이후로 업데이트 되지 않습니다. 새 글은 블로그로 확인해주세요. 블로그로 이동
프로그래밍 [BeAlert] 재난경보를 PWA 앱 알람으로 받아보자 feat fastapi, nextjs
2024. 6. 20. 00:40

현재 사이트는 2024년 11월 이후로 업데이트 되지 않습니다. 새 글은 블로그로 확인해주세요. 블로그로 이동

요즘 슬슬 여름이 다가오고, 폭염경보등 여러 알람이 울릴 것을 대비하여 ..

 

올해에는 재난경보를 좀 보고싶다..!!!! 라는 생각으로 (너무 시끄러워서 꺼둔상태이다)

 

어떤식으로 구현해볼까 고민 하다가 새벽에 딱 스친 생각 

 

"아 ! 사파리에서 이제 Notification API지원하니까 이걸로 해보면 되겠구나 !"

 

라는 생각으로 바로 PWA앱 만들기 작전을 시작했다 

 

환경설정

 

일단 무조건 Nextjs, 이거말고는 떠오르지가 않더라 ..

그 이유중 하나는 .. PWA에 있었다 

https://github.com/shadowwalker/next-pwa

 

GitHub - shadowwalker/next-pwa: Zero config PWA plugin for Next.js, with workbox 🧰

Zero config PWA plugin for Next.js, with workbox 🧰 - shadowwalker/next-pwa

github.com

 

냅다 manifest하나 작성해두면 fully하게 PWA를 설정해버리는 이 기적같은 라이브러리 덕에 일단 nextjs를 쓰기로 하고 ..

 

UI는 뒤도 안돌아보고 shadcn을 사용하였다  

https://ui.shadcn.com/

 

Introduction

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.

ui.shadcn.com

 

실시간 알람을 위해서 api를 긁어 데이터를 저장 + 메시징을 해야하기때문에 이번에는 백엔드도 필요했다

 

nextjs에서 크론을 돌리는 미친짓을 할 수도 있으나 .. 그냥 정신건강상 백엔드를 하나 따로 짜기로했다

 

이번 백엔드는 express, springboot가 아닌 좀 새로운 것을 해보자는 마인드로 fastapi를 사용하기로한다 

 

현업에서는 프론트지만.. 백엔드팀이 파이썬 개발자분들이라서 .. fastapi를 쓰시던데 

 

나도 서버용 파이썬 코드는 좀 봐둘 필요가 있다 생각해서 이번에 바로 fastapi를 골랐다 

 

db는 mysql대신에 mariadb로 돌아왔다. 날이 갈수록 오라클에대한 믿음은 불신으로 바뀌고있기에 .. 

 

이번엔 CI/CD도 적용해보기로했다 

 

간단하게 github action을 통하여 올려보기로하자 

 

마지막으로 메시징은 FCM을 이용하기로한다 

 

자체서버를 이용할까도 생각했는데 이미 상용에 최강자가 무료로 풀려있는데 굳이 관리 포인트를 만들기 좀 그렇기 때문이다 

 

 

 

백엔드 작성

 

이번에는 백엔드를 먼저 작성했다 

 

이유는 FCM + 스케줄러 설정때문인데 이게 가능해야 프론트가 정보를 제대로 긁어서 나타내 줄 수 있기 때문이다 

 

먼저 fastapi를 초기화하고requirements.txt를 작성하고 환경을 구성한다

 

https://fastapi.tiangolo.com/ko/

 

FastAPI

FastAPI framework, high performance, easy to learn, fast to code, ready for production

fastapi.tiangolo.com

 

공식문서가 참 잘되어있다 잘 따라가기만하면 바로 할 수 있을 것이다 

 

main.py등등 은 제쳐두고 하나하나 핵심코드만 찾아 보자

 

먼저 재난경보 api를 로드하는 함수

import httpx
import json
import os
from datetime import datetime

BASE_DIR = os.path.abspath(os.path.dirname(__file__))
SECRET_FILE = os.path.join(BASE_DIR, 'secrets.json')

with open(SECRET_FILE) as f:
    secrets = json.load(f)

GOV_API_URL = secrets["GOV_API"]["URL"]

ERROR_CODE_MAP = {
    290: "인증키가 유효하지 않습니다. 인증키가 없는 경우 홈페이지에서 인증키를 신청하십시오.",
    310: "해당하는 서비스를 찾을 수 없습니다. 요청인자 중 SERVICE를 확인하십시오.",
    333: "요청위치 값의 타입이 유효하지 않습니다. 요청위치 값은 정수를 입력하세요.",
    336: "데이터 요청은 한번에 최대 1,000건을 넘을 수 없습니다.",
    337: "일별 트래픽 제한을 넘은 호출입니다. 오늘은 더이상 호출할 수 없습니다.",
    500: "서버 오류입니다. 지속적으로 발생시 홈페이지로 문의(Q&A) 바랍니다.",
    600: "데이터베이스 연결 오류입니다. 지속적으로 발생시 홈페이지로 문의(Q&A) 바랍니다.",
    601: "SQL 문장 오류입니다. 지속적으로 발생시 홈페이지로 문의(Q&A) 바랍니다.",
    0: "정상 처리되었습니다.",
    300: "관리자에 의해 인증키 사용이 제한되었습니다.",
    200: "해당하는 데이터가 없습니다."
}

def fetch_disaster_messages():
    response = httpx.get(GOV_API_URL, verify=False)  
    response.raise_for_status()  
    data = response.json()

    error_code = data.get("errorCode")
    if error_code in ERROR_CODE_MAP:
        if error_code != 0:
            raise ValueError(ERROR_CODE_MAP[error_code])

    messages = []
    disaster_msgs = data.get("DisasterMsg", [])
    for item in disaster_msgs:
        rows = item.get("row", [])
        for row in rows:
            row["create_date"] = datetime.strptime(row["create_date"], "%Y/%m/%d %H:%M:%S")
            messages.append(row)
    return messages

재난경보 docs에 표시된 에러코드를 선언

 

그리고 함수로 api를 긁고 값을 리팩터링한 뒤 리스트를 리턴하는 함수를 작성해준다

 

def load_disaster_messages_from_gov(db: Session, disaster_messages: list[schemas.DisasterMessageCreate]):
    new_messages = []
    for message in disaster_messages:
        existing_message = db.query(models.DisasterMessage).filter(models.DisasterMessage.md101_sn == message.md101_sn).first()
        if existing_message is None:
            try:
                db_disaster_message = models.DisasterMessage(**message.dict())
                db.add(db_disaster_message)
                db.commit()
                new_messages.append(db_disaster_message)
            except IntegrityError:
                db.rollback()
                print(f"Duplicate entry found for md101_sn: {message.md101_sn}, skipping...")

    for new_message in new_messages:
        send_firebase_message(new_message.location_id, new_message.msg)

 

FCM은 이미 설정했다 가정하고, 아까 작성한 api를 긁어오고 리턴한 list를 받아와서 db에 한줄씩 넣는다 

 

bulk로 넣어도 되긴하는데 새로운 메시지를 구분해야해서 .. 짧은 백엔드 지식상 이게 최선이었다 

 

새로운 메시지는 또 for문을 돌리면서 FCM을 이용해 {title:body} 형식으로 메시지를 전송한다 

 

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from datetime import datetime
import time
import utils
import schemas
import crud
from database import SessionLocal

def load_disaster_messages():
    db = SessionLocal()
    try:
        disaster_messages_json = utils.fetch_disaster_messages()
        disaster_messages = [schemas.DisasterMessageCreate(**msg) for msg in disaster_messages_json]
        crud.load_disaster_messages_from_gov(db, disaster_messages)
        print(f"{datetime.now()}: Disaster messages loaded successfully")
    except ValueError as ve:
        print(f"{datetime.now()}: ValueError - {ve}")
    except Exception as e:
        print(f"{datetime.now()}: Exception - {e}")
    finally:
        db.close()

scheduler = BackgroundScheduler()
scheduler.start()
scheduler.add_job(
    load_disaster_messages,
    trigger=IntervalTrigger(seconds=10),
    id='load_disaster_messages_job',
    name='Load disaster messages every 10 seconds',
    replace_existing=True
)

import atexit
atexit.register(lambda: scheduler.shutdown())

 

 

위의 함수를 가지고 이제 스케쥴러를 작성한다 

 

 

스케쥴러를 시작하고

긁는 함수와 exception을 설정하고 scheduler에 등록, 10초마다, id와 name을 넣고 스케쥴러에 추가, 

 

마지막으로 앱이 죽을때 shutdown도 넣어준다 

 

그럼 간단하게 데이터가 10초마다 잘 긁어진다 

 

그리고 토큰을 저장하는 로직과 토큰별로 location_id를 저장하는 로직을 작성하고 백엔드 설정을 끝을낸다 

 

 

프론트 작성

 

프론트는 매우 간단한데 약간 애매한 nextjs에다가 fcm을 붙이는 작업을 해야한다 

 

먼저 firebase설정용 ts파일을 하나 작성한다 

 

 

import { initializeApp } from 'firebase/app'
import { getMessaging, getToken, onMessage, deleteToken } from 'firebase/messaging'

const firebaseConfig = {
    apiKey: 'AIzaSyDU7gEdgf8nnQmOyiDfFuP8QwMm2X6rM_A',
    authDomain: 'bealert-89401.firebaseapp.com',
    projectId: 'bealert-89401',
    storageBucket: 'bealert-89401.appspot.com',
    messagingSenderId: '335950957415',
    appId: '1:335950957415:web:2ce5fff93a9e868ac0fdc2',
    measurementId: 'G-BD1MESEHRS',
}

const firebaseApp = initializeApp(firebaseConfig)
const messaging = getMessaging(firebaseApp)

const getFCMToken = async (vapidKey: string) => {
    try {
        const currentToken = await getToken(messaging, { vapidKey })
        if (currentToken) {
            return currentToken
        } else {
            console.log('No registration token available. Request permission to generate one.')
        }
    } catch (error) {
        console.log('An error occurred while retrieving token. ', error)
    }
}

const unsubscribeFromFCM = async (vapidKey: string) => {
    try {
        const currentToken = await getFCMToken(vapidKey)
        if (currentToken) {
            const result = await deleteToken(messaging)
            if (result) {
                console.log('Token successfully deleted.')
            } else {
                console.error('Failed to delete token.')
            }
        } else {
            console.warn('No token available to unsubscribe.')
        }
    } catch (error) {
        console.error('Error unsubscribing from FCM:', error)
    }
}

firebaseApp.automaticDataCollectionEnabled = false

export { messaging, getToken, onMessage, unsubscribeFromFCM }

 

vapid정보만 노출을 막으면 된다해서 config은 그대로 사용했다

 

자신의 firebase의 설정을 저기다 넣어주고 시작하자 

 

필요한 함수들을 export해주고 이번에는 fcm용 ts파일을 또 작성해보자 

 

import { useEffect, useRef } from 'react'
import { AlertApi } from './alert-api'
import { getToken, messaging, onMessage, unsubscribeFromFCM } from './firebase'

const useFCM = (vapidKey: string) => {
    const retryLoadToken = useRef(0)
    const alertApi = new AlertApi()
    const requestPermission = async () => {
        if (!('Notification' in window)) {
            return
        }

        try {
            const permission = await Notification.requestPermission()
            if (permission !== 'granted') {
                console.log('Notification permission not granted.')
                return
            }

            console.log('Notification permission granted.')
            await navigator.serviceWorker
                .register('/firebase-messaging-sw.js', { scope: '/firebase-cloud-messaging-push-scope' })
                .then(async (reg) => {
                    console.log('Service worker registered:', reg)

                    const currentToken = await getToken(messaging, { vapidKey })
                    if (currentToken) {
                        console.log('FCM Token:', currentToken)
                        await sendTokenToServer(currentToken)
                        console.log('Token sent to server.')
                    } else {
                        console.log('No registration token available. Request permission to generate one.')
                    }

                    localStorage.setItem('fcm_token', currentToken)
                    window.location.reload()
                })
        } catch (error) {
            if (retryLoadToken.current < 3) {
                console.log('retry to load token')
                requestPermission()
            } else {
                console.log('Error during subscription:', error)
            }
        }
    }

    const unsubscribeFromPushService = async () => {
        try {
            const registration = await navigator.serviceWorker.getRegistration()
            if (!registration) {
                console.log('No service worker registration found')
                return null
            }
            console.log('Service worker is ready:', registration)

            const subscription = await registration.pushManager.getSubscription()
            console.log('Current subscription:', subscription)

            if (subscription) {
                await subscription.unsubscribe()
                console.log('Successfully unsubscribed from push service')
            } else {
                console.log('No push subscription found')
            }

            return subscription
        } catch (error) {
            console.error('Error during push service unsubscription:', error)
            throw error
        }
    }

    const unregisterServiceWorker = async () => {
        try {
            const registrations = await navigator.serviceWorker.getRegistrations()
            for (const registration of registrations) {
                await registration.unregister()
                console.log('Service worker unregistered:', registration)
            }
        } catch (error) {
            console.error('Error during service worker unregistration:', error)
            throw error
        }
    }

    const requestUnsubscribe = async () => {
        try {
            console.log('Remove token from server...')
            getToken(messaging, { vapidKey }).then((token) => {
                removeTokenFromServer(token)
            })

            console.log('Unsubscribing...')
            await unsubscribeFromPushService()
            console.log('Unsubscribed from push service.')

            await unsubscribeFromFCM(vapidKey)
            console.log('Unsubscribed from FCM.')

            await unregisterServiceWorker()
            localStorage.removeItem('fcm_token')
            console.log('All service workers unregistered.')
            window.location.reload()
        } catch (error) {
            console.error('Error while unsubscribing:', error)
        }
    }

    useEffect(() => {
        if (localStorage.getItem('fcm_token')) {
            getToken(messaging, { vapidKey })
            onMessage(messaging, (payload) => {
                console.log('Message received. ', payload)
            })
        }
    }, [vapidKey])

    const sendTokenToServer = async (token: string) => {
        try {
            const response = await alertApi.requestAddToken(token)

            if (!response) {
                throw new Error('Failed to send token to server')
            }
        } catch (error) {
            console.error('Error sending token to server:', error)
        }
    }

    const removeTokenFromServer = async (token: string) => {
        try {
            const response = await alertApi.requestDeleteToken(token)
            if (!response) {
                throw new Error('Failed to remove token from server')
            }
        } catch (error) {
            console.error('Error removing token from server:', error)
        }
    }

    const requestLocationListByToken = async () => {
        try {
            const currentToken = await getToken(messaging, { vapidKey })
            return await alertApi.requestSubscribedListByToken(currentToken)
        } catch (error) {
            console.error('Failed to update location list:', error)
        }
    }

    const requestSubscribeLocation = async (location: string): Promise<void> => {
        try {
            const currentToken = await getToken(messaging, { vapidKey })
            await alertApi.requestSubscribeLocation(location, currentToken)
        } catch (error) {
            console.error('Failed to subscribe location:', error)
        }
    }

    const requestUnsubscribeLocation = async (location: string): Promise<void> => {
        try {
            const currentToken = await getToken(messaging, { vapidKey })
            await alertApi.requestUnsubscribeLocation(location, currentToken)
        } catch (error) {
            console.error('Failed to unsubscribe location:', error)
        }
    }

    return {
        requestPermission,
        requestUnsubscribe,
        requestLocationListByToken,
        requestSubscribeLocation,
        requestUnsubscribeLocation,
    }
}

export default useFCM

 

alertApi는 백엔드와 통신하는 class를 하나 작성해줬고 

나머지는 구독/구독해제 로직을 작성해준다.

 

구독할때 토큰저장과 알림설정, 구독해지는 반대를 꼭 실행시켜주는 로직을 작성핮 ㅏ

 

마지막으로 fcm이 사용될 .. 콜백 파일? 뭐 여하튼 이러한 서비스워커용 js파일을 작성해야한다 

 

public/firebase-messaging-sw.js 파일을 작성하고 아래와 같이 작성해주었다

self.addEventListener("install", () => {
    console.log("FCM installing");
    self.skipWaiting();
});

self.addEventListener("activate", (event) => {
    event.waitUntil(self.clients.claim());
});

self.addEventListener("push", async (e) => {
    if (!e.data.json()) return;
    const resultData = e.data.json().notification;

    const locationId = resultData.title;
    const locationInfo = locationMap[locationId] || locationId;

    const notificationTitle = `[${locationInfo}]`;
    const notificationOptions = {
        body: resultData.body,
        icon: resultData.image,
        tag: resultData.tag,
        ...resultData,
    };

    self.registration.showNotification(notificationTitle, notificationOptions);
});

self.addEventListener("notificationclick", (event) => {
    const url = "/";
    event.notification.close();
    event.waitUntil(clients.openWindow(url));
});

 

클릭시에 해당 페이지로 이동하는 로직(해당 알람으로 focus되게할까 생각했지만 굳이 ? 라는 생각으로 패스해줬다)

 

메시지를 push하는 경우의 로직

(json파일로 매핑하는 함수가 제대로 동작을안해서 .. 결국 백엔드에서 지역명을 맵핑하는 로직으로 바꿨다)

 

나머지는 여타 다른 fcm예제에서 볼수있는 예시이다 (자세함이 필요하다면 다른글을 참고하자. 이 글에서는 여정만 표시한다)

 

이렇게 다 짜고, nextjs에서 해당 메시지를 출력하는 페이지를 작성하고 pwa를 등록 .. 해야하는데 pwa manifest는 다음과 같이 작성했다 

 

{
    "name": "BeAlert",
    "short_name": "BeAlert",
    "description": "BeAlert",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#000000",
    "theme_color": "#000000",
    "icons": [
        {
            "src": "/128.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/512.png",
            "sizes": "512x512",
            "type": "image/png",
            "purpose": "maskable"
        }
    ]
}

 

특별한것은없고 아이콘,  테마색에 맞춰서 색상지정, 앱내보내기를위한 display standalone, 이름등을 지정해주었다

 

이정도면 뭐 훌륭하다 

 

로컬에서는 메시징이 제대로 작동하지않기에 기초적인 테스트를 끝내고, ci/cd용 github action yml파일을 작성해보자 

 

name: Deploy to Server

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v2

      - name: Install SSH key
        uses: webfactory/ssh-agent@v0.5.4
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Prepare Deployment Directory
        run: |
          ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
            if [ -d '/home/BeAlert' ]; then
              cd /home/BeAlert
              git pull
            else
              cd ~
              git clone ${{ secrets.REPO_URL }} /home/BeAlert
              cd /home/BeAlert
            fi
          "

      - name: Clean and Install Dependencies
        run: |
          ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
            cd /home/BeAlert
            rm -rf .next
            echo 'APP_VAPIDKEY=${{ secrets.APP_VAPIDKEY }}' > .env
            echo 'NEXT_PUBLIC_BACKEND_URL=${{ secrets.NEXT_PUBLIC_BACKEND_URL }}' >> .env
            pnpm i
            pnpm build
          "

      - name: Stop Existing Services
        run: |
          ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
            docker stop bealert-back || true
            docker rm bealert-back || true

            # Kill process using port 15551 if exists
            pid=\$(sudo ss -lptn 'sport = :15551' | awk '/pid=/ {print \$NF}' | cut -d'=' -f2 | cut -d',' -f1)
            echo 'Found PID: '\$pid
            if [ -n \"\$pid\" ]; then
              echo 'Killing process '\$pid' using port 15551'
              kill -9 \$pid
            fi

            # Verify the process has been killed
            if sudo ss -lptn 'sport = :15551' | grep -q :15551; then
              echo 'Failed to kill the process using port 15551' >&2
              exit 1
            fi
          "

      - name: Start Application
        run: |
          ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
            cd /home/BeAlert
            chmod +x build_and_run.sh
            ./build_and_run.sh
            nohup setsid pnpm start --port 15551 > server.log 2>&1 &
          "

      - name: Verify Application is Running
        run: |
          ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
            sleep 5
            if ! sudo ss -lptn 'sport = :15551' | grep -q :15551; then
              echo 'Server did not start correctly' >&2
              cat server.log
              exit 1
            fi
          "

 

각종 secrets을 설정하고 빌드/배포는 서버에서 하게끔 설정했다

 

빌드 자체를 github action에서 하고 파일만 쏙 빼서 서버에 mv치는 방법도 있긴한데

 

저거 작성할때 이미 새벽이라서 그런거 생각할 시간 없고 그냥 서버내부에서 build치는 방법을 택했다 

 

저렇게 작성하고 테스트를 해보면 .. 다음과 같이 뜬다 

 

경남 지역코드로 실험해보았다

아이폰에서는 Notification API를 사용하기위해서는

홈 화면에 추가해서 사용해야한다 

 

안드로이드는.. 주위에 안드로이드 쓰는사람이 없어서 확인은 못했지만 잘 되지 않을까 

 

 

마치며

 

이렇게 빠르고 간단하게 PWA앱을 하나 작성해보았다 

 

세상 참 좋아졌다는 생각과 마음만 먹으면 빠르게 모든것을 할 수 있다는 자신감을 얻게되는 프로젝트였다 

 

모든 코드는 아래의 깃허브에서 확인할 수 있다 

 

https://github.com/B-HS/BeAlert

 

GitHub - B-HS/BeAlert: 재난경보 알림 PWA 앱

재난경보 알림 PWA 앱. Contribute to B-HS/BeAlert development by creating an account on GitHub.

github.com

 


프로그래밍의 다른 글