이 튜토리얼을 시작하기에 앞서 몇 가지 전제 조건이 필요합니다. 원활한 진행을 위해 다음 항목들을 미리 준비해 두시길 권장합니다.
- Redis와 QStash 인스턴스가 생성된 Upstash 계정
- API 키에 접근할 수 있는 OpenAI 계정
- 스토리 생성 기능을 구현할 Next.js 프로젝트
- 프로젝트를 배포할 Vercel 계정
소개
AI로 나만의 이야기를 만들어 보고 싶었던 적이 있으신가요? OpenAI의 Completions API와 Upstash의 QStash, Redis를 함께 사용하면 자연어 처리를 기반으로 한 맞춤형 스토리 생성기를 그 어느 때보다 쉽게 만들 수 있습니다. 이번 튜토리얼에서는 이러한 도구들을 설정하고 활용하여 독창적이고 매력적인 이야기를 생성하는 전 과정을 단계별로 살펴보겠습니다.

앱의 추가 화면은 다음과 같습니다.
- 스토리 생성 폼
- 스토리 생성 중 상태
- 생성된 스토리 표시
아키텍처
코드를 직접 살펴보면 앱이 어떻게 구성되어 있는지 충분히 이해할 수 있지만, 먼저 큰 그림을 잡아드리기 위해 아래 이미지에서 애플리케이션 흐름의 주요 부분과 각 요소 간 통신 방식을 확인하실 수 있습니다.

