Computer >> 컴퓨터 >  >> 프로그래밍 >> Redis

원하는 시간에 정확하게: QStash로 구현하는 시간대 인식 이메일 스케줄링

docsly에서는 지난주 또는 지난 한 달간 받은 피드백을 요약해 사용자에게 이메일로 알려주는 새로운 기능을 출시했습니다. 이메일 발송 자체는 낯선 문제가 아니지만, 최고의 사용자 경험을 제공하기 위해 모든 이메일을 사용자의 시간대에 맞춰 발송하기로 결정했습니다. 이를 통해 이른 새벽이나 늦은 밤처럼 어색한 시간에 이메일이 전달되는 상황을 피할 수 있습니다. 아울러 사용자가 이메일 수신 빈도를 직접 선택할 수 있게 하고, 예약된 이메일 알림을 언제든지 취소할 수 있는 기능도 함께 제공하고자 했습니다.

다만 이 솔루션을 구현하는 과정은 생각보다 까다로웠습니다.

  1. 사용자의 시간대 정보를 데이터베이스에 저장하고 싶지 않았습니다.
  2. 매분 또는 매시간 크론(cron) 작업을 돌려가며 이메일 발송 시점인지 확인하고 싶지 않았습니다.
  3. 사용자가 예약된 이메일 알림을 자유롭게 취소할 수 있어야 했습니다.

그래서 우리는 독특한 해결책을 고안했습니다. 바로 사용자의 시간대에 맞춰 크론 작업을 예약하고, 사용자가 이메일 알림을 취소하면 해당 크론 작업도 함께 취소하는 방식입니다. 남은 질문은 단 하나였습니다. '어떻게?' 하는 것이죠.

우리는 이미 Redis 스토어로 Upstash를 사용하고 있었고, 조사 끝에 QStash가 Schedules(일정 예약) 기능도 지원한다는 사실을 알게 되었습니다. 좀 더 들여다보니 QStash가 CRON 표현식까지 지원했고, 그래서 크론 작업 예약에 QStash를 활용하기로 결정했습니다.

이 글에서는 Next.js 애플리케이션에서 QStash와 Upstash Redis를 활용해 사용자의 시간대에 맞춰 이메일을 예약 발송하는 과정을 단계별로 살펴보겠습니다. 완성된 소스 코드는 GitHub에서도 확인할 수 있습니다.

사용자 시간대에 맞춰 이메일을 예약하는 Next.js 애플리케이션 만들기

사전 준비물

이 튜토리얼을 따라 하려면 다음 항목이 필요합니다.

  • Upstash 계정
  • Node.js 개발 환경

프로젝트 설정

먼저 다음 명령어로 새로운 Next.js 프로젝트를 생성합니다.

npx create-next-app qstash-email-scheduling

다음으로 Upstash와 통신하기 위한 의존성을 설치합니다.

npm install --save @upstash/redis axios

프로젝트 루트에 .env.local 파일을 생성하고, Upstash 계정에서 발급받은 아래 환경 변수들을 추가합니다.

UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
QSTASH_URL=
QSTASH_TOKEN=
QSATSH_CURRENT_SIGNING_KEY=
QSATSH_NEXT_SIGNING_KEY=

솔루션 개요

코드 구현에 앞서 전체적인 솔루션 구조부터 살펴보겠습니다. 세 개의 Next.js API 라우트를 생성합니다.

  • POST /api/schedule-cron — 사용자의 시간대에 맞춰 이메일 크론 작업을 예약합니다.
  • POST /api/cancel-schedule — 예약된 이메일 크론 작업을 취소합니다.
  • POST /api/send-email — 예약된 크론 작업에 의해 트리거되어 이메일을 실제로 발송합니다.

API 라우트 외에도, 사용자가 원하는 이메일 수신 시간을 선택할 수 있는 간단한 폼도 함께 만들겠습니다.

사용자 인터페이스 만들기

사용자 인터페이스를 위해 app/page.tsx 파일에 다음 코드로 새 페이지를 생성합니다.

"use client";
 
import { useState } from "react";
 
import axios from "axios";
 
