본문 바로가기
튜토리얼/웹

[Next.js] Vercel AI SDK로 챗봇 만들기 (스트리밍, API 키 불필요)

by 루까(Luka) 2026. 3. 2.
반응형

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에서 useChatinput, 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';

convertToModelMessagesPromise 반환

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

데모: https://tutorial-i9xl.vercel.app

반응형