최종 프로젝트 회고 #3 - nodemailer

DO YEON KIM·2024년 10월 15일
0


최종 프로젝트 내내 내 속을 썩였던 알림에 대해 작성해보고자 한다.


알림 기능의 구현 용도

캘린더 페이지에서 복약 기록을 동록할 수 있도록 하였는데, 기록 등록 시 원하는 알림 시간을 추가하여 약을 복용할 시간이 되었을 때, 알림을 받을 수 있도록 하였다.

1차 시도

처음엔 이메일로 받기엔 즉시 알림을 확인하기 어렵기 때문에 ux가 떨어지는 거 같아 알람을 바로 확인할 수 있는 web-push로 제작하였는데 .. 문제는 json-server환경에서만 돌아간다는 문제가 있었다.

다 짜놓은 코드라 아까워서 튜터님과 어떻게든 해결해보려 했지만 실패.

2차 시도

sms 알람을 보내기 위해 결제도 하고,, 파이어베이스도 팠지만 이번엔 vercel 배포 버전에서는 작동하지 않는 문제가 발생. 이또한 튜터님과 해결해보려했지만 실패하고 가장 안정적인 이메일 알람으로 변경하였따.

3차 시도

현 프로젝트의 알람 최종 버전. 이메일 알람이다.
처음엔 이메일로 알람이 올 때 단 한 개가 오는게 아니라 여러 개가 동시에 오는 문제가 잦아서 굉장히 고생한 기억이 있다. 또한 언젠가는 돌아가지 않을까,, 라는 마음에 삭제하지 않았던 코드와 새로 작성한 코드가 엉켜 고생한 경험도 있다.

아래는 내 애증의 코드다


파일 구조


🟡 src\utils\notificationMessage.ts

interface NotificationData {
  medi_nickname: string;
  medi_name: string;
  user_nickname: string;
  notes: string;
}

export function generateNotificationMessage(data: NotificationData) {
  const subject = `💊 MEDIHELP 💊 ${data.medi_nickname}을 먹을 시간이에요!`;
  const message = `
    💊 ${data.user_nickname}님이 설정하신 ${data.medi_nickname} (${data.medi_name})을 복용하실 시간입니다!

    💊 메모: ${data.notes}
  `;
  return { subject, message };
}

interface NotificationData {
  medi_nickname: string;
  medi_name: string;
  user_nickname: string;
  notes: string;
}

generateNotificationMessage 함수에 전달되는 데이터 객체의 구조를 정의한다.

+ 🟣 인터페이스는 ts에서 데이터의 형식을 정의하는 도구. 함수가 어떤 데이터를 받아야 하는지 명확하게 나타냄.


export function generateNotificationMessage(data: NotificationData) {
  const subject = `💊 MEDIHELP 💊 ${data.medi_nickname}을 먹을 시간이에요!`;
  const message = `
    💊 ${data.user_nickname}님이 설정하신 ${data.medi_nickname} (${data.medi_name})을 복용하실 시간입니다!

    💊 메모: ${data.notes}
  `;
  return { subject, message };
}

data는 NotificationData 타입의 데이터를 매개변수로 받는다.

이 함수는 객체를 반환 ( = 제목과 메세지 내용을 반환)


🟡 src\utils\scheduleEmail.ts

import cron from 'node-cron';
import { createClient } from '@supabase/supabase-js';
import { sendEmail } from './sendEmail';
import { generateNotificationMessage } from './notificationMessage';

const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);

