이 글에서는 Upstash, Next.js, LangChain, Fly.io를 활용해 오픈소스 맞춤형 콘텐츠 AI 챗봇을 구축한 과정을 다룹니다. Upstash는 모델 학습 일정 예약은 물론, 넉넉한 요청 속도 제한(Rate Limiting)과 OpenAI API 응답 캐싱까지 지원해 주는 핵심 역할을 했습니다.

사용할 기술 스택
- Next.js – 프론트엔드와 백엔드
- LangChain – 언어 모델 기반 애플리케이션 개발 프레임워크
- Upstash – QStash를 통한 모델 학습 스케줄링, 요청 속도 제한 및 OpenAI 응답 캐싱
- Tailwind CSS – 스타일링
- Fly.io – 배포
사전 준비물
- Node.js 18
- Upstash 계정
- OpenAI 계정 (OpenAI API 키 발급용)
Upstash Redis 설정하기
Upstash 계정을 생성하고 로그인한 후, Redis 탭으로 이동해 데이터베이스를 생성합니다.


데이터베이스 생성이 완료되면 Details(상세 정보) 탭으로 이동합니다. 아래로 스크롤하여 Connect your database 섹션을 찾고, 해당 내용을 복사해 안전한 곳에 저장해 둡니다.

또한 REST API 섹션까지 스크롤한 뒤 .env 버튼을 선택하고, 표시되는 내용을 복사하여 안전하게 보관합니다.

Upstash QStash 설정하기
로그인 상태에서 QStash 탭으로 이동하여 QSTASH_URL, QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY 값을 확인합니다. 복사한 내용은 반드시 안전한 곳에 보관하세요.

프로젝트 설정하기
앱 저장소를 클론한 뒤 이 튜토리얼을 따라가며 프로젝트의 모든 내용을 학습하면 됩니다. 프로젝트를 클론하려면 다음 명령어를 실행하세요.
git clone https://github.com/rishi-raj-jain/custom-content-ai-chatbot
cd custom-content-ai-chatbot
npm install
저장소를 클론한 후에는 .env 파일을 생성하고, 앞서 저장해 둔 값들을 추가합니다.
파일은 대략 다음과 같은 형태입니다:
# .env
# 위 단계에서 얻은 값들
# Upstash Redis 시크릿
UPSTASH_REDIS_REST_URL="https://....upstash.io"
UPSTASH_REDIS_REST_TOKEN="..."
# Upstash QStash 시크릿
QSTASH_URL="https://qstash.upstash.io/v1/publish/"
QSTASH_TOKEN="..."
QSTASH_CURRENT_SIGNING_KEY="sig_..."
QSTASH_NEXT_SIGNING_KEY="sig_..."
# OpenAI 키
OPENAI_API_KEY="sk-..."
# 관리자 접근 키
# 학습 요청이 오직 관리자만 수행하도록 검증하는 데 사용됨
ADMIN_KEY="..."
여기까지 완료했다면 다음 명령어로 로컬 환경을 실행할 수 있습니다.
npm run dev
저장소 구조 살펴보기
아래는 프로젝트의 메인 폴더 구조입니다. 빨간색으로 표시된 파일들은 벡터 스토어 관리, 사용자 정의 콘텐츠로 학습된 AI와 대화하는 API 라우트 작성(응답 캐싱 포함), 그리고 모델 학습 프로세스 예약과 관련된 부분으로, 이 글에서 자세히 다룰 내용입니다.

전체 데이터 흐름과 동작 방식
다음은 데이터 흐름과 주요 동작을 보여주는 개략적인 다이어그램입니다 👇🏻

