이 블로그에서는 사용자가 구독하고 뉴스레터 수신 빈도를 선택할 수 있는 뉴스레터 앱을 구축하겠습니다. Upstash Redis를 사용하겠습니다. 구독 데이터 및 Upstash 워크플로를 저장합니다. 데이터 저장, 환영 이메일 전송, 사용자 기본 설정에 따른 뉴스레터 예약 등의 작업을 관리합니다.
동기
우선, 서버리스 환경이 훌륭합니다! 확장성이 뛰어나고 예산이 저렴합니다. 그러나 실행 시간 제한과 같은 특정 제한 사항이 있습니다. 이는 장기 실행 작업을 실행해야 할 때 특히 문제가 될 수 있습니다.
Upstash 워크플로가 바로 여기에 있습니다. 작용합니다. Upstash Workflow를 사용하면 필요한 만큼 오랫동안 실행할 수 있는 지속적인 워크플로를 만들 수 있습니다. 따라서 더 이상 서버리스 기능 시간 초과에 대해 걱정할 필요가 없습니다.
Upstash Workflow를 사용할 때 얻을 수 있는 기능 목록은 다음과 같습니다:
- 더 이상 서버리스 기능 시간 초과가 없습니다 :워크플로는 필요한 만큼 오랫동안 실행될 수 있습니다.
- 자동 복구 :문제가 발생하여 작업 흐름이 중간에 실패하면 자동으로 복구됩니다.
- 자동 재시도 :워크플로의 어느 단계라도 실패하면 자동으로 다시 시도됩니다.
- 실시간 모니터링 :Upstash 콘솔에서 실시간으로 워크플로를 모니터링할 수 있습니다.
전제조건
- Next.js 애플리케이션에 대한 기본 이해
- Redis 및 QStash 토큰을 위한 Upstash 계정.
- 배포용 Vercel 계정.
- 로컬 개발을 위한 ngrok(권장)
프로젝트 설정
create-next-app을 사용하여 새로운 Next.js 프로젝트를 부트스트랩하는 것부터 시작해 보겠습니다. :
npx create-next-app@latest --typescript newsletter-app
cd newsletter-app 이제 Upstash QStash 및 Redis 서비스와 상호 작용하는 데 필요한 종속성을 추가해 보겠습니다.
npm install @upstash/qstash @upstash/redis 디렉터리 구조
코드를 살펴보기 전에 프로젝트를 어떻게 구성할지 간단히 살펴보겠습니다.
src/app/:여기에는 주요 애플리케이션 구성요소와 페이지가 위치하게 됩니다.src/app/api/:구독, 구독 취소 및 워크플로 처리를 위한 API 경로를 여기에 배치하겠습니다.src/components/:이 폴더에는 구독 및 구독 취소 양식 구성 요소가 포함됩니다.src/lib/:Redis 및 이메일 전송을 위한 유틸리티 기능이 여기에 있습니다.src/types/:TypeScript 유형 정의를 이 디렉토리에 보관합니다.
환경 변수
.env를 만들어야 합니다. 프로젝트 루트에 파일을 만들고 다음을 추가하세요:
QSTASH_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
EMAIL_SERVICE_URL=
NEXT_PUBLIC_BASE_URL= - QSTASH_TOKEN :Upstash 콘솔에서 액세스되는 Upstash QStash 토큰.
- UPSTASH_REDIS_REST_URL 및 UPSTASH_REDIS_REST_TOKEN :Upstash 콘솔에서 액세스한 Upstash Redis 자격 증명.
- EMAIL_SERVICE_URL :이메일 전송 API의 엔드포인트입니다.
- NEXT_PUBLIC_BASE_URL :배포된 애플리케이션의 기본 URL(예:
https://your-app.vercel.app) ).
UPSTASH_WORKFLOW_URL를 설정할 수도 있습니다. .env의 변수 ngrok URL을 사용하여 로컬 개발용 파일을 만듭니다. ngrok를 사용하여 로컬에서 워크플로를 개발하는 방법에 대해 자세히 알아보려면 Upstash 문서를 참조하세요.
UPSTASH_WORKFLOW_URL 환경 변수는 로컬 개발에만 필요합니다. 프로덕션에서는 baseUrl 매개변수는 자동으로 설정되며 생략 가능합니다.
프로젝트 구현
구독 양식 구성요소
SubscriptionForm 구성 요소를 통해 사용자는 이메일을 입력하고 뉴스레터 수신 빈도를 선택할 수 있습니다. 양식이 제출되면 /api/subscribe로 POST 요청을 보냅니다. 양식 데이터로.
"use client";
import React, { useState } from "react";
export default function SubscriptionForm() {
const [frequency, setFrequency] = useState("daily");
const [showCustomFrequency, setShowCustomFrequency] = useState(false);
const [message, setMessage] = useState("");
const [isError, setIsError] = useState(false);
// Handle frequency selection
const handleFrequencyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setFrequency(value);
setShowCustomFrequency(value === "custom");
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setMessage("");
setIsError(false);
const formData = new FormData(e.currentTarget);
try {
const response = await fetch("/api/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(Object.fromEntries(formData.entries())),
});
const result = await response.json();
if (!response.ok) {
setIsError(true);
setMessage(result.error || "An error occurred during subscription.");
} else {
setIsError(false);
setMessage(result.message || "Subscription successful!");
}
} catch (error) {
console.error("An unexpected error occurred:", error);
setIsError(true);
setMessage("An unexpected error occurred.");
}
};
// Render the form
return (
<form className="flex flex-col gap-4 text-gray-700" onSubmit={handleSubmit}>
<input
type="email"
name="email"
placeholder="Your Email"
required
className="border p-2 rounded"
/>
<select
name="frequency"
value={frequency}
onChange={handleFrequencyChange}
required
className="border p-2 rounded text-gray-700"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="custom">Custom Amount of Days</option>
</select>
{showCustomFrequency && (
<input
type="number"
name="customFrequency"
placeholder="Enter number of days"
min="1"
className="border p-2 rounded text-gray-700"
required
/>
)}
<button type="submit" className="bg-blue-500 text-white p-2 rounded">
Subscribe
</button>
{message && (
<p className={`mt-2 ${isError ? "text-red-500" : "text-green-500"}`}>
{message}
</p>
)}
</form>
);
} 구독 취소 양식 구성요소
UnsubscribeForm 구성 요소를 사용하면 사용자가 이메일을 입력하여 뉴스레터 구독을 취소할 수 있습니다. 양식이 제출되면 /api/unsubscribe으로 POST 요청을 보냅니다. 이메일 데이터로. 또한 사용자가 이메일 중 하나에서 수신 거부 링크를 클릭한 경우 이메일 필드가 미리 채워집니다.
"use client";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
const UnsubscribeForm = () => {
const searchParams = useSearchParams();
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [isError, setIsError] = useState(false);
// Pre-fill email from query parameter
useEffect(() => {
const emailParam = searchParams.get("email");
if (emailParam) {
setEmail(emailParam);
}
}, [searchParams]);
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMessage("");
setIsError(false);
try {
const response = await fetch("/api/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setIsError(false);
setMessage("You have been unsubscribed successfully.");
} else {
setIsError(true);
setMessage(data.error || "Something went wrong. Please try again.");
}
} catch (error) {
console.error("Error unsubscribing:", error);
setIsError(true);
setMessage("An unexpected error occurred. Please try again.");
}
};
// Render the form
return (
<form className="flex flex-col gap-4 text-gray-700" onSubmit={handleSubmit}>
<input
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Your Email"
required
className="border p-2 rounded"
/>
<button
type="submit"
className="bg-red-500 hover:bg-red-700 text-white p-2 rounded"
>
Unsubscribe
</button>
{message && (
<p className={`mt-2 ${isError ? "text-red-500" : "text-green-500"}`}>
{message}
</p>
)}
</form>
);
};
export default function UnsubscribePage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UnsubscribeForm />
</Suspense>
);
} Redis에 데이터 저장
Upstash Redis를 사용하여 사용자 구독 데이터를 저장하겠습니다.
Upsatsh Redis를 사용하려면 먼저 Upstash 콘솔에 Redis 데이터베이스를 설정하고 REST URL과 토큰을 가져와야 합니다. 이에 대한 자세한 내용은 Upstash 문서를 확인하세요.
redis.ts Redis와 상호 작용하기 위한 Redis 클라이언트 및 도우미 기능이 포함됩니다:
import { Redis } from "@upstash/redis";
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export async function getUserFrequency(email: string): Promise<number | null> {
const data = await redis.get(`user:${email}`);
console.log("User data:", data);
if (!data) return null;
const parsed = JSON.parse(JSON.stringify(data));
return parsed.frequency;
}
export async function removeUser(email: string): Promise<void> {
await redis.del(`user:${email}`);
}
export async function checkSubscription(email: string): Promise<boolean> {
return (await getUserFrequency(email)) !== null;
} 이메일 전송 기능
이메일을 보내려면 QStash Python SDK를 사용하여 이메일 스케줄러를 만드는 방법에 대한 이전 블로그 게시물에서 개발한 자체 이메일 API를 사용하겠습니다.
src/lib/email.tsexport async function sendEmail(message: string, email: string) {
console.log(`Sending email to ${email}`);
const url = process.env.EMAIL_SERVICE_URL;
const payload = {
to_email: email,
subject: "Upstash Newsletter",
content: message,
};
if (!url) {
console.error("EMAIL_SERVICE_URL is not defined.");
return;
}
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error("Failed to send email:", await response.text());
}
} 유형 정의
구독 데이터에 대한 유형 정의도 필요합니다:
src/types/index.tsexport type SubscriptionData = {
email: string;
frequency: string;
customFrequency?: string;
}; API 경로 구독
구독 요청을 처리하는 API 경로를 생성하겠습니다. 사용자가 구독 양식을 제출하면 이 엔드포인트는 사용자가 이미 구독했는지 확인하고 사용자가 선택한 빈도에 따라 이메일 전송을 처리하는 워크플로를 대기열에 추가합니다.
src/app/api/subscribe/route.tsimport { NextRequest, NextResponse } from "next/server";
import { checkSubscription } from "@/lib/redis";
export const POST = async (request: NextRequest) => {
try {
const { email, frequency: freq, customFrequency } = await request.json();
console.log("Email:", email);
console.log("Frequency:", freq);
console.log("Custom Frequency:", customFrequency);
if (!email || !freq) {
console.error("Email and frequency are required.");
return NextResponse.json(
{ error: "Email and frequency are required." },
{ status: 400 }
);
}
let frequency = freq;
if (frequency === "custom") {
if (!customFrequency) {
console.error("Custom frequency days are required.");
return NextResponse.json(
{ error: "Custom frequency days are required." },
{ status: 400 }
);
}
frequency = customFrequency;
}
if (frequency === "daily") {
frequency = "1";
} else if (frequency === "weekly") {
frequency = "7";
} else if (frequency === "monthly") {
frequency = "30";
}
const frequencyNumber = Number(frequency);
if (isNaN(frequencyNumber) || frequencyNumber <= 0) {
console.error("Invalid frequency value.");
return NextResponse.json(
{ error: "Invalid frequency value." },
{ status: 400 }
);
}
const exists = await checkSubscription(email);
if (exists) {
console.error("Email is already subscribed.");
return NextResponse.json(
{ error: "Email is already subscribed." },
{ status: 400 }
);
}
console.log("Subscription successful!");
console.log("Enqueue the workflow");
// Enqueue the workflow
await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/workflow`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QSTASH_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
frequency: frequencyNumber,
}),
})
.then((response) => {
if (!response.ok) {
console.error("Failed to enqueue workflow:", response.statusText);
return NextResponse.json(
{ error: "Failed to enqueue workflow." },
{ status: 500 }
);
} else {
console.log("Workflow enqueued successfully");
}
})
.catch((error) => {
console.error("Error enqueuing workflow:", error);
return NextResponse.json(
{ error: "Error enqueuing workflow." },
{ status: 500 }
);
});
return NextResponse.json({ message: "Subscription successful!" });
} catch (error) {
console.error("Error occurred:", error);
return NextResponse.json(
{ error: "An error occurred during subscription." },
{ status: 500 }
);
}
}; API 경로 구독 취소
구독 경로가 있으므로 구독 취소 경로도 필요합니다. 요청이 접수되면 사용자가 구독했는지 확인하고 Redis에서 해당 데이터를 제거합니다. 확인 이메일도 보내드리겠습니다.
src/app/api/unsubscribe/route.tsimport { NextRequest, NextResponse } from "next/server";
import { redis } from "@/lib/redis";
import { sendEmail } from "@/lib/email";
export const POST = async (request: NextRequest) => {
try {
const { email } = await request.json();
if (!email) {
return NextResponse.json(
{ error: "Email is required." },
{ status: 400 }
);
}
const userExists = await redis.exists(`user:${email}`);
if (!userExists) {
return NextResponse.json(
{ error: "Email is not subscribed." },
{ status: 400 }
);
}
// Remove the user from Redis
await redis.del(`user:${email}`);
// Send an email to confirm unsubscription
await sendEmail(
"You have been unsubscribed from Upstash Newsletter.",
email
);
return NextResponse.json({ message: "You have been unsubscribed." });
} catch (error) {
console.error("Unsubscribe error:", error);
return NextResponse.json(
{ error: "An error occurred. Please try again." },
{ status: 500 }
);
}
}; 워크플로 API 경로
이제 재미있는 부분이 있습니다! 지정된 빈도 간격으로 뉴스레터를 보내기 위한 워크플로를 처리하는 API 경로를 생성하겠습니다.
우리의 작업 흐름은 다음을 수행합니다:
- 사용자의 구독 데이터를 Redis에 저장합니다.
- 환영 이메일을 보내세요.
- 루프 입력:
- 지정된 빈도 기간 동안 기다립니다.
- 사용자가 아직 구독 중인지 확인하세요.
- 뉴스레터 이메일을 보냅니다.
- 무한 루프를 원하지 않으므로 설정된 수의 뉴스레터가 전송될 때까지 반복합니다.
다음은 뉴스레터를 구독하고 단일 뉴스레터를 받은 후 구독을 취소한 사용자를 위한 완료된 워크플로의 예입니다.

Upstash 콘솔에서 작업 흐름에 액세스하고 모니터링할 수 있습니다.
메인 페이지 구성요소
애플리케이션의 메인 페이지를 설정해 보겠습니다. 이 페이지에는 구독 양식과 구독 취소 페이지 링크가 포함되어 있습니다.
src/app/page.tsximport SubscriptionForm from "@/components/SubscriptionForm";
import Link from "next/link";
export default function Home() {
return (
<main className="flex flex-col items-center justify-center min-h-screen p-4">
<h1 className="text-3xl font-bold mb-6">
Subscribe to Upstash Newsletter
</h1>
{/* Subscription Form */}
<SubscriptionForm />
{/* Unsubscribe Link */}
<div className="mt-8">
<p className="text-gray-600">
Already subscribed and want to unsubscribe?
<Link
href="/unsubscribe"
className="text-red-500 hover:text-red-700 font-bold ml-2"
>
Click here to unsubscribe
</Link>
</p>
</div>
</main>
);
} 구독 취소 페이지 구성 요소
마지막으로 구독 취소 페이지를 만들어 보겠습니다.
src/app/unsubscribe/page.tsximport UnsubscribePage from "@/components/UnsubscribeForm";
export default function UnsubscribeHome() {
return (
<main className="flex flex-col items-center justify-center min-h-screen p-4">
<h1 className="text-3xl font-bold mb-6">
Unsubscribe from Upstash Newsletter
</h1>
{/* Unsubscribe Form */}
<UnsubscribePage />
</main>
);
} 결론
그리고 거기에 있습니다! 우리는 서버리스 기능 시간 초과에 대한 걱정 없이 간단한 뉴스레터 앱을 구축했습니다.
GitHub에서 이 프로젝트의 전체 소스 코드를 찾을 수 있으며 여기에서 라이브 데모를 확인할 수 있습니다.
Upstash 워크플로우에 대한 자세한 내용은 Upstash 문서를 참조하세요.
궁금한 점이 있으시면 Discord를 통해 언제든지 문의해 주세요. 또한 더 많은 튜토리얼과 사용 사례를 보려면 Upstash 블로그를 탐색하는 것을 잊지 마세요.