function getKoreanTime() {
  return new Date(new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"}));
}

async function sendScheduledEmails() {
  const now = getKoreanTime();
  const currentTime = now.toTimeString().slice(0, 5);
  const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][now.getDay()];

  console.log(`Checking for emails to send at ${currentTime} on ${dayOfWeek}`);

  const { data: mediData, error } = await supabase
    .from('medications')
    .select(`
      *,
      users (
        email,
        nickname
      )
    `)
    .contains('day_of_week', [dayOfWeek])
    .contains('notification_time', [currentTime]);

  if (error) {
    console.error('Failed to fetch medication records:', error);
    return;
  }

  console.log(`Found ${mediData.length} medications to process`);

  for (const record of mediData) {
    if (!record.users) {
      console.log(`Skipping record ${record.id} due to missing user data`);
      continue;
    }

    const { subject, message } = generateNotificationMessage({
      medi_nickname: record.medi_nickname,
      medi_name: record.medi_name,
      user_nickname: record.users.nickname,
      notes: record.notes,
    });

    try {

      if(!record.is_sent){
        await sendEmail({
          to: record.users.email,
          subject,
          text: message,
        });

        const { data, error } = await supabase
        .from('medications')
        .update({...record, is_sent: true})
        .eq('id', record.id)
        .select();
  
      }
     

      console.log(`Email sent to ${record.users.email} for medication ${record.medi_nickname}`);
    } catch (emailError) {
      console.error('Failed to send email:', emailError);
    }
  }
}
// 매 분마다 스케줄링된 작업 실행
cron.schedule('* * * * *', sendScheduledEmails, {
  timezone: 'Asia/Seoul',
});

console.log('Email scheduling initialized');

import cron from 'node-cron';
import { createClient } from '@supabase/supabase-js';
import { sendEmail } from './sendEmail';
import { generateNotificationMessage } from './notificationMessage';

const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);

node-cron 라이브러리를 사용하여 주기적으로 작업을 실행

createClient를 사용하여 db에 정보를 저장하고, 약물 정보를 조회


function getKoreanTime() {
  return new Date(new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"}));
}

알람에서 오류가 많이 나서 최대한 오류가 안나도록 한국 시간으로도 맞춰줬다.


async function sendScheduledEmails() {
  const now = getKoreanTime();
  const currentTime = now.toTimeString().slice(0, 5); // 현재 시간을 HH:MM 형식으로 변환
  const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][now.getDay()]; // 요일을 한글로 구함

  console.log(`Checking for emails to send at ${currentTime} on ${dayOfWeek}`);

요일을 한국어로 설정해주고 한국 시간과 요일을 가져온다.


  const { data: mediData, error } = await supabase
    .from('medications')
    .select(`
      *,
      users (
        email,
        nickname
      )
    `)
    .contains('day_of_week', [dayOfWeek])
    .contains('notification_time', [currentTime]);

수파베이스 medications 테이블에서 현재 요일과 알림이 일치하는 데이터를 조회한 후, 알림이 설정된 요일과 현재 요일이 일치하는 데이터를 찾는다. 그리고 약에 관련된 사용자의 이메일과 닉네임을 가져온다.


  if (error) {
    console.error('Failed to fetch medication records:', error);
    return;
  }

  console.log(`Found ${mediData.length} medications to process`);

에러 시 로그에 기록. 정상적으로 불러와질 경우엔 콘솔에 약 개수 출력.


  for (const record of mediData) {
    if (!record.users) {
      console.log(`Skipping record ${record.id} due to missing user data`);
      continue;
    }

    const { subject, message } = generateNotificationMessage({
      medi_nickname: record.medi_nickname,
      medi_name: record.medi_name,
      user_nickname: record.users.nickname,
      notes: record.notes,
    });

이메일이나 닉네임이 없는 경우는 데이터를 건너 뛰고, 있는 경우엔 알림 메시지를 생성.


    try {
      if (!record.is_sent) {
        await sendEmail({
          to: record.users.email,
          subject,
          text: message,
        });

        const { data, error } = await supabase
          .from('medications')
          .update({ ...record, is_sent: true })
          .eq('id', record.id)
          .select();
      }

      console.log(`Email sent to ${record.users.email} for medication ${record.medi_nickname}`);
    } catch (emailError) {
      console.error('Failed to send email:', emailError);
    }
  }
}

sendemail 함수를 이용하여 이메일을 전송