export default function Home() {
 const userId = "tony-stark-11";
 const [selectedTime, setSelectedTime] = useState("10:00");
 
 async function createEmailNotificationSchedule() {
 try {
 await axios.post(
 "/api/schedule-cron",
 {
 userId,
 selectedTime,
 utcOffset: new Date().getTimezoneOffset(),
 },
 {
 headers: {
 "Content-Type": "application/json",
 },
 },
 );
 alert("Email notification scheduled");
 } catch (e) {
 console.log("Client side error", e);
 alert("Error scheduling email notification");
 }
 }
 
 async function cancelEmailNotificationSchedule() {
 try {
 await axios.post(
 "/api/cancel-schedule",
 {
 userId,
 },
 {
 headers: {
 "Content-Type": "application/json",
 },
 },
 );
 alert("Email notification schedule cancelled");
 } catch (e) {
 console.log("Client side error", e);
 alert("Error scheduling email notification");
 }
 }
 
 return (
 <main className="mx-auto flex min-h-screen max-w-md flex-col justify-center p-24">
 <h1 className="mb-4 text-xl font-bold text-neutral-600">
 Email Notification for {userId}
 </h1>
 Send daily email summary at:
 <select
 onChange={(e) => setSelectedTime(e.target.value)}
 className="mt-4 h-12 w-64 rounded-lg border-2 border-neutral-600 bg-neutral-800 p-2
 text-white"
 >
 {new Array(24).fill(0).map((_, i) => {
 const time = i < 10 ? `0${i}:00` : `${i}:00`;
 return (
 <option key={i} value={time}>
 {time}
 </option>
 );
 })}
 </select>
 <button
 className="mt-4 rounded bg-green-700 px-4 py-2 text-white"
 onClick={createEmailNotificationSchedule}
 >
 Schedule
 </button>
 <button
 className="mt-4 rounded bg-red-500 px-4 py-2 text-white"
 onClick={cancelEmailNotificationSchedule}
 >
 Cancel Schedule
 </button>
 </main>
 );
}

위 코드는 드롭다운 메뉴에서 사용자가 매일 받을 이메일 요약의 선호 시간을 설정할 수 있는 폼을 만듭니다. 사용자는 알림을 예약하거나 취소할 수 있으며, 애플리케이션은 HTTP POST 요청을 통해 서버와 통신합니다. HTTP 엔드포인트는 이어지는 섹션에서 차례로 만들어 보겠습니다.

원하는 시간에 정확하게: QStash로 구현하는 시간대 인식 이메일 스케줄링

이메일 크론 작업 예약하기

원하는 시간에 정확하게: QStash로 구현하는 시간대 인식 이메일 스케줄링

먼저 POST /api/schedule-cron 라우트부터 만들어 보겠습니다. 이 라우트는 사용자의 시간대에 맞춰 이메일 크론 작업을 예약하는 데 사용되며, 크론 작업 예약에는 QStash 라이브러리를 활용합니다.

import { NextApiRequest, NextApiResponse } from "next";
 
import { Redis } from "@upstash/redis";
import axios from "axios";
 
export const QSTASH_CONFIG = {
 QSTASH_URL: process.env.QSTASH_URL,
 QSTASH_TOKEN: process.env.QSTASH_TOKEN,
 QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY,
};
 
