엘리스 AI트랙 4기/프로젝트

[PWA] Push Notification Nodejs 프로젝트에 적용하기

남쪽마을밤송이 2022. 7. 15. 21:57

* 이전 포스팅에서 이어지는 글입니다.

 

[PWA] Push Notification 튜토리얼 따라해보기

 [ 01 튜토리얼 선택 ]  (1) 영상 소개 이 자리를 빌어 나를 구원해 준 인상 좋은 개발자님께 감사의 인사를 드린다... 2일째 밤까지 아무 진전이 없어 좌절감만 느끼고 있었는데 이 분이 그나마

s0n9h2.tistory.com

  • 이전 포스팅에서 튜토리얼을 실습해봤으니 이제 본 프로젝트의 적재적소(?)에 넣어 적용하기만 하면 된다.
  • 나는 프론트 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&centerImageUrl=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;
  },