하루 하나씩 작성하는 TIL #63
원래는 저번주 화요일부터 진행되었던 팀 프로젝트인데, 와이어프레임 작성 및 주제 선정, 파일 구조 정리, 디자이너님 만남, 노션 작성 등으로 인해 til은 면접 대비 질문으로 작성하였다.
필자가 맡은 역할은 마이페이지이고, 사용자 정보 불러오기 및 수정, 사용자가 작성한 댓글 불러오기, 복약 알람 기능, 복약 기록 등이 있다. 중간 발표 이후 추가적으로 리팩토링 및 기능 구현 예정이다. (ex) 찜하기 기능)
오늘은 복약 알람 기능을 구현할 예정이고, 이에 관한 til을 작성해보도록 하겠다.
검색해보니, Service Worker와 Push API를 사용하여 알람을 구현할 수 있었다.
일단, 서버가 푸시 서비스 제공자에게 자신의 신원을 증명하기 위해 웹 푸시 프로토콜에서 사용하는 인증 메커니즘인 VAPID (Voluntary Application Server Identification for Web Push)의 사용 또한 필요했다.
우선 프로젝트의 구조는
위와 같은 형식이다.
필자는 추가적으로
src/components/templates/mypage/Alerts.tsx
src/app/mypage/page.tsx
server.js
public/sw.js를 추가한 상태이다.
💊 서버 인증 : VAPID는 서버가 자신을 인증할 수 있도록 도와줌. 이는 푸시 메시지를 신뢰할 수 있는 출처에서 보냈는지 확인하는 데 사용.
💊 메시지 보호 : VAPID는 푸시 메시지가 변조되지 않았음을 보장.
위와 같다.
npx web-push generate-vapid-keys
위 라이브러리를 사용하여 생성할 수 있다.
생성된 키는 ,, 공개해도 되나? 아무래도 안될 거 같아서 작성하지 않겠다.
💊 Express 서버를 설정하고, VAPID 키를 사용하여 푸시 알림을 처리하는 로직을 구현하였다.
💊 클라이언트로부터 푸시 구독 정보를 받아 저장하고, 지정된 시간에 푸시 알림을 전송하도록 한다.
💊 Express 서버를 사용하는 이유는, 간단하고 직관적인 API를 제공하여, 서버를 쉽게 설정하고 관리할 수 있다. 최소한의 설정으로 빠르게 서버를 구축할 수 있다.
const express = require('express');
const webpush = require('web-push');
const bodyParser = require('body-parser');
const cors = require('cors');
💊 express 모듈을 가져와서 express 객체를 만들어준다. Express는 Node.js를 위한 웹 프레임워크로, 서버를 쉽게 구축할 수 있도록 도와준다.
💊 web-push 모듈을 가져와서 webpush 객체를 만든다. 이 모듈은 푸시 알림을 전송하기 위해 사용된다.
💊 body-parser 모듈을 가져와서 bodyParser 객체를 만든다. 이 모듈은 요청 본문을 파싱하여 req.body 속성에 저장하는 역할을 한다.
💊 cors 모듈을 가져와서 cors 객체를 만든다. 이 모듈은 Cross-Origin Resource Sharing(CORS) 정책을 설정하여 다른 도메인에서의 요청을 허용한다.
💊 외부 모듈을 사용하려면 다음과 같이 require를 사용해줘야 한다. (Node.js에서 모듈을 가져오기 위해 사용하는 CommonJS 문법)
const app = express();
const port = 4000;
💊 express 객체를 사용하여 새로운 Express 애플리케이션을 만들어주고, 서버가 실행될 포트를 설정해준다.
const vapidKeys = {
publicKey: '발급받은 키',
privateKey: '발급받은 키'
};
💊 위에 언급하였던 VAPID 키를 설정한다. 이 키는 푸시 서비스 제공자에게 서버의 신원을 증명하는 데 사용된다.
webpush.setVapidDetails(
'내 이메일',
vapidKeys.publicKey,
vapidKeys.privateKey
);
💊 web-push 라이브러리를 설정하여 VAPID 키와 이메일 주소를 사용하여 푸시 서비스 제공자에게 인증 정보를 제공해준다.
app.use(cors({
origin: 'http://localhost:3000',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204
}));
💊 CORS 설정을 적용합니다. 여기서는 http://localhost:3000에서 오는 요청을 허용하도록 설정해준다.
app.use(bodyParser.json());
💊 JSON 형식의 요청 본문을 파싱하여 req.body에 저장한다.
let subscriptions = [];
💊 구독 정보를 저장할 배열을 선언한다. 이 배열에는 클라이언트의 푸시 구독 정보가 저장된다.
app.post('/subscribe', (req, res) => {
const subscription = req.body;
subscriptions.push(subscription);
res.status(201).json({});
});
💊 /subscribe 엔드포인트를 설정한다. 클라이언트에서 구독 정보를 보내면 이를 subscriptions 배열에 저장한다.
💊 성공적으로 저장되면 201 상태 코드와 빈 JSON 응답을 반환한다.
app.post('/scheduleNotification', (req, res) => {
const { time, description } = req.body;
const now = new Date();
const [hours, minutes] = time.split(':').map(Number);
const alertTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, 0, 0);
if (alertTime < now) {
alertTime.setDate(alertTime.getDate() + 1);
}
const timeUntilAlert = alertTime.getTime() - now.getTime();
setTimeout(() => {
const payload = JSON.stringify({
title: '테스트 알림',
body: description || '이것은 테스트 알림입니다.',
icon: '/default-icon.png',
badge: '/default-badge.png',
url: 'http://localhost:3000/'
});
console.log('전송할 푸시 데이터:', payload);
subscriptions.forEach(subscription => {
webpush.sendNotification(subscription, payload)
.then(response => {
console.log('알림 전송 성공:', response);
})
.catch(error => {
console.error('Error sending notification:', error);
});
});
}, timeUntilAlert);
res.status(200).json({ message: '알람 설정 성공' });
});
💊 /scheduleNotification 엔드포인트를 설정해준다. 클라이언트에서 알람 시간을 보내면 이를 처리.
💊 req.body에서 time과 description을 추출.
💊 현재 시간을 가져와 time과 비교하여 알람 시간을 설정. 알람 시간이 현재 시간보다 이전이면 다음 날로 설정.
💊 setTimeout을 사용하여 지정된 시간에 푸시 알림을 전송하도록 예약.
💊 지정된 시간이 되면, 푸시 알림 데이터를 JSON 형식으로 만들어 subscriptions 배열에 저장된 각 구독 정보에 대해 푸시 알림을 전송.
💊 성공적으로 알람이 설정되면 200 상태 코드와 JSON 응답을 반환
서비스 워커를 등록하고, 푸시 알림을 구독하는 로직을 구현하였다.
구독 정보를 서버로 전송해줘야 한다.
"use client";
import { useEffect } from "react";
const VAPID_PUBLIC_KEY = '발급받은 키';
const ServiceWorkerRegister = () => {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
subscription.unsubscribe().then(() => {
console.log('기존 구독 취소됨');
subscribeUser(registration);
}).catch(error => {
console.error('기존 구독 취소 실패:', error);
subscribeUser(registration);
});
} else {
subscribeUser(registration);
}
});
});
}
}, []);
💊 ServiceWorkerRegister라는 React 컴포넌트를 정의. 이 컴포넌트는 서비스 워커를 등록하고 푸시 알림을 구독하는 역할을 한다.
💊 useEffect 훅을 사용하여 컴포넌트가 처음 렌더링될 때 서비스 워커를 등록하고 푸시 알림을 구독한다.
💊 if ('serviceWorker' in navigator)는 브라우저가 서비스 워커를 지원하는지 확인한다. (보통은 다 된다)
💊 navigator.serviceWorker.ready는 서비스 워커가 준비되면 Promise를 반환한다.
💊 registration.pushManager.getSubscription()은 현재 푸시 구독을 가져온다. 이미 구독된 경우 구독을 취소하고 다시 구독합니다. (이미 된 상태면 오류 메세지 뜬다.)
💊 subscribeUser 함수는 푸시 알림을 새로 구독합니다.
const subscribeUser = (registration: ServiceWorkerRegistration) => {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
}).then(subscription => {
console.log('Push Manager 구독:', subscription);
fetch('http://localhost:4000/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
}).then(data => {
console.log('구독 정보 서버에 전송 성공:', data);
}).catch(error => {
console.error('Failed to send subscription:', error);
});
}).catch(error => {
console.log('Service Worker registration failed:', error);
});
} else {
console.log('알림 권한 거부됨');
}
});
};
return null;
};
💊 subscribeUser 함수는 푸시 알림을 구독하는 역할을 한다.
💊 Notification.requestPermission()은 사용자에게 알림 권한을 요청한다.
💊 권한이 승인되면, registration.pushManager.subscribe()를 통해 푸시 알림을 구독한다.
💊 구독 정보는 서버로 전송된다.
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export default ServiceWorkerRegister;
💊 urlBase64ToUint8Array 함수는 Base64 형식의 문자열을 Uint8Array로 변환한다.
💊 VAPID 공개 키를 PushManager.subscribe에 전달하기 위해 변환한다.
알람 추가 폼과 알람 목록을 관리하는 컴포넌트를 구현하였다.
알람을 설정하면 서버로 전송하여 지정된 시간에 알람이 울리도록 구현하였다.
import React, { useState } from 'react';
interface Alert {
time: string;
description: string;
}
interface AlertFormProps {
onAddAlert: (alert: Alert) => void;
}
💊 Alert 인터페이스를 정의한다. 이는 알람 객체의 구조를 정의한다.
💊 AlertForm 컴포넌트의 props 구조를 정의하는 인터페이스. onAddAlert 함수는 새로운 알람을 추가하는 데 사용된다.
const AlertForm: React.FC<AlertFormProps> = ({ onAddAlert }) => {
const [time, setTime] = useState('');
const [description, setDescription] = useState('');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
onAddAlert({ time, description });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Time:</label>
<input type="time" value={time} onChange={(e) => setTime(e.target.value)} required />
</div>
<div>
<label>Description:</label>
<input type="text" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<button type="submit">Add Alert</button>
</form>
);
};
💊 AlertForm 컴포넌트는 알람을 추가하기 위한 폼을 렌더링.
💊 time과 description의 상태를 관리.
💊 폼 제출 시 onAddAlert 함수를 호출하여 알람을 추가.
const Alerts: React.FC = () => {
const [alerts, setAlerts] = useState<Alert[]>([]);
const addAlert = (alert: Alert) => {
setAlerts([...alerts, alert]);
console.log('알람 추가됨:', alert);
// 서버로 알람 데이터를 전송하는 로직
fetch('http://localhost:4000/scheduleNotification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(alert),
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('알람 데이터 서버에 전송 성공:', data);
})
.catch(error => {
console.error('Failed to send alert data to server:', error);
});
};
return (
<div>
<AlertForm onAddAlert={addAlert} />
<div>
{alerts.map((alert, index) => (
<div key={index}>
<p>Time: {alert.time}</p>
{alert.description && <p>Description: {alert.description}</p>}
</div>
))}
</div>
</div>
);
};
export default Alerts;
💊 Alerts 컴포넌트는 여러 개의 알람을 관리하고 렌더링한다.
💊 addAlert 함수는 새로운 알람을 추가하고, 이를 서버로 전송.
💊 추가된 알람은 alerts 상태 배열에 저장되며, 화면에 렌더링된다.
💊 src/app/mypage/page.tsx에선,
💊 ServiceWorkerRegister와 Alerts 컴포넌트를 포함하여 마이페이지를 구성하였다.
💊 컴포넌트를 다 분리하여 작성하고 있기 떄문에, 일단은 alerts만 넣어뒀다. (따로 보여줄 필요는 없을 거 같아 코드는 가져오지 않겠다.)
self.addEventListener('push', function(event) {
console.log('푸시 이벤트 수신:', event);
💊 서비스 워커가 푸시 메시지를 수신할 때 실행될 이벤트 리스너를 등록.
💊 event 객체를 통해 푸시 이벤트와 관련된 정보를 가져오기.
if (event.data) {
const data = event.data.json();
console.log('푸시 데이터:', data);
💊 if (event.data)를 사용하여 푸시 이벤트에 데이터가 포함되어 있는지 확인합니다.
💊 event.data.json()을 사용하여 푸시 메시지의 데이터를 JSON 형식으로 파싱합니다.
const options = {
body: data.body,
icon: data.icon || '/default-icon.png',
badge: data.badge || '/default-badge.png',
data: {
url: data.url || 'http://localhost:3000/'
}
};
💊 body: 푸시 메시지의 본문 내용
💊 icon: 푸시 메시지에 표시될 아이콘입니다. 데이터에 아이콘이 없으면 기본 아이콘 경로를 사용
💊 badge: 푸시 메시지에 표시될 배지입니다. 데이터에 배지가 없으면 기본 배지 경로를 사용
💊 data: 푸시 메시지에 추가적인 데이터를 포함. 여기서는 알림 클릭 시 열릴 URL을 포함.
event.waitUntil(
self.registration.showNotification(data.title, options)
);
} else {
console.log('푸시 데이터가 없음');
}
💊 self.registration.showNotification(data.title, options)를 사용하여 푸시 알림을 표시.
💊 event.waitUntil은 푸시 알림이 완료될 때까지 서비스 워커가 종료되지 않도록 함.
self.addEventListener('notificationclick', function(event) {
console.log('알림 클릭:', event);
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
💊 clients.openWindow(event.notification.data.url)를 사용하여 알림에 포함된 URL을 새 창이나 탭에서 열기.
💊 event.waitUntil은 창이 열릴 때까지 서비스 워커가 종료되지 않도록 함.
이렇게 해두면, 우선은
못생겼지만 일단은 잘 뜬다.
이제 supabase에 데이터를 연동하고, 추가적인 기본 디자인 및 (디자이너님께서 아직 웹 프레임워크 진행이 안된 상태라 가시성 좋게만 짤 예정) 내용을 작성할 예정이다.