export const upstash = new Redis({
 url: process.env.UPSTASH_REDIS_REST_URL!,
 token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
 
// Edit this endpoint to match your domain
const SUMMARY_ENDPOINT = "https://<your-domain>/api/send-email";
 
export default async function scheduleSummary(
 req: NextApiRequest,
 res: NextApiResponse,
) {
 console.log("========SCHEDULE SUMMARY========");
 if (req.method !== "POST") {
 return res.status(400).json({ message: "bad request" });
 }
 const { body } = req;
 
 const { userId, selectedTime, utcOffset } = body;
 
 const emailScheduleKey = `email-schedule-${userId}`;
 
 const scheduleId = await upstash.get(emailScheduleKey);
 
 // remove existing schedule before creating a new one
 if (scheduleId) {
 try {
 await axios.delete(
 `https://qstash.upstash.io/v1/schedules/${scheduleId}`,
 {
 headers: {
 Authorization: `Bearer ${QSTASH_CONFIG.QSTASH_TOKEN}`,
 },
 },
 );
 } catch (e) {
 console.log("Schedule not found in QStash ");
 }
 await upstash.del(emailScheduleKey);
 }
 
 const [hour, min] = convertToUTC(selectedTime, utcOffset).split(":");
 const selectedCron = `${min} ${hour} * * *`;
 
 // create and store new schedule
 try {
 const { data, status } = await axios.post(
 `${QSTASH_CONFIG.QSTASH_URL}${SUMMARY_ENDPOINT}`,
 { userId },
 {
 headers: {
 "Content-Type": "application/json",
 Authorization: `Bearer ${QSTASH_CONFIG.QSTASH_TOKEN}`,
 "Upstash-Cron": selectedCron,
 },
 },
 );
 console.log({ data, status });
 if (data.scheduleId) {
 await upstash.set(emailScheduleKey, data.scheduleId);
 }
 } catch (e) {
 console.log({ e });
 }
 
 return res.status(200).json({ message: "success" });
}
 
function convertToUTC(timeString: string, utcOffset: number) {
 const [hours, minutes] = timeString.split(":").map(Number);
 const timeInMinutes = hours * 60 + minutes;
 const utcTimeInMinutes = (timeInMinutes + utcOffset + 1440) % 1440;
 const utcHours = Math.floor(utcTimeInMinutes / 60);
 const utcMinutes = utcTimeInMinutes % 60;
 return `${utcHours.toString().padStart(2, "0")}:${utcMinutes
 .toString()
 .padStart(2, "0")}`;
}

이 코드의 핵심은 API 엔드포인트 역할을 하는 scheduleSummary 함수입니다. 이 함수는 들어오는 POST 요청을 처리하며 다음 단계를 순차적으로 수행합니다.

  1. 요청 메서드가 POST인지 검증합니다.
  2. 요청 본문에서 userId, selectedTime, utcOffset 데이터를 추출합니다.
  3. Upstash 데이터베이스에 저장할 사용자의 이메일 일정 키를 생성합니다.
  4. 해당 사용자의 기존 일정이 있다면 조회한 뒤 QStash에서 삭제합니다.
  5. 시간 문자열과 UTC 오프셋을 받아 시간대 차이를 반영해 UTC 형식으로 변환하는 convertToUTC 함수를 사용해, 사용자가 선택한 시간을 UTC로 변환합니다.
  6. QStash의 일정 예약 기능으로 이메일 요약 발송 일정을 새로 생성하고, /api/send-email 엔드포인트가 수신할 페이로드에 userId를 담아 전달합니다.
  7. 새로 생성된 일정 ID를 Upstash 데이터베이스에 저장합니다.

이메일 요약 발송하기

POST /api/send-email 라우트는 예약된 크론 작업에 의해 트리거되어 이메일을 발송합니다. 여기서는 요청의 서명을 검증하기 위해 @upstash/qstash/nextjs 라이브러리를 사용합니다. 덕분에 요청이 다른 출처가 아닌 QStash에서 온 것임을 확실히 보장할 수 있습니다.

POST /api/send-email 함수는 요청 본문에서 userId를 전달받습니다. 이 userId를 기반으로 이메일을 준비하고 발송하는 로직을 직접 구현하면 됩니다.

import { NextApiRequest, NextApiResponse } from "next";
 
import { verifySignature } from "@upstash/qstash/nextjs";
 
async function handler(request: NextApiRequest, res: NextApiResponse) {
 console.log("==========Project summary handler==========");
 if (request.method !== "POST") {
 return res.status(400).json({ message: "bad request" });
 }
 const { body } = request;
 const { userId } = body;
 
 // prepare and send email
 
 return res.status(200).json({ message: "success" });
}
 
export default verifySignature(handler);
 
export const config = {
 api: {
 bodyParser: false,
 },
};

예약된 이메일 크론 작업 취소하기

원하는 시간에 정확하게: QStash로 구현하는 시간대 인식 이메일 스케줄링

이번에는 POST /api/cancel-schedule 라우트를 만들어 보겠습니다. 이 라우트는 예약된 이메일 크론 작업을 취소하는 데 사용되며, 마찬가지로 QStash 라이브러리를 통해 크론 작업을 취소합니다.

import { NextApiRequest, NextApiResponse } from "next";
import axios from "axios";
import { Redis } from "@upstash/redis";
 
export const QSTASH_CONFIG = {
 QSTASH_URL: process.env.QSTASH_URL,
 QSTASH_TOKEN: process.env.QSTASH_TOKEN,
 QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY,
};
 
export const upstash = new Redis({
 url: process.env.UPSTASH_REDIS_REST_URL!,
 token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
 
export default async function scheduleSummary(
 req: NextApiRequest,
 res: NextApiResponse
) {
 console.log("========REMOVE SCHEDULE SUMMARY========");
 if (req.method !== "POST") {
 return res.status(400).json({ message: "bad request" });
 }
 const { body } = req;
 
 const { userId } = body;
 
 const emailScheduleKey = `email-schedule-${userId}`;
 
 const scheduleId = await upstash.get(emailScheduleKey);
 
 // remove existing schedule before creating a new one
 if (scheduleId) {
 try {
 await axios.delete(
 `https://qstash.upstash.io/v1/schedules/${scheduleId}`,
 {
 headers: {
 Authorization: `Bearer ${QSTASH_CONFIG.QSTASH_TOKEN}`,
 },
 }
 );
 } catch (e) {
 console.log("Schedule not found in QStash ");
 }
 await upstash.del(emailScheduleKey);
 }
 
 
 return res.status(200).json({ message: "success" });
}

마무리

정리하자면, 우리는 Upstash Redis와 QStash를 활용해 사용자의 시간대에 맞춰 이메일을 예약 발송하는 시스템을 성공적으로 구축했습니다. 그 과정에서 사용자의 시간대 정보를 데이터베이스에 저장하지 않았고, 사용자가 언제든지 예약된 이메일 알림을 취소할 수 있는 기능까지 구현했습니다.

제품의 기술 문서를 관리하고 계신다면 docsly를 확인해 보세요. docsly는 기술 문서를 위해 특별히 설계된 피드백 도구로, 사용자 피드백을 수집하고 이를 실행 가능한 인사이트로 전환하는 데 큰 도움을 줍니다.