TIL #63 웹 푸시 알림을 사용한 알림 시스템 구현

DO YEON KIM·2024년 7월 22일
1

부트캠프

목록 보기
63/72

하루 하나씩 작성하는 TIL #63


원래는 저번주 화요일부터 진행되었던 팀 프로젝트인데, 와이어프레임 작성 및 주제 선정, 파일 구조 정리, 디자이너님 만남, 노션 작성 등으로 인해 til은 면접 대비 질문으로 작성하였다.


필자가 맡은 역할은 마이페이지이고, 사용자 정보 불러오기 및 수정, 사용자가 작성한 댓글 불러오기, 복약 알람 기능, 복약 기록 등이 있다. 중간 발표 이후 추가적으로 리팩토링 및 기능 구현 예정이다. (ex) 찜하기 기능)


오늘은 복약 알람 기능을 구현할 예정이고, 이에 관한 til을 작성해보도록 하겠다.


검색해보니, Service WorkerPush API를 사용하여 알람을 구현할 수 있었다.

일단, 서버가 푸시 서비스 제공자에게 자신의 신원을 증명하기 위해 웹 푸시 프로토콜에서 사용하는 인증 메커니즘인 VAPID (Voluntary Application Server Identification for Web Push)의 사용 또한 필요했다.


1. 프로젝트 구조 설정

우선 프로젝트의 구조는


위와 같은 형식이다.

필자는 추가적으로

src/components/templates/mypage/Alerts.tsx
src/app/mypage/page.tsx
server.js
public/sw.js를 추가한 상태이다.


2. VAPID 키 생성

VAPID의 역할은

💊 서버 인증 : VAPID는 서버가 자신을 인증할 수 있도록 도와줌. 이는 푸시 메시지를 신뢰할 수 있는 출처에서 보냈는지 확인하는 데 사용.

💊 메시지 보호 : VAPID는 푸시 메시지가 변조되지 않았음을 보장.

위와 같다.

npx web-push generate-vapid-keys

위 라이브러리를 사용하여 생성할 수 있다.

생성된 키는 ,, 공개해도 되나? 아무래도 안될 거 같아서 작성하지 않겠다.


3. 서버 코드 작성 (server.js)

💊 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 응답을 반환


4. 클라이언트 코드 작성

서비스 워커를 등록하고, 푸시 알림을 구독하는 로직을 구현하였다.

구독 정보를 서버로 전송해줘야 한다.

4.1. Service Worker 등록

"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에 전달하기 위해 변환한다.

4.2. 알람 설정

알람 추가 폼과 알람 목록을 관리하는 컴포넌트를 구현하였다.

알람을 설정하면 서버로 전송하여 지정된 시간에 알람이 울리도록 구현하였다.


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만 넣어뒀다. (따로 보여줄 필요는 없을 거 같아 코드는 가져오지 않겠다.)


5. Service Worker 등록


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에 데이터를 연동하고, 추가적인 기본 디자인 및 (디자이너님께서 아직 웹 프레임워크 진행이 안된 상태라 가시성 좋게만 짤 예정) 내용을 작성할 예정이다.

profile
프론트엔드 개발자를 향해서

0개의 댓글