중복 전송을 방지하기 위해 테이블에 있는 record.is_sent가 false인 경우에만 이메일을 전송하고, 이메일 전송 후 is_sent 필드를 true로 업데이트하여 같은 약물에 대한 알림이 반복되지 않게 했다.

전송 중 올가 발생하면 이를 로그에 기록.


cron.schedule('* * * * *', sendScheduledEmails, {
  timezone: 'Asia/Seoul',
});

console.log('Email scheduling initialized');

한국 시간을 기준으로 매 분마다 sendScheduledEmails함수를 실행.

한 번 이메일을 보낸 약물은 중복 전송을 방지하기 위해 is_sent 필드를 true로 업데이트.


🟡 src\utils\sendEmail.ts

import nodemailer from 'nodemailer';
import { generateNotificationMessage } from './notificationMessage';
const transporter = nodemailer.createTransport({
  host: 'smtp.naver.com',
  port: 465,
  secure: true, 
  auth: {
    user: process.env.EMAIL_USER,
    pass: process.env.EMAIL_PASS,
  },
});
interface MailOptions {
  to: string;
  subject: string;
  text: string;
  html?: string;
}
export const sendEmail = async (options: MailOptions) => {
  const { to, subject, text, html } = options;
  const mailOptions = {
    from: process.env.EMAIL_USER,
    to,
    subject,
    text,
    html,
  };

  console.log('Attempting to send email with options:', {
    ...mailOptions,
    text: mailOptions.text.substring(0, 100) + '...',  // 긴 텍스트는 일부만 로그에 출력
  });

  try {
    const info = await transporter.sendMail(mailOptions);
    console.log('Email sent successfully:', info);
    return info;
  } catch (error) {
    console.error('Failed to send email:', error);
    throw error;
  }
};



export default sendEmail;

import nodemailer from 'nodemailer';
import { generateNotificationMessage } from './notificationMessage';

nodemailer: 이메일 전송을 위한 Node.js 라이브러리로, SMTP(Simple Mail Transfer Protocol)를 사용하여 이메일을 전송한다.

+ 🟣 smtp란?

인터넷에서 이메일을 전송하고 수신 서버로 전달하는 표준 통신 프로토콜. 주로 클라이언트 - 서버, 서버 - 서버 간에 이메일ㅇ르 주고 받는데에 사용.


const transporter = nodemailer.createTransport({
  host: 'smtp.naver.com',
  port: 465,
  secure: true, 
  auth: {
    user: process.env.EMAIL_USER,
    pass: process.env.EMAIL_PASS,
  },
});

이메일을 보내기 위한 설정을 가진 객체.

서버의 호스트, 포트 번호 등을 설정해준다.


interface MailOptions {
  to: string;
  subject: string;
  text: string;
  html?: string;
}

이메일을 보낼 때 필요한 옵션들을 정의한 인터페이스.


export const sendEmail = async (options: MailOptions) => {
 const { to, subject, text, html } = options;
 const mailOptions = {
   from: process.env.EMAIL_USER,
   to,
   subject,
   text,
   html,
 };

이메일을 실제로 보내는 비동기 함수.

인터페이스로 받은 options를 기반으로 이메일 전송 옵션을 설정.


  console.log('Attempting to send email with options:', {
    ...mailOptions,
    text: mailOptions.text.substring(0, 100) + '...',  // 긴 텍스트는 일부만 로그에 출력
  });

이메일 전송 전에 콘솔에 이메일 전송 정보를 로그로 출력.


  try {
    const info = await transporter.sendMail(mailOptions);
    console.log('Email sent successfully:', info);
    return info;
  } catch (error) {
    console.error('Failed to send email:', error);
    throw error;
  }
};

export default sendEmail;

이메일 전송 및 에러 처리.

transporter.sendMail: 이메일 전송을 실제로 수행하는 메서드. mailOptions를 인수로 전달하여 이메일을 전송

결과를 콘솔에 기록

다른 파일에서 sendEmail을 가져와 사용할 수 있도록 export.

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

0개의 댓글