* 이전 포스팅에서 이어지는 글입니다.
- 이전 포스팅에서 튜토리얼을 실습해봤으니 이제 본 프로젝트의 적재적소(?)에 넣어 적용하기만 하면 된다.
- 나는 프론트 React-TypeScript, 백엔드 Node.js(TypeScript) Express 환경에서 작업했다.
- 참고로 본 프로젝트에서 푸시 알림 기능을 구현할 때 백엔드 서버가 죽으면 푸시 알림도 가지 못하기 때문에, 별도의 Node 서버를 구축하면 서비스 의존도를 줄일 수 있다.
- 나는 처음에 그런 부분을 고려하지 못하고 열심히 합쳤다가 나중에 다시 분리했다...😤
- 따라서 본 포스팅은 push 서버를 따로 분리해 Node 서버가 2개인 구조로 설명한다.
[ 01 전체적인 구조 ]
- 이해를 돕기 위해 전체적인 구조부터 설명해야겠다.
- 위 설명과 같이 구독 / 구독 취소 api는 사용자 로그인 정보 접근이 쉬운 BackEnd Server에서, 그리고 로그인 정보 필요 없이 시간에 맞춰 DB 조회만 필요한 Push 알림 자동 전송 api는 Push Server에서 작동하도록 분리했다.
[ 02 구독 / 구독 취소 api ]
(1) Front End
- 위 Scheduler 페이지의 구독 서비스 모달 Component를 작성한다.
- subscribe( ) 함수는 다음과 같다.
- Notification.permission을 확인해 denied된 상태이면 알림 권한 차단을 푸는 alert 메세지를 띄웠다. 찾아봤는데 사용자가 알림 권한 동의 거부시 서비스 입장에서 대안을 제공해줄 수 있는 방법은 마땅치 않았다.
- 튜토리얼과 다른점은 사용자의 구독 정보(디바이스 토큰)를 Database에 저장하고 사용한다는 것이다.
const subscribe = async () => {
console.log("subscribe function");
const sw = await navigator.serviceWorker.ready;
if (Notification.permission === "denied") {
alert("알림 권한을 거부하셨습니다.\n구독을 원하신다면\n[브라우저 설정 - 개인정보 및 보안 - 사이트 설정]에서\nPill my rhythm 사이트의 알림 권한 차단을 재설정해주세요.");
}
// 사용자 구독 정보로 구독 요청
const subscription = await sw.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.REACT_APP_WEB_PUSH_PUBLIC_KEY,
});
// 사용자 구독 정보 DB에 추가
try {
await originpost("subscribe/create", { device_token: subscription });
} catch (error: any) {
if (error.response.data.message) {
alert(error.response.data.message);
}
}
};
- unsubscribe( ) 함수도 추가했다.
- 튜토리얼은 간단해서 없었지만 실서비스에는 구독 취소 기능도 필요할 것이다.
- 구독과 반대로 pushManager에서 구독 취소와 Database에서 삭제해주면 된다.
- 이 때 순서를 DB 삭제 후 pushManager 구독 취소로 해야 DB에 문제가 생겼을 때 pushManager만 구독 취소를 해버리는 일이 일어나지 않는다. 그럴 경우 나중에 DB만 믿고 Push 알림을 보내면 pushManager는 모르는 구독 정보이기 때문에 에러가 난다.
const unsubscribe = async () => {
console.log("unsubscribe function");
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
alert("구독 정보가 없는 기기입니다.");
return;
}
// 사용자 구독 정보 DB에서 삭제
await originpost("subscribe/delete", { device_token: subscription });
// 사용자 구독 정보로 구독 취소 요청
await subscription.unsubscribe();
};
- Subscribe.tsx 의 전체 코드는 다음과 같다.
- Android 구독을 위한 QR코드 추가하는 부분은 후의 포스팅에서 설명하겠다.
더보기
import React, { useContext, useMemo, useState } from "react";
import { AES } from "crypto-js";
import { originpost } from "../../Api";
function Subscribe() {
const secretKey: any = process.env.REACT_APP_SECRET_KEY;
const jwtToken = String(sessionStorage.getItem("userToken"));
const encryptedToken = AES.encrypt(jwtToken, secretKey).toString();
const encodedPageLink = encodeURIComponent(`${process.env.REACT_APP_MODE}:${process.env.REACT_APP_FRONT_PORT}/m/subscribe?token=${encryptedToken}`);
const QRcode = `https://quickchart.io/qr?text=${encodedPageLink}&ecLevel=L&size=200¢erImageUrl=https://ifh.cc/g/Y4Z5z3.png`;
const subscribe = async () => {
console.log("subscribe function");
const sw = await navigator.serviceWorker.ready;
if (Notification.permission === "denied") {
alert("알림 권한을 거부하셨습니다.\n구독을 원하신다면\n[브라우저 설정 - 개인정보 및 보안 - 사이트 설정]에서\nPill my rhythm 사이트의 알림 권한 차단을 재설정해주세요.");
}
// 사용자 구독 정보로 구독 요청
const subscription = await sw.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.REACT_APP_WEB_PUSH_PUBLIC_KEY,
});
// 사용자 구독 정보 DB에 추가
try {
await originpost("subscribe/create", { device_token: subscription });
} catch (error: any) {
if (error.response.data.message) {
alert(error.response.data.message);
}
}
};
const unsubscribe = async () => {
console.log("unsubscribe function");
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
alert("구독 정보가 없는 기기입니다.");
return;
}
// 사용자 구독 정보 DB에서 삭제
await originpost("subscribe/delete", { device_token: subscription });
// 사용자 구독 정보로 구독 취소 요청
await subscription.unsubscribe();
};
return (
<div id="subscribeService" className="py-8 px-8 max-w-sm mx-auto bg-white rounded-xl shadow-lg space-y-2 sm:py-4 sm:flex sm:items-center sm:space-y-0 sm:space-x-6">
<div className="text-center space-y-2 sm:text-left">
<div className="space-y-0.5">
<p className="text-md text-black font-semibold mb-1">구독 서비스</p>
<p className="text-slate-500 font-medium text-sm">영양제 일정 알림을 받아보세요!</p>
</div>
<button className="px-0 py-1 text-sm text-teal-600 font-semibold rounded-full border border-teal-200 hover:text-white hover:bg-teal-600 hover:border-transparent focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2">
<label htmlFor="QRcode" className="modal-button cursor-pointer px-4 py-1">
구독하러 가기
</label>
</button>
<input type="checkbox" id="QRcode" className="modal-toggle" />
<label htmlFor="QRcode" className="modal cursor-pointer">
<label className="modal-box relative" htmlFor="">
<h2 className="card-title mb-1 text-teal-500">영양제 일정 알림 구독 서비스</h2>
<div className="divider">Web</div>
<div className="items-center justify-center flex flex-row space-x-2 p-2">
<button
onClick={subscribe}
className="px-4 py-1 text-sm text-teal-600 font-semibold rounded-full border border-teal-200 hover:text-white hover:bg-teal-600 hover:border-transparent focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2"
>
구독하기
</button>
<button
onClick={unsubscribe}
className="px-4 py-1 text-sm text-teal-600 font-semibold rounded-full border border-teal-200 hover:text-white hover:bg-teal-600 hover:border-transparent focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2"
>
구독 취소
</button>
</div>
<div className="divider">Mobile</div>
<div className="items-center justify-center flex flex-col">
<img src={useMemo(() => QRcode, [])} alt="QRcode" width="170" height="170" className="rounded-xl" />
<h3 className="text-slate-900 mt-5 text-base font-medium tracking-tight">모바일 알림 구독 QR</h3>
<p className="text-slate-500 mt-2 text-sm text-left">
영양제 일정 알림 서비스는 현재 ios에서 지원되지 않아 Android 또는 Web에서만 가능합니다. Android 접속시 google 애플리케이션의 google lens를 이용하시는 것을 추천드립니다.
</p>
</div>
</label>
</label>
</div>
</div>
);
}
export default Subscribe;
(2) Back End
- 라우터, 컨트롤러 계층 코드는 생략한다.
- 서비스 계층의 createSubscription 메소드이다.
- 먼저 사용자 id와 토큰 정보로 이미 구독 신청한 정보인지 확인한다.
- 그리고 DB에 추가해준다.
- 구독이 완료되면 알림 기능을 활성화한다는 Push 알림을 전송한다.
createSubscription: async (fk_user_id: string, device_token: ISendNotificationInput) => {
const subscription = await Subscribe.findByUserAndDevice(fk_user_id, device_token);
if (subscription) {
throw new HttpException(401, "이미 구독 신청을 한 기기입니다.");
}
const newSubscriptionData = { fk_user_id, device_token };
const newSubscription = await Subscribe.create(newSubscriptionData);
const notificationData = {
messageType: "info",
title: "Pill my rhythm",
body: "영양제 스케줄 알림 기능을 활성화합니다.",
};
webpush.sendNotification(device_token, JSON.stringify(notificationData)).catch((error) => {
console.error(error);
throw new HttpException(500, error);
});
return newSubscription;
},
- 다음은 구독 취소 메소드 deleteSubscription 이다.
- 구독 정보가 없다면 구독한적이 없다고 오류를 띄워준다.
- 그리고 DB에서 삭제 전 마지막으로 더이상 구독하지 않는다는 내용의 Push 알림을 전송한다.
- 마지막으로 DB에서 구독 정보를 삭제한다.
deleteSubscription: async (fk_user_id: string, device_token: ISendNotificationInput) => {
const subscription = await Subscribe.findByUserAndDevice(fk_user_id, device_token);
if (!subscription) {
throw new HttpException(401, "구독 정보가 없는 기기입니다.");
}
const notificationData = {
messageType: "info",
title: "Pill my rhythm",
body: "영양제 스케줄 알림 기능을 더이상 구독하지 않습니다.",
};
await webpush.sendNotification(device_token, JSON.stringify(notificationData)).catch((error) => {
console.error(error);
throw new HttpException(500, error);
});
const unsubscription = await Subscribe.delete(device_token);
return unsubscription;
},
여기서 잠깐, sendNotification 메소드가 뭐고 어떻게 사용하는건데?!
💡 sendNotification(subscription, payload, options)
한 마디로 Service Worker에게 "push" 이벤트를 발생시키는 메소드
Service Worker는 대기중이다가 "push" 이벤트를 수신하면 showNotification으로 사용자에게 푸시 알림을 전송한다.
◽ subscription
DB에 저장해 둔 사용자 구독 정보
◽ payload
Service Worker에게 전송할 데이터
◽ options
Options for the GCM API key and vapid keys can be passed in if they are unique for each notification you wish to send (라고 나와있는데 사용하지 않음)
[ 03 Push 알림 전송 api ]
(1) Push Server
- 역시 라우터와 컨트롤러 계층 코드는 생략한다.
- 간단하게 이 메소드를 설명하면 다음과 같다.
- 우리 프로젝트의 스케줄러는 오전 6시부터 밤 12시까지 30분 간격으로 일정을 생성할 수 있다.
- 따라서 30분 간격의 시간을 query string으로 받아 그 시간에 설정된 영양제 "일정" 중 구독한 사용자들의 "구독 정보"와 설정해 둔 "영양제" 정보를 join해서 가공한 데이터로 각각의 사용자들에게 푸시 알림을 발송한다.
- 지금은 postman으로 테스트하지만 이후 30분 간격으로 자동으로 이 메소드가 실행되어 푸시 알림이 전송되도록 디벨롭 한 과정도 포스팅 할 예정이다.
- 먼저 필요한 데이터들을 조회하기 위해 DB 계층 쿼리 메소드를 작성한다.
- 일정을 기반으로 조회하기 때문에 Schedule 모델에서 시작해 필요한 테이블들을 하나씩 들리며 필요한 속성들만 응답하게 하는 쿼리이다.
- 관련하여 자세한 내용은 이 포스팅을 참고한다.
import { Op, col } from "./models";
import { Users } from "./models/user";
import { Schedules } from "./models/schedule";
import { Subscribes } from "./models/subscribe";
import { DailySupplements } from "./models/dailySupplement";
import { Supplements } from "./models/supplement";
const Schedule = {
findByOnlyTime: async (time: Date) => {
const supplementSchedules = await Schedules.findAll({
attributes: ["to_do"],
where: { type: "S", start: time },
include: {
required: true, // inner join
model: Users,
attributes: ["pk_user_id", "user_name", "email"],
include: [
{
required: true, // inner join
model: Subscribes,
attributes: ["device_token"],
},
{
required: false, // outer join, 영양제 일정만 있는 회원 정보도 불러오기
model: DailySupplements,
attributes: ["fk_supplement_id"],
where: {
"$User.DailySupplements.type$": {
[Op.eq]: col("Schedules.to_do"),
},
},
include: [
{
required: true, // inner join
model: Supplements,
attributes: ["name"],
},
],
},
],
},
});
const supplementSchedulesData = supplementSchedules.map((element) =>
element.get({ plain: true })
);
return supplementSchedulesData;
},
};
export { Schedule };
- 그리고 주된 기능을 수행하는 서비스 계층의 pushSupplementSchedules 메소드를 작성한다.
pushSupplementSchedules: async (time: Date) => {
const supplementSchedulesDataArray = await Schedule.findByOnlyTime(time);
supplementSchedulesDataArray.forEach(async (scheduleData: any) => {
const supplementArray: string[] = [];
for (const supplement of scheduleData.User.DailySupplements) {
supplementArray.push(supplement.Supplement.name);
}
// Today's Checklist 페이지에 쓸 refresh token(만료 기간 하루) 발급
const checklistToken = makeChecklistToken({
userId: scheduleData.User["pk_user_id"],
});
const pushData: pushData = {
name: scheduleData.User["user_name"],
when: scheduleData.to_do,
supplements: supplementArray.join(", "),
jwtToken: checklistToken,
};
const secretKey: any = process.env.SECRET_KEY;
const notificationData = {
messageType: "supplement",
title: `${pushData.name}님, ${pushData.when} 영양제 드실 시간이에요!`,
body: `${pushData.supplements} 영양제를 복용해주세요.`,
encryptedToken: AES.encrypt(pushData.jwtToken, secretKey).toString(),
};
const subscriptionArray = scheduleData.User.Subscribes;
for (const subscription of subscriptionArray) {
try {
await webpush.sendNotification(
subscription.device_token,
JSON.stringify(notificationData)
);
} catch (error) {
// 사용자 토큰 만료되면 에러 발생
if (error instanceof Error) {
// DB에서 만료된 기기 정보 삭제 후 메일 전송하기
await Subscribe.delete(subscription.device_token);
const resubscribeToken = makeResubscribeToken({
userId: scheduleData.User["pk_user_id"],
});
const emailData = {
user_name: scheduleData.User["user_name"],
email: scheduleData.User["email"],
encryptedToken: AES.encrypt(
resubscribeToken,
secretKey
).toString(),
};
await emailUtil.expirationEmail(emailData);
console.log(
`사용자 ${scheduleData.User["pk_user_id"]}의 토큰 정보가 만료되어 삭제 후 재갱신 메일을 전송했습니다.`
);
}
}
}
});
return supplementSchedulesDataArray;
},
'엘리스 AI트랙 4기 > 프로젝트' 카테고리의 다른 글
[PWA] Push Notification 알림 자동 발송하기 (feat. node-schedule) (1) | 2022.07.18 |
---|---|
[PWA] Push Notification 상황별 다른 알림 옵션 설정하기 (0) | 2022.07.16 |
[PWA] Push Notification 튜토리얼 따라해보기 (0) | 2022.07.11 |
[PWA] Push Notification 개요 (0) | 2022.07.11 |
[PWA] manifest.json 파일 설정으로 웹앱 홈 화면에 추가하기 (0) | 2022.06.29 |