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

[PWA] Push Notification 외전2. chrome에서의 subscription token 만료 문제 (feat. nodemailer)

남쪽마을밤송이 2022. 7. 31. 02:57

 [01 문제 상황] 

  • 기능을 구현해놓고 구체화하며 테스트를 진행하는데, 계속해서 토큰이 만료되어 만료된 토큰에 푸시 알림을 보내려 하면 서버가 죽는 현상이 일어났다.
  • 서버가 죽는건 에러 처리를 하지 않았기 때문이므로 try catch문으로 해결해줬는데, 토큰이 만료되는 문제는 내 문제가 아니기 때문에 해결 방법을 몰랐다.
  • 구글링 결과, 만료시 pushsubscriptionchange 이벤트리스너로 service worker에게 새로운 구독 정보로 갱신해달라고 하면 되는데 중요한 점은 chorome은 해당 이벤트를 지원하지 않는다는 것이었따..?!
    • firefox만 지원하는건 너무하잖아 ㅠㅠ

  • 따라서 이런 글들을 읽으며... 나는 어떻게 처리할지 고민했다.
    • 실서비스에서 PWA를 사용하면 무조건 이런 문제가 있을텐데 최근글을 많이 못찾았다...
    • 스택오버플로우의 어떤 댓글들에서는 chrome도 가능하다고 해서 헷갈리는데 공식적으로는 지원하지 않는게 맞았다.

 

 [02 해결 방안 ] 

  • 먼저 만료된 구독 정보는 그냥 DB에서 바로 soft delete 처리했다.
  • 그러는 동시에 사용자에게 서비스를 다시 구독할 수 있는 QR과 링크를 첨부한 메일을 전송한다.

 

 [03 코드 ] 

 (1) subscribeService.ts 

  • 이전에 만들어 둔 모바일 구독 페이지와 QR코드를 첨부해서 email을 전송한다.
import webpush from "web-push";
import { AES } from "crypto-js";
import { Subscribe } from "../db/Subscribe";
import { Schedule } from "../db/Schedule";
import { pushData } from "../interfaces/subscribeInput";
import { HttpException } from "../utils/error-util";
import { makeChecklistToken, makeResubscribeToken } from "../utils/jwt-util";
import { emailUtil } from "../utils/emailUtil";

const SubscribeService = {
  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;
  },
};

export { SubscribeService };

 (2) emailUtil.ts 

  • 메일 내용은 html을 사용할 수 있는데 이번엔 위에서 봤다시피 css를 좀 신경써서 작성해봤다.
    • 참고로 inline-style 관련 설정( flex-direction: colume )이 제대로 적용되지 않아 확인해보니 HTML 버전이 낮다나 그래서 결국  padding-left 로 배치를 조정했다.
import nodemailer from "nodemailer";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

const transporter = nodemailer.createTransport({
  service: "gmail", //사용하고자 하는 서비스
  port: 587,
  host: "smtp.gmail.com",
  secure: true, // true for 587, false for other ports
  requireTLS: true,
  auth: {
    user: process.env.GMAIL_ID, // gmail 주소
    pass: process.env.GMAIL_PASSWORD, // gmail 패스워드 혹은 앱 비밀번호
  },
});

interface expirationData {
  user_name: string;
  email: string;
  encryptedToken: string;
}

interface pushErrorData {
  developers: string[];
  time: Date;
  errorContent: {
    syscall: string;
    code: string;
    errno: string;
  };
}

const emailUtil = {
  expirationEmail: async (data: expirationData) => {
    const pageLink = `${process.env.APP_MODE}:${process.env.FRONT_PORT}/m/subscribe?token=${data.encryptedToken}`;
    const encodedPageLink = encodeURIComponent(
      `${process.env.APP_MODE}:${process.env.FRONT_PORT}/m/subscribe?token=${data.encryptedToken}`
    );
    const QRcode = `https://quickchart.io/qr?text=${encodedPageLink}&ecLevel=L&size=200&centerImageUrl=https://ifh.cc/g/Y4Z5z3.png`;

    await transporter
      .sendMail({
        from: process.env.GMAIL_ID, // 보내는 주소 입력
        to: data.email, // 위에서 선언해준 받는사람 이메일
        subject: `안녕하세요, Pill my rhythm 서비스입니다.`, // 메일 제목
        // 내용
        html:
          `<div style="display: grid; justify-content: center; align-items: center; width: 100%; height: 100%">` +
          `<br />` +
          `<p style="color: black"><b>${data.user_name}</b>님 안녕하세요, 회원님의 구독 정보가 <b>만료</b>되었습니다.</p>` +
          `<p style="color: black">저희의 서비스에 만족하셨다면 아래의 재구독 버튼이나 QR 코드로 접속하여 다시 <b>구독 버튼</b>을 눌러주세요.</p>` +
          `<br />` +
          `<div style="width: 620px; height: 250px; border: 4px solid #00dfd7; border-radius: 4px">` +
          `<br /><div style="padding-left: 250px">
            <p><a href='${pageLink}' target="_blank"><button style="color: #268c88; border: 0px; border-radius: 4px; width: 88pt; height: 20pt">
            <b>재구독하러 가기</b>
            </button></a></p>` +
          `<img src=${QRcode} alt="QRcode" width="120" height="120" /></div>` +
          `<div style="padding-left: 180px"><p style="color: #8f8f8f; font-size: 9px">* QR 코드 접속은 Android에서만 가능합니다. (ios는 불가능)</p></div>` +
          `<br />
            </div>
            <br />
            </div>`,
      })
      .then((res) => {
        console.log(`${data.email}님께 재구독 요청 메일을 보냈습니다.`);
        if (res.rejected.length != 0) {
          console.log(`${res.rejected[0]}`);
        }
      });
  },
};

export { emailUtil };

 

 [04 한계점 ] 

  • 이렇게 해도 사용자가 직접 재구독을 해야 하고, 구독 갱신 주기가 어떻게 되는지도 확실하지 않아 대처를 할수가 없다.
  • 그래서 사실 chrome에서 pushsubscriptionchange 이벤트가 지원되는게 가장 좋은데 지원되지 않는게 맞는 것 같지만 됐다는 사람들도 있어 시간 날 때 추가해봐야겠다.
    • 추가를 해도 sevice worker가 관리하는 구독 정보를 강제로 만료시킬 방법은 없어서 테스트하기도 힘들다...휴
    • 좋은 방법을 알고 있는 분이 나타나서 댓글을 달아주면 좋겠다.

 

 [05 마무리 ] 

  • 어쨌든 여기까지 해서 내가 웹 푸시 알림 기능을 구현하며 겪었던 시행착오, 구현 순서, 디벨롭한 부분을 꽤나 상세하게 적어봤다.
  • 중간 중간 빠진 내용이 있는 것 같긴 한데 처음부터 다시 읽어보며 생각날 때마다 틈틈이 채워넣어야겠다.