Next.js + Vercel AI SDK로 챗봇 만들기 (스트리밍)
왜 만들었나
튜토리얼 시리즈 14번째 주제는 AI 챗봇이다. 처음엔 OpenAI API를 붙이려고 했는데, 튜토리얼 목적으로 API 키가 필요한 건 장벽이 높다. 그래서 Pollinations.ai를 쓰기로 했다. API 키 없이 무료로 쓸 수 있는 AI 텍스트 API다.
클라이언트 쪽은 Vercel AI SDK v6을 그대로 활용한다. SDK가 제공하는 스트리밍 처리, 메시지 히스토리 관리, React 훅 연동은 그대로 쓰되, 서버 라우트에서만 Pollinations.ai를 직접 호출하는 방식이다.
토큰이 하나씩 출력되는 스트리밍 방식이 핵심이다. 한 덩어리로 기다리는 것과 체감 반응 속도가 완전히 다르다.
기술 상세
프로젝트 구조
14-nextjs-ai-chatbot/
├── app/
│ ├── api/chat/
│ │ └── route.ts ← POST 핸들러 (Pollinations 호출 + SSE 파싱)
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx ← 채팅 UI (Vercel AI SDK v6)환경변수 설정 없음. API 키 불필요.
핵심 패키지
| 패키지 | 역할 |
|---|---|
ai |
Vercel AI SDK 코어 (convertToModelMessages, 트랜스포트) |
@ai-sdk/react |
useChat React 훅 |
@ai-sdk/openai는 쓰지 않는다. Pollinations.ai를 직접 fetch로 호출한다.
아키텍처
클라이언트 (page.tsx)
└─ useChat({ transport: TextStreamChatTransport })
└─ POST /api/chat (route.ts)
└─ fetch → Pollinations.ai (openai-fast)
└─ SSE 파싱 → 순수 텍스트 스트림 반환Pollinations.ai는 OpenAI 호환 SSE 포맷으로 응답한다. 서버에서 SSE를 파싱해 순수 텍스트 청크만 추출해 클라이언트로 스트리밍한다. 클라이언트의 TextStreamChatTransport는 이 텍스트 스트림을 그대로 수신한다.
Vercel AI SDK v6 vs v3/v4
| v3/v4 | v6 | |
|---|---|---|
| 서버 응답 | toDataStreamResponse() |
커스텀 스트림 또는 toTextStreamResponse() |
| 클라이언트 훅 반환값 | input, handleInputChange, handleSubmit |
sendMessage, status, messages |
| 메시지 구조 | { content: string } |
{ parts: UIMessagePart[] } |
| 트랜스포트 | 암묵적 | 명시적 (TextStreamChatTransport) |
v6는 API가 전면 재설계됐다. v3/v4 레퍼런스를 그대로 따라가면 빌드가 깨진다.
소스 코드
app/api/chat/route.ts — Pollinations.ai 스트리밍
import { convertToModelMessages } from 'ai';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const modelMessages = await convertToModelMessages(messages);
const response = await fetch('https://text.pollinations.ai/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'openai-fast',
messages: [
{
role: 'system',
content: '당신은 친절하고 유능한 AI 어시스턴트입니다. 한국어로 대화합니다.',
},
...modelMessages,
],
stream: true,
}),
});
if (!response.ok || !response.body) {
return new Response('AI 응답 오류', { status: response.status });
}
// Pollinations SSE 파싱 → 순수 텍스트 스트림
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const stream = new ReadableStream({
async start(controller) {
const reader = response.body!.getReader();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const content = json.choices?.[0]?.delta?.content;
if (content) {
controller.enqueue(encoder.encode(content));
}
} catch {
// JSON 파싱 실패 무시
}
}
}
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
},
});
}
Pollinations.ai가 반환하는 SSE는 OpenAI 포맷(data: {"choices":[{"delta":{"content":"..."}}]})과 동일하다. 줄 단위로 파싱해서 텍스트만 뽑아 ReadableStream으로 반환한다.
app/page.tsx — 채팅 UI
'use client';
import { useChat } from '@ai-sdk/react';
import { TextStreamChatTransport } from 'ai';
import { useState } from 'react';
const transport = new TextStreamChatTransport({ api: '/api/chat' });
export default function ChatPage() {
const { messages, sendMessage, status, error } = useChat({ transport });
const [input, setInput] = useState('');
const isLoading = status === 'streaming' || status === 'submitted';
const handleSend = () => {
const text = input.trim();
if (!text || isLoading) return;
sendMessage({ text });
setInput('');
};
return (
// ... UI
{messages.map(m => (
<div key={m.id}>
{m.parts.map((part, i) =>
part.type === 'text' ? <span key={i}>{part.text}</span> : null
)}
</div>
))}
);
}
v6에서 useChat은 input, handleInputChange, handleSubmit을 반환하지 않는다. 입력 상태는 useState로 직접 관리하고, 전송은 sendMessage({ text })로 한다.
메시지 렌더링도 달라졌다. v3/v4의 m.content가 아니라 m.parts 배열을 순회해야 한다. 타입이 'text'인 파트에서 part.text를 꺼낸다.
삽질 기록
ai/react 모듈을 찾을 수 없음
Module not found: Can't resolve 'ai/react'v6에서 useChat은 별도 패키지로 분리됐다. @ai-sdk/react를 설치하고 임포트 경로를 바꿔야 한다.
// Before (v3/v4)
import { useChat } from 'ai/react';
// After (v6)
import { useChat } from '@ai-sdk/react';
convertToModelMessages가 Promise 반환
v6에서 convertToModelMessages는 비동기 함수다.
// 잘못된 코드 — messages가 Promise 객체
messages: convertToModelMessages(messages),
// 올바른 코드
messages: await convertToModelMessages(messages),
await 없이 쓰면 런타임 에러는 안 나지만 API에 잘못된 형식의 메시지가 전달된다.
useChat 반환값에 input이 없음
가장 헷갈리는 변경점이다. v3/v4 튜토리얼을 따라하면 다음처럼 쓰게 된다.
// v3/v4 방식 — v6에서 빌드 에러
const { input, handleInputChange, handleSubmit } = useChat();
v6에서 useChat은 이 값들을 반환하지 않는다. 직접 관리해야 한다.
// v6 방식
const { messages, sendMessage, status } = useChat({ transport });
const [input, setInput] = useState('');
TypeScript를 쓰면 이 오류가 빌드 단계에서 잡힌다. TS를 쓰지 않았다면 런타임에서야 발견했을 것이다.
status 값 확인
v3/v4의 isLoading은 v6에서 없다. 대신 status로 상태를 확인한다.
const isLoading = status === 'streaming' || status === 'submitted';
| status | 상태 |
|---|---|
submitted |
요청 전송 후 응답 대기 중 |
streaming |
스트림 수신 중 |
ready |
완료 |
error |
오류 |
마무리
Vercel AI SDK v6는 v3/v4와 API가 크게 다르다. 검색으로 찾은 레퍼런스 대부분이 구버전이라 그대로 쓰면 빌드가 깨진다. 공식 문서에서 버전을 반드시 확인해야 한다.
API 키 없이 AI 챗봇을 만들고 싶다면 Pollinations.ai가 좋은 선택이다. OpenAI 호환 SSE를 지원하기 때문에 서버 라우트에서 직접 파싱하면 클라이언트 코드는 건드릴 필요가 없다.
핵심 패턴만 기억하면 된다.
- 서버: Pollinations.ai fetch → SSE 파싱 →
ReadableStream반환 - 클라이언트:
useChat({ transport: new TextStreamChatTransport({ api: '/api/chat' }) }) - 메시지 렌더링:
m.parts배열 순회
기술 스택: Next.js 16 · TypeScript · Tailwind CSS · Vercel AI SDK v6 · Pollinations.ai
소스 코드: https://github.com/lukaPlayground/tutorial/tree/main/14-nextjs-ai-chatbot
'튜토리얼 > 웹' 카테고리의 다른 글
| [Next.js] NextAuth v5로 소셜 로그인 구현하기 — GitHub + Google OAuth (0) | 2026.03.02 |
|---|---|
| [Next.js] Prisma + PostgreSQL Todo 앱 만들기 (Server Actions) (0) | 2026.03.02 |
| [Next.js] 마크다운 블로그 만들기 (정적 생성) (1) | 2026.03.02 |
| [React] 무한 스크롤 구현하기 (커스텀 훅 + IntersectionObserver) (0) | 2026.03.02 |
| [React] 날씨 앱 만들기 (Open-Meteo API · API 키 불필요) (0) | 2026.03.02 |