프로젝트 설정
먼저 Next.js 프로젝트를 생성해야 합니다. TypeScript를 포함한 새 Next.js 프로젝트는 아래 명령어로 생성할 수 있으며, Next.js 설정 방법은 공식 문서에서 자세히 확인할 수 있습니다.
이 튜토리얼에서는 프론트엔드 폼 스타일링을 위해 Tailwind CSS(forms 및 typography 플러그인 포함)도 설치했지만, 이는 완전히 선택 사항입니다.
다음으로 Upstash의 QStash와 Redis 라이브러리를 설치합니다.
npm install @upstash/qstash
npm install @upstash/redis
이제 .env.local 파일을 생성하고 아래 키들(각 서비스에서 발급받은 값 포함)을 채워 넣습니다.
SITE_URL=https://your-project-url.vercel.app
OPENAI_API_KEY=
QSTASH_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
QStash와 Redis 토큰은 Upstash 콘솔에서, OpenAI API 키는 OpenAI 대시보드에서 확인할 수 있습니다. 사이트 URL은 프로젝트를 생성하고 기본 Next.js 프로젝트를 배포한 후 Vercel 대시보드에서 찾을 수 있습니다.
프론트엔드 설정
다음으로 스토리 프롬프트를 입력할 페이지와 폼을 만들겠습니다. 프롬프트용 텍스트 필드와 제출 버튼이 필요합니다.
스토리 생성
파일: pages/index.tsx
import { RefObject, useRef, useState } from "react";
import Head from "next/head";
import useInterval from "../hooks/useInterval";
export default function Home() {
const [generating, setGenerating] = useState<boolean>(false);
const [messageId, setMessageId] = useState<string | null>(null);
const [story, setStory] = useState<string[]>([]);
const themeRef: RefObject<HTMLInputElement> = useRef(null);
const characterRef: RefObject<HTMLInputElement> = useRef(null);
const moralRef: RefObject<HTMLInputElement> = useRef(null);
useInterval(
async () => {
await fetch(`/api/poll?id=${messageId}`)
.then((res: any) => res.json())
.then((data: any) => {
if (!data.choices) {
return;
}
setGenerating(false);
setMessageId(null);
setStory(data.choices[0].text.split("\n\n"));
})
.catch((err: any) => console.error(err));
},
messageId ? 1000 : null,
);
async function generateStory(event: any) {
event.preventDefault();
setGenerating(true);
await fetch("/api/create", {
method: "POST",
body: JSON.stringify({
theme: themeRef.current?.value,
character: characterRef.current?.value,
moral: moralRef.current?.value,
}),
headers: { "Content-Type": "application/json" },
})
.then((res: any) => res.json())
.then((data: any) => setMessageId(data.id))
.catch((err: any) => console.error(err));
}
return (
<>
<Head>
<title>StoryTime</title>
<meta
name="description"
content="A simple Next.js application which allows you to create stories using AI."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<div className="my-16 flex flex-col items-center justify-center md:my-32">
<h1 className="text-5xl font-black">StoryTime</h1>
{story.length > 0 && (
<div className="mx-auto mt-10 max-w-3xl">
<div className="prose lg:prose-xl w-full">
{story.map((paragraph: string, index: number) => (
<p key={index}>{paragraph}</p>
))}
</div>
<div className="text-center">
<button
type="button"
onClick={() => setStory([])}
className="mt-6 inline-flex items-center rounded-full border border-transparent bg-gray-900 px-6 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2"
>
Start Over
</button>
</div>
</div>
)}
{story.length == 0 && (
<form
onSubmit={generateStory}
className="mt-10 flex w-full max-w-lg flex-col items-center"
>
<div className="w-full space-y-4">
<div>
<label htmlFor="theme" className="text-sm font-semibold">
My story is about
</label>
<input
name="theme"
id="theme"
type="text"
className="mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500"
placeholder="two friends going on an adventure"
ref={themeRef}
required
/>
</div>
<div>
<label htmlFor="character" className="text-sm font-semibold">
My main character is
</label>
<input
name="character"
id="character"
type="text"
className="mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500"
placeholder="a dog named Spot"
ref={characterRef}
required
/>
</div>
<div>
<label htmlFor="moral" className="text-sm font-semibold">
The moral of my story is
</label>
<input
name="moral"
id="moral"
type="text"
className="mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500"
placeholder="to always be kind"
ref={moralRef}
required
/>
</div>
</div>
<button
type="submit"
disabled={generating}
className="mt-6 inline-flex items-center rounded-full border border-transparent bg-gray-900 px-6 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2 disabled:opacity-50"
>
{generating ? "Generating..." : "Generate"}
</button>
</form>
)}
</div>
</main>
</>
);
}
이 파일은 사용자가 스토리의 주제, 등장인물, 교훈을 입력할 수 있는 폼을 렌더링하는 React 컴포넌트를 정의합니다. 폼이 제출되면 입력된 주제, 인물, 교훈 값을 본문으로 담아 /api/create 엔드포인트로 POST 요청을 전송합니다.
컴포넌트는 이후 폴링(polling) 상태에 진입하여, 매초마다 이전 스토리 생성 요청에서 받은 메시지 식별자와 함께 /api/poll 엔드포인트로 GET 요청을 보냅니다. 이를 통해 어떤 요청이 어떤 스토리에 해당하는지 추적하면서 OpenAI가 스토리 생성을 완료했는지 확인할 수 있습니다.
/api/poll 엔드포인트의 응답에 choices 속성이 포함되어 있다면 스토리가 성공적으로 생성된 것입니다. 이 시점에 컴포넌트는 폴링을 중단하고, 응답으로 받은 스토리 텍스트를 문단 단위로 분할하여 각 문단을 개별적으로 렌더링합니다.
인터벌 훅
파일: hooks/useInterval.ts
import { useEffect, useRef } from "react";
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (!delay && delay !== 0) {
return;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
export default useInterval;
useInterval 훅은 useEffect와 useRef 훅을 활용해 인터벌과 콜백 함수를 관리함으로써 React 컴포넌트 생명주기와 매끄럽게 연동됩니다. 덕분에 컴포넌트 내에서 반복 작업을 손쉽게 제어할 수 있고, 성능도 최적화되며 코드베이스의 유지보수성도 향상됩니다. 이 훅에 대한 더 자세한 내용은 관련 레퍼런스 문서를 참고하세요.
API 설정
이제 콜백, 폴링, 생성 엔드포인트 파일과 Redis·QStash 라이브러리 사용 코드를 차례로 작성하겠습니다.
스토리 생성
파일: pages/api/create.ts
import type { NextApiRequest, NextApiResponse } from "next";
import qstashClient from "../../lib/qstash";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(400).json({
message: `Invalid request method: ${req.method}.`,
});
}
const { theme, character, moral }: any = req.body;
qstashClient
.publishJSON({
url: "https://api.openai.com/v1/completions",
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QSTASH_TOKEN}`,
"Content-Type": "application/json",
"Upstash-Callback": `${process.env.SITE_URL}/api/callback`,
"Upstash-Forward-Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: {
model: "text-davinci-003",
prompt: `Write a children's story about ${theme}, which has a main character who is ${character} with the moral of the story being ${moral}.`,
max_tokens: 500,
temperature: 0.75,
},
})
.then((data: any) => {
return res.status(202).json({ id: data.messageId });
})
.catch((error: any) => {
return res.status(500).json({ message: error.message });
});
}
먼저 요청 메서드가 POST인지 검사하고, 아니라면 클라이언트 오류를 의미하는 상태 코드 400으로 응답합니다. 그다음 요청 본문에서 주제(theme), 인물(character), 교훈(moral) 필드를 구조 분해 할당합니다.
이어서 qstashClient 객체의 publishJSON 메서드를 호출합니다. 이 메서드는 주제, 인물, 교훈 값을 바탕으로 동화를 생성하는 프롬프트가 담긴 JSON 본문과 함께 OpenAI API로 POST 요청을 전송합니다. 또한 여러 헤더를 설정하는데, QSTASH_TOKEN 환경 변수에 저장된 토큰을 담은 인증 헤더와, OpenAI API 요청에 함께 전달될 OPENAI_API_KEY를 넘겨주는 포워드 인증 헤더가 포함됩니다.
publishJSON 호출이 성공하면 요청의 메시지 ID를 반환하며, 이 ID는 이후 폴링 과정에서 요청 완료 여부를 확인하는 데 사용됩니다. 오류가 발생하면 내부 서버 오류를 의미하는 상태 코드 500과 함께 해당 오류 메시지를 응답합니다.
콜백
파일: pages/api/callback.ts
import type { NextApiRequest, NextApiResponse } from "next";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { body }: any = req;
try {
const decoded = Buffer.from(body.body, "base64").toString("utf-8");
await redis.set(body.sourceMessageId, decoded);
return res.status(200).send(decoded);
} catch (error) {
return res.status(500).json({ error });
}
}
우선 들어오는 요청의 본문(base64로 인코딩된 문자열)을 디코딩을 시도합니다. 성공하면 디코딩된 문자열을 QStash에 최초 요청을 보낼 때 반환받은 것과 동일한 키로 Redis에 저장합니다.
마지막으로 성공을 의미하는 상태 코드 200과 디코딩된 문자열을 함께 응답합니다. 오류가 발생하면 상태 코드 500과 오류 정보를 반환합니다.
폴링
파일: pages/api/poll.ts
import type { NextApiRequest, NextApiResponse } from "next";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { id }: any = req.query;
try {
const data = await redis.get(id);
if (!data) {
return res
.status(404)
.json({ message: "Data for supplied ID not found" });
}
return res.status(200).json(data);
} catch (error: any) {
return res.status(500).json({ message: error.message });
}
}
먼저 요청의 쿼리 객체에서 id를 구조 분해합니다. 그다음 해당 id로 Redis에 저장된 데이터를 조회하고, 데이터가 없으면 요청한 리소스를 찾을 수 없다는 의미의 상태 코드 404와 안내 메시지를 응답합니다.
해당 키에 해당하는 데이터가 존재하면 성공을 의미하는 상태 코드 200과 함께 데이터를 반환합니다. 오류가 발생하면 상태 코드 500과 오류 메시지를 응답합니다.
라이브러리 클라이언트
다음으로 스토리 생성 과정에서 사용될 QStash와 Redis 클라이언트를 생성하는 두 개의 파일을 만듭니다. 두 파일 모두 외부 서비스와 상호작용하는 데 사용되는 객체를 export합니다.
파일: lib/qstash.ts
import { Client } from "@upstash/qstash";
const qstashClient = new Client({
token: process.env.QSTASH_TOKEN as string,
});
export default qstashClient;
QStash 클라이언트는 QSTASH_TOKEN 환경 변수에 저장된 토큰으로 초기화됩니다. 이 객체를 통해 Upstash QStash 서비스로 HTTP 요청을 전송할 수 있습니다.
파일: lib/redis.ts
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL as string,
token: process.env.UPSTASH_REDIS_REST_TOKEN as string,
});
export default redis;
Redis 클라이언트는 각각 UPSTASH_REDIS_REST_URL과 UPSTASH_REDIS_REST_TOKEN 환경 변수에 저장된 URL과 토큰으로 초기화됩니다. 이 객체를 사용하면 Upstash Redis REST API를 통해 Redis 데이터베이스에 데이터를 저장하고 조회할 수 있습니다.
마무리
OpenAI의 Completions API와 Upstash의 QStash, Redis를 활용하면 자연어 처리 기반 맞춤형 스토리 생성기를 손쉽게 만들 수 있습니다. 이 튜토리얼을 따라 하셨다면 이제 이 도구들로 스토리 생성 시스템을 직접 구축하고, 자신만의 방식으로 개선해 나갈 수 있습니다.
전체 소스 코드는 저장소에서 확인하실 수 있습니다.
추가 개선 아이디어
이 스토리 생성기를 출발점 삼아 시도해볼 만한 아이디어를 몇 가지 소개합니다.
- 프론트엔드 스타일링을 더욱 화려하고 시각적으로 매력적으로 개선하기
- OpenAI의 DALL-E를 활용해 주어진 프롬프트 기반의 삽화를 스토리에 추가하기
- API를 통해 결과물을 책 출판 서비스와 연동하여 사용자가 실물 책을 주문할 수 있게 하기
가능성과 방향은 무궁무진합니다. 즐거운 마음으로 개발 과정을 만끽해 보세요. 지금까지 만든 작업물은 OpenAI, QStash, Redis를 활용하는 다른 프로젝트의 기반으로도 활용할 수 있습니다.