요즘 슬슬 여름이 다가오고, 폭염경보등 여러 알람이 울릴 것을 대비하여 ..
올해에는 재난경보를 좀 보고싶다..!!!! 라는 생각으로 (너무 시끄러워서 꺼둔상태이다)
어떤식으로 구현해볼까 고민 하다가 새벽에 딱 스친 생각
"아 ! 사파리에서 이제 Notification API지원하니까 이걸로 해보면 되겠구나 !"
라는 생각으로 바로 PWA앱 만들기 작전을 시작했다
환경설정
일단 무조건 Nextjs, 이거말고는 떠오르지가 않더라 ..
그 이유중 하나는 .. PWA에 있었다
https://github.com/shadowwalker/next-pwa
냅다 manifest하나 작성해두면 fully하게 PWA를 설정해버리는 이 기적같은 라이브러리 덕에 일단 nextjs를 쓰기로 하고 ..
UI는 뒤도 안돌아보고 shadcn을 사용하였다
실시간 알람을 위해서 api를 긁어 데이터를 저장 + 메시징을 해야하기때문에 이번에는 백엔드도 필요했다
nextjs에서 크론을 돌리는 미친짓을 할 수도 있으나 .. 그냥 정신건강상 백엔드를 하나 따로 짜기로했다
이번 백엔드는 express, springboot가 아닌 좀 새로운 것을 해보자는 마인드로 fastapi를 사용하기로한다
현업에서는 프론트지만.. 백엔드팀이 파이썬 개발자분들이라서 .. fastapi를 쓰시던데
나도 서버용 파이썬 코드는 좀 봐둘 필요가 있다 생각해서 이번에 바로 fastapi를 골랐다
db는 mysql대신에 mariadb로 돌아왔다. 날이 갈수록 오라클에대한 믿음은 불신으로 바뀌고있기에 ..
이번엔 CI/CD도 적용해보기로했다
간단하게 github action을 통하여 올려보기로하자
마지막으로 메시징은 FCM을 이용하기로한다
자체서버를 이용할까도 생각했는데 이미 상용에 최강자가 무료로 풀려있는데 굳이 관리 포인트를 만들기 좀 그렇기 때문이다
백엔드 작성
이번에는 백엔드를 먼저 작성했다
이유는 FCM + 스케줄러 설정때문인데 이게 가능해야 프론트가 정보를 제대로 긁어서 나타내 줄 수 있기 때문이다
먼저 fastapi를 초기화하고requirements.txt를 작성하고 환경을 구성한다
https://fastapi.tiangolo.com/ko/
공식문서가 참 잘되어있다 잘 따라가기만하면 바로 할 수 있을 것이다
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
'프로그래밍' 카테고리의 다른 글
[BCrawler] 특가를 크롤링 해서 모아서 보자 (0) | 2024.05.19 |
---|---|
[BIcon] Next js 로 Badge 생성기를 만들어보자 feat. ImageResponse (0) | 2024.02.09 |
서늘한 하루, 서버와의 사투 feat nginx, chown, chmod (0) | 2024.02.05 |
[개인서버] 서버 통합 및 마이그레이션을 해보자 - 준비 (2) | 2024.01.14 |
[DBeaver] 맥용 dbeaver에서 mysql dump를 해보자 (0) | 2023.12.30 |