- 사용자가 챗봇을 통해 질문하면 먼저 IP 기반 요청 속도 제한이 확인되고, Upstash Redis에 캐시된 응답이 없는 경우 OpenAI API에서 응답을 가져온 뒤 캐시에 저장하고 사용자에게 스트리밍 방식으로 전달합니다.
- 관리자가 특정 URL 목록에 대해 기존 모델의 재학습을 요청하면, Upstash QStash 덕분에 지정된 지연 시간 이후 서버리스 환경에서 POST 요청이 발생하여 해당 URL의 콘텐츠를 가져오고 백그라운드에서 모델을 업데이트합니다.
Next.js에서 Chat 및 Train API 라우트 구성하기
이 섹션에서는 크로스 오리진 요청(CORS)을 허용하고, 채팅 API 호출에 속도 제한을 적용하며, 응답을 캐싱·스트리밍하는 pages/api/chat.js, 그리고 특정 URL에 대한 학습만 백그라운드에서 수행하는 pages/api/train.js 라우트를 어떻게 구성했는지 설명합니다.
1. CORS 활성화
cors 패키지를 사용해 애플리케이션에서 CORS를 활성화하면 웹사이트에 삽입된 봇 등 여러 곳에서 챗봇을 사용할 수 있습니다. API 라우트가 초기화되는 즉시 아래와 같이 CORS 설정을 실행합니다 👇🏻
// File: pages/api/chat.js
// cors 참조 함수
import { runMiddleware } from '@/lib/cors'
export default async function (req, res) {
try {
// 미들웨어 실행
await runMiddleware(req, res)
// ...
catch (e) {
console.log(e.message || e.toString())
}
return res.end()
}
// CORS 함수
// File: lib/cors.js
import Cors from 'cors'
// cors 미들웨어 초기화
// 사용 가능한 옵션은 여기서 확인할 수 있습니다: https://github.com/expressjs/cors#configuration-options
const cors = Cors({
methods: ['POST', 'OPTIONS', 'HEAD'],
})
// 미들웨어 실행이 완료될 때까지 대기하는 헬퍼 함수
// 미들웨어에서 에러 발생 시 에러를 던집니다
export function runMiddleware(req, res, fn = cors) {
return new Promise((resolve, reject) => {
fn(req, res, (result) => {
if (result instanceof Error) return reject(result)
return resolve(result)
})
})
}
2. 특정 URL에 대한 콘텐츠 학습 요청 예약하기
Upstash QStash를 활용하면 "발사 후 잊기(fire and forget)" 방식의 API를 만들 수 있습니다. 메인 함수가 끝나기를 기다릴 필요 없이, 백그라운드에서 작업을 처리할 수 있으며(원한다면 일정 지연 시간 이후에), 마치 크론잡처럼 동작하지만 정해진 주기가 아니라 요청이 들어올 때마다 실행됩니다.
동일한 채팅 API 라우트에서 admin-key 헤더를 포함한 요청을 받고, 해당 값이 서버 측 시크릿(ADMIN_KEY)과 일치하면 요청 본문에 전달된 URL 목록에 대해 일정 지연(여기서는 10초) 후 콘텐츠 학습을 예약합니다. 지연 시간이 지난 후 학습 요청은 지정된 엔드포인트(여기서는 https://custom-content-ai-chatbot.fly.dev/api/train)로 전송됩니다.
// File: pages/api/chat.js
// 헤더에 `admin-key`가 포함된 경우
if (req.headers['admin-key'] === process.env.ADMIN_KEY) {
// body에 `urls`가 없으면 `Bad Request` 반환
if (!req.body.urls) return res.status(400).send('No urls to train on.')
// QStash API를 호출해 지금으로부터 10초 후 이 URL 목록에 대해 학습 진행
await qstashClient.publishJSON({
delay: 10,
body: { urls: req.body.urls },
url: 'https://custom-content-ai-chatbot.fly.dev/api/train'
})
return res.status(200).end()
}
이제 train API 라우트(pages/api/train.js)의 내부를 자세히 살펴보겠습니다 👇🏻
// File: pages/api/train.js
import train from '@/lib/train'
import * as dotenv from 'dotenv'
import { redis } from '@/lib/redis'
import { runMiddleware } from '@/lib/cors'
import { verifySignature } from '@upstash/qstash/nextjs'
dotenv.config()
// 요청 본문을 JSON으로 직접 변환하는 기능 비활성화
// 자세한 내용: https://nextjs.org/docs/pages/building-your-application/routing/api-routes#custom-config
export const config = {
api: {
bodyParser: false,
},
}
async function handler(req, res) {
try {
// 미들웨어 실행
await runMiddleware(req, res)
// POST가 아닌 메서드라면 `Forbidden Access` 반환
if (req.method !== 'POST') return res.status(403).send('No other methods allowed.')
// body에 `urls`가 없으면 `Bad Request` 반환
if (!req.body.urls) return res.status(400).send('No urls to train on.')
// 해당 URL들에 대해 학습 진행
await train(req.body.urls)
// 저장이 완료되면 Upstash의 모든 캐시된 응답 삭제
let allKeys = await redis.keys('*')
if (allKeys) {
// 속도 제한 관련 키는 제외하도록 필터링
allKeys = allKeys.filter((i) => !i.includes('@upstash/ratelimit:'))
const p = redis.pipeline()
// 모든 키를 삭제하는 파이프라인 생성
allKeys.forEach((i) => p.del(i))
// 파이프라인 명령을 트랜잭션으로 실행
await p.exec()
console.log('Cleaned cached responses in Upstash.')
}
return res.status(200).end()
} catch (e) {
console.log(e.message || e.toString())
}
return res.end()
}
// Upstash-Signature와 함께 온 유효한
// QStash 예약 POST 요청인지 검증
export default verifySignature(handler)
위 코드에서는 세 가지 핵심 작업을 수행합니다.
- QStash의
verifySignature메서드를 사용해 수신 요청을 검증합니다. 내부적으로Upstash-Signature헤더를 찾아 수신한 원본 본문(raw body)과 대조하여 검증합니다. train함수를 호출해 URL 콘텐츠를 가져오고 기존 벡터 스토어에 추가한 뒤 저장합니다.- 속도 제한 구현과 관련된 키를 필터링한 후, Redis 트랜잭션을 통해 Upstash Redis에 캐시된 응답들을 삭제합니다.
3. 요청 속도 제한 (Rate Limiting)
속도 제한 구현을 위해 Upstash Redis 데이터베이스 클라이언트와 @upstash/ratelimit이라는 속도 제한 라이브러리를 사용했습니다.
// File: lib/redis.js
// 속도 제한 참조 함수
import * as dotenv from 'dotenv'
import { Redis } from '@upstash/redis'
import { Ratelimit } from '@upstash/ratelimit'
// 환경 변수 로드
dotenv.config()
// Upstash Redis 초기화
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
// Upstash Rate Limiter 초기화
export const ratelimit = {
chat: new Ratelimit({
redis,
// IP당 하루 최대 30개 질문으로 제한
limiter: Ratelimit.slidingWindow(30, '86400s'),
}),
}
속도 제한 덕분에 이 서비스를 완전 무료로 공개 운영할 수 있었습니다! 이를 통해 채팅 응답이라는 시스템의 장점을 누구에게나 보여줄 수 있었습니다. 사실상 누구나 하루에 30개의 질문을 웹사이트를 통해 할 수 있습니다. IP 주소를 키로 사용해 하루 30개 질문이라는 속도 제한을 적용한 것입니다.
// File: pages/api/chat.js
import requestIp from 'request-ip'
import { ratelimit } from '@/lib/redis'
// ...
// 클라이언트 IP 조회
const detectedIp = requestIp.getClientIp(req)
// IP가 감지되지 않으면 `Bad Request` 반환
if (!detectedIp) return res.status(400).send('Bad request.')
// 속도 제한 확인
const result = await ratelimit.chat.limit(detectedIp)
// 제한에 걸렸다면 그대로 반환
if (!result.success) return res.status(400).send('Rate limit exceeded.')
// 채팅 응답 제공 계속 진행
4. 저장된 벡터 스토어 로드 후 OpenAI에 응답 요청하기
모든 검증을 마쳤으니 이제 핵심 작업, 즉 사용자 정의 콘텐츠와 함께 OpenAI API를 호출하고 응답을 사용자에게 전달하는 단계입니다. 설명을 위해 몇 가지 부분으로 나누어 살펴보겠습니다.
- 4.1: 저장된 벡터 스토어 불러오기
// File: pages/api/chat.js
// loadVectorStore 참조 함수
import { loadVectorStore } from '@/lib/vectorStore'
// 학습된 모델 로드
const vectorStore = await loadVectorStore()
// ...
// 벡터 스토어 함수
// File: lib/vectorStore.js
import { join } from 'path'
import { existsSync } from 'fs'
import { Document } from 'langchain/document'
import { FaissStore } from 'langchain/vectorstores/faiss'
import { OpenAIEmbeddings } from 'langchain/embeddings/openai'
export async function loadVectorStore() {
const directory = join(process.cwd(), 'loadedVectorStore')
const docStoreJSON = join(process.cwd(), 'loadedVectorStore', 'docstore.json')
if (existsSync(docStoreJSON)) {
// 디렉터리가 존재하면 Faiss 연동으로 저장된 벡터 스토어 로드
return await FaissStore.load(directory, new OpenAIEmbeddings())
} else {
// 콘텐츠가 없으면 시작용으로 `Hey` 문서만 담은 벡터 스토어 로드
return await FaissStore.fromDocuments([new Document({ pageContent: 'Hey' })], new OpenAIEmbeddings())
}
}
- 4.2: 사용자 질의에 프롬프트 가이드라인 추가하기
LangChain의 PromptTemplate을 활용해, 사용자 질의와 함께 AI가 질문에 어떤 방식과 형식으로 답변해야 하는지에 대한 지침을 전달합니다.
// File: pages/api/chat.js
import { z } from 'zod'
import { PromptTemplate } from 'langchain/prompts'
import { RetrievalQAChain } from 'langchain/chains'
import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers'
// 학습된 모델 로드
// ...
// OpenAI에게 무엇을 작성할지 지정하는 프롬프트 생성
const outputParser = StructuredOutputParser.fromZodSchema(
z.object({
answer: z.string().describe('answer to question in HTML friendly format, use all of the tags wherever possible and including reference links'),
}),
)
// ...
// OpenAI의 응답을 다듬는 데 도움이 되는 출력 파서 클래스 인스턴스 생성
const outputFixingParser = OutputFixingParser.fromLLM(model, outputParser)
// OpenAI에게 입력을 어떻게 처리할지 지정하는 프롬프트 생성
const prompt = new PromptTemplate({
template: `Answer the user's question as best and be as detailed as possible:\n{format_instructions}\n{query}`,
inputVariables: ['query'],
partialVariables: {
format_instructions: outputFixingParser.getFormatInstructions(),
},
})
// 프롬프트와 모델을 함께 OpenAI API에 전달
const chain = RetrievalQAChain.fromLLM(model, vectorStore.asRetriever(), prompt)
- 4.3: 응답 스트리밍 및 캐싱
Upstash Redis로 응답을 캐싱하기 위해 LangChain에서 제공하는 UpstashRedisCache 라이브러리를 사용합니다. 기존 Redis 인스턴스를 클라이언트로 전달하고, 캐싱 핸들러를 ChatOpenAI 래퍼에 넘겨 응답이 전달되는 즉시 캐시되도록 구성합니다.
// File: pages/api/chat.js
import { redis } from '@/lib/redis'
import { ChatOpenAI } from 'langchain/chat_models/openai'
import { UpstashRedisCache } from 'langchain/cache/upstash_redis'
// 학습된 모델 로드
// ...
// Upstash 캐싱 생성
const upstashRedisCache = new UpstashRedisCache({ client: redis })
// 응답이 캐시되지 않았는지 감지하는 플래그
let doesToken = false
const model = new ChatOpenAI({
// 스트리밍을 활성화해 가능한 한 빠르게 사용자에게 응답 전달
streaming: true,
// Upstash Redis 캐시 클라이언트로 응답 캐싱
cache: upstashRedisCache,
callbacks: [
{
handleLLMNewToken(token) {
// OpenAI에서 스트림을 수신하면 플래그를 true로 설정
doesToken = true
// 토큰을 사용자에게 스트리밍
res.write(token)
},
},
],
})
// LLM QA 체인 생성
// ...
// 캐시된 경우 참조할 수 있도록 출력 저장
const chainOutput = await chain.call({ query: req.body.input })
// 토큰을 받지 못했다면 콘텐츠가 캐시되어 있다는 의미
// 캐시된 응답을 그대로 반환
if (!doesToken) return res.status(200).send(chainOutput.text)
여기까지 배울 내용이 정말 많았습니다! 이제 준비가 모두 끝났습니다.
Fly.io에 배포하기
이 저장소에는 Fly.io 배포를 위한 설정이 기본으로 포함되어 있습니다. 구체적으로는 다음과 같습니다.
- Dockerfile
- fly.toml
- .dockerignore
배포하려면 Fly.io 계정이 필요합니다. 계정을 만든 후, 프로젝트 루트 폴더에서 다음 명령어를 실행해 Fly.io에 앱을 생성할 수 있습니다.
# 포함된 설정을 기반으로 계정에 앱 생성
# 결과적으로 기존 fly.toml에서 앱 이름만 변경됩니다
fly launch
그리고 다음과 같이 배포합니다 👇🏻
# 위에서 생성한 설정을 기반으로 앱 배포
fly deploy
이제 배포까지 완료되었습니다! 네, 이게 전부입니다.
마무리
이 프로젝트를 통해 OpenAI 응답 캐싱, 요청 속도 제한, 그리고 모델 학습을 위한 예약 API 호출을 구현하면서 귀중한 경험을 얻을 수 있었습니다. 무엇보다 필요에 따라 확장되는 Upstash라는 서비스를 활용해 이 모든 것을 달성했다는 점이 인상적입니다.
Next.js, Redis, TailwindCSS, LangChain, Serverless Scheduling