
Vercel AI SDK: Next.js에서 스트리밍 AI 채팅 만들기
Vercel AI SDK를 써서 Next.js에서 스트리밍 AI 채팅을 만드는 방법을 정리했다. useChat 훅, Server Actions, 프로바이더 전환, 도구 통합까지 실제 코드로 한 번에 설명한다.

Vercel AI SDK를 써서 Next.js에서 스트리밍 AI 채팅을 만드는 방법을 정리했다. useChat 훅, Server Actions, 프로바이더 전환, 도구 통합까지 실제 코드로 한 번에 설명한다.
서버 컴포넌트를 클라이언트 컴포넌트 안에 import 했더니, DB 연결이 끊기고 에러가 폭발했습니다. Next.js App Router의 핵심인 'Composition Pattern'을 구멍 뚫린 도넛에 비유해 설명하고, Context Provider를 올바르게 분리하는 방법을 정리해봤습니다.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

처음 AI 채팅 기능을 붙일 때, 직접 fetch로 OpenAI API를 호출하고 스트리밍도 손으로 파싱했다. 처음엔 "별거 아니네"라고 생각했는데, 금방 복잡해졌다.
스트리밍 응답 파싱, 로딩 상태 관리, 에러 처리, 메시지 히스토리 유지, 중단 처리... 생각보다 고려할 게 많았다. 그리고 OpenAI에서 Anthropic으로 바꾸려 하니 API 형식이 달라서 코드를 상당 부분 갈아엎어야 했다.
그때 발견한 게 Vercel AI SDK였다. 한 마디로 정리하면 이렇다.
"AI 채팅 앱의 보일러플레이트를 없애주는 라이브러리"
useChat 훅 하나로 스트리밍, 로딩 상태, 메시지 히스토리가 다 해결된다. 프로바이더도 OpenAI, Anthropic, Google 등을 코드 한 줄 바꾸는 것으로 전환할 수 있다.
직접 구현 대비 코드량이 1/3로 줄었다. 오늘은 AI SDK의 핵심 개념부터 실제 채팅 UI 구축까지 전부 정리해본다.
npm install ai @ai-sdk/openai @ai-sdk/anthropic
# 또는 pnpm / yarn
AI SDK는 크게 세 레이어로 나뉜다.
ai # 코어 SDK (provider-agnostic)
├── @ai-sdk/openai # OpenAI 프로바이더
├── @ai-sdk/anthropic # Anthropic 프로바이더
├── @ai-sdk/google # Google AI 프로바이더
└── ... # 기타 프로바이더
핵심 철학은 프로바이더 분리다. 어떤 LLM 서비스를 쓰든 코어 API는 동일하다. 바꿀 때 import 한 줄만 수정하면 된다.
// OpenAI 사용
import { openai } from "@ai-sdk/openai";
const model = openai("gpt-4o");
// Anthropic으로 전환 - 이게 전부임
import { anthropic } from "@ai-sdk/anthropic";
const model = anthropic("claude-opus-4-5");
// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: openai("gpt-4o"),
messages,
system: "당신은 친절한 AI 어시스턴트입니다.",
});
return result.toDataStreamResponse();
}
단 8줄이다. streamText가 LLM을 호출하고, toDataStreamResponse()가 클라이언트가 소비할 수 있는 스트리밍 응답으로 변환해준다.
// app/chat/page.tsx
"use client";
import { useChat } from "ai/react";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
useChat({
api: "/api/chat",
});
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
{/* 메시지 목록 */}
<div className="flex-1 overflow-y-auto space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`rounded-lg px-4 py-2 max-w-[80%] ${
message.role === "user"
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-900"
}`}
>
{message.content}
</div>
</div>
))}
{/* 로딩 인디케이터 */}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-lg px-4 py-2">
<span className="animate-pulse">생각 중...</span>
</div>
</div>
)}
</div>
{/* 에러 표시 */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg mb-4">
오류가 발생했습니다: {error.message}
</div>
)}
{/* 입력 폼 */}
<form onSubmit={handleSubmit} className="flex gap-2 mt-4">
<input
value={input}
onChange={handleInputChange}
placeholder="메시지를 입력하세요..."
className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-blue-500 text-white px-6 py-2 rounded-lg disabled:opacity-50"
>
전송
</button>
</form>
</div>
);
}
useChat이 반환하는 것들을 정리해보면:
| 반환값 | 타입 | 설명 |
|---|---|---|
messages | Message[] | 전체 대화 히스토리 |
input | string | 현재 입력 값 |
handleInputChange | 이벤트 핸들러 | 입력 변경 처리 |
handleSubmit | 이벤트 핸들러 | 폼 제출 처리 |
isLoading | boolean | 응답 대기 중 여부 |
error | Error | undefined | 오류 정보 |
reload | 함수 | 마지막 메시지 재요청 |
stop | 함수 | 스트리밍 중단 |
App Router에서는 Route Handler 없이 Server Actions만으로도 AI 채팅을 구현할 수 있다.
// app/actions.ts
"use server";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { createStreamableValue } from "ai/rsc";
export async function chat(messages: { role: "user" | "assistant"; content: string }[]) {
const stream = createStreamableValue("");
// streamText를 비동기로 실행
(async () => {
const { textStream } = await streamText({
model: anthropic("claude-opus-4-5"),
messages,
});
for await (const delta of textStream) {
stream.update(delta);
}
stream.done();
})();
return { output: stream.value };
}
// 클라이언트에서 사용
"use client";
import { readStreamableValue } from "ai/rsc";
import { chat } from "./actions";
import { useState } from "react";
export default function ChatWithActions() {
const [messages, setMessages] = useState<
{ role: "user" | "assistant"; content: string }[]
>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim()) return;
const userMessage = { role: "user" as const, content: input };
const updatedMessages = [...messages, userMessage];
setMessages([...updatedMessages, { role: "assistant", content: "" }]);
setInput("");
setIsLoading(true);
const { output } = await chat(updatedMessages);
for await (const delta of readStreamableValue(output)) {
setMessages((prev) => {
const last = prev[prev.length - 1];
return [
...prev.slice(0, -1),
{ ...last, content: last.content + (delta ?? "") },
];
});
}
setIsLoading(false);
}
return (
<div>
{/* 메시지 목록과 입력 폼 */}
</div>
);
}
Route Handler vs Server Actions, 어느 쪽이 나을까?
| Route Handler | Server Actions | |
|---|---|---|
| 설정 복잡도 | 낮음 | 낮음 |
| 타입 안전성 | 보통 | 높음 |
| 재사용성 | API 형태라 외부에서도 호출 가능 | 서버 전용 |
| useChat 호환 | 완전 호환 | 직접 구현 필요 |
| Edge Runtime | 지원 | 지원 |
단순 채팅이면 Route Handler + useChat 조합이 가장 빠르다.
개발/스테이징/프로덕션 환경에 따라 다른 모델을 쓰고 싶을 때:
// lib/ai.ts
import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import type { LanguageModel } from "ai";
type Provider = "openai" | "anthropic" | "google";
interface ModelConfig {
provider: Provider;
modelId: string;
}
const CONFIGS: Record<string, ModelConfig> = {
fast: { provider: "openai", modelId: "gpt-4o-mini" },
standard: { provider: "anthropic", modelId: "claude-sonnet-4-5" },
powerful: { provider: "anthropic", modelId: "claude-opus-4-5" },
coding: { provider: "openai", modelId: "gpt-4o" },
};
export function getModel(preset: keyof typeof CONFIGS = "standard"): LanguageModel {
const config = CONFIGS[preset];
switch (config.provider) {
case "openai":
return openai(config.modelId);
case "anthropic":
return anthropic(config.modelId);
case "google":
return google(config.modelId);
default:
return anthropic("claude-sonnet-4-5");
}
}
// 사용
const model = getModel("powerful");
프라이머리 프로바이더가 다운됐을 때 자동으로 폴백하는 패턴:
import { experimental_createProviderRegistry as createProviderRegistry } from "ai";
import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
const registry = createProviderRegistry({
openai,
anthropic,
});
// registry에서 모델을 가져올 때 프로바이더:모델ID 형식 사용
// const model = registry.languageModel("openai:gpt-4o");
async function streamWithFallback(messages: unknown[]) {
const providers = [
() => anthropic("claude-opus-4-5"),
() => openai("gpt-4o"),
];
for (const getProvider of providers) {
try {
const result = await streamText({
model: getProvider(),
messages: messages as Parameters<typeof streamText>[0]["messages"],
});
return result;
} catch (error) {
console.warn("프로바이더 실패, 폴백 시도:", error);
}
}
throw new Error("모든 프로바이더가 실패했습니다");
}
AI SDK는 Tool Use를 일급으로 지원한다. 서버에서 도구를 정의하고, 클라이언트에서는 도구 호출 상태를 실시간으로 받아볼 수 있다.
// app/api/chat/route.ts
import { streamText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: anthropic("claude-opus-4-5"),
messages,
tools: {
getWeather: tool({
description: "특정 도시의 현재 날씨를 조회합니다",
parameters: z.object({
city: z.string().describe("날씨를 조회할 도시 이름"),
unit: z
.enum(["celsius", "fahrenheit"])
.optional()
.describe("온도 단위"),
}),
execute: async ({ city, unit = "celsius" }) => {
// 실제로는 날씨 API 호출
return {
city,
temperature: 22,
unit,
condition: "맑음",
humidity: 65,
};
},
}),
searchDatabase: tool({
description: "제품 데이터베이스에서 정보를 검색합니다",
parameters: z.object({
query: z.string().describe("검색 쿼리"),
limit: z.number().min(1).max(20).optional().describe("최대 결과 수"),
}),
execute: async ({ query, limit = 5 }) => {
// 실제로는 DB 쿼리
return {
results: [
{ id: "1", name: `${query} 관련 상품 1`, price: 29900 },
{ id: "2", name: `${query} 관련 상품 2`, price: 49900 },
].slice(0, limit),
};
},
}),
},
maxSteps: 5, // 도구 호출 최대 반복 횟수
});
return result.toDataStreamResponse();
}
클라이언트에서는 도구 호출 상태를 toolInvocations로 받을 수 있다:
// components/ChatWithTools.tsx
"use client";
import { useChat } from "ai/react";
export default function ChatWithTools() {
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat({ api: "/api/chat" });
return (
<div className="space-y-4">
{messages.map((message) => (
<div key={message.id}>
<div
className={`font-semibold ${message.role === "user" ? "text-blue-600" : "text-green-600"}`}
>
{message.role === "user" ? "나" : "AI"}
</div>
<div>{message.content}</div>
{/* 도구 호출 상태 표시 */}
{message.toolInvocations?.map((invocation) => (
<div
key={invocation.toolCallId}
className="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-200 text-sm"
>
<div className="font-medium text-gray-600">
도구 호출: {invocation.toolName}
</div>
<div className="text-gray-500 mt-1">
파라미터: {JSON.stringify(invocation.args, null, 2)}
</div>
{invocation.state === "result" ? (
<div className="text-green-600 mt-1">
결과: {JSON.stringify(invocation.result, null, 2)}
</div>
) : (
<div className="text-yellow-600 mt-1 animate-pulse">
실행 중...
</div>
)}
</div>
))}
</div>
))}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
className="flex-1 border rounded-lg px-3 py-2"
placeholder="날씨를 물어보거나 상품을 검색해보세요..."
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
전송
</button>
</form>
</div>
);
}
지금까지 배운 것들을 합쳐서 실제로 쓸 수 있는 채팅 컴포넌트를 만들어보자.
// components/ChatUI.tsx
"use client";
import { useChat } from "ai/react";
import { useEffect, useRef } from "react";
interface ChatUIProps {
systemPrompt?: string;
placeholder?: string;
className?: string;
}
export default function ChatUI({
systemPrompt,
placeholder = "메시지를 입력하세요...",
className = "",
}: ChatUIProps) {
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
error,
reload,
stop,
} = useChat({
api: "/api/chat",
body: { systemPrompt }, // 서버에 추가 데이터 전달
onError: (err) => {
console.error("채팅 오류:", err);
},
});
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// 새 메시지가 오면 자동 스크롤
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// 로딩 끝나면 입력창에 포커스
useEffect(() => {
if (!isLoading) {
inputRef.current?.focus();
}
}, [isLoading]);
return (
<div className={`flex flex-col h-full ${className}`}>
{/* 메시지 영역 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 mt-20">
대화를 시작해보세요
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${message.role === "user" ? "flex-row-reverse" : "flex-row"}`}
>
{/* 아바타 */}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 ${
message.role === "user" ? "bg-blue-500" : "bg-purple-500"
}`}
>
{message.role === "user" ? "나" : "AI"}
</div>
{/* 메시지 버블 */}
<div
className={`max-w-[75%] rounded-2xl px-4 py-3 ${
message.role === "user"
? "bg-blue-500 text-white rounded-tr-sm"
: "bg-white border border-gray-200 text-gray-900 rounded-tl-sm shadow-sm"
}`}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{message.content}
</p>
{/* 도구 호출 표시 */}
{message.toolInvocations && message.toolInvocations.length > 0 && (
<div className="mt-2 space-y-1">
{message.toolInvocations.map((inv) => (
<div
key={inv.toolCallId}
className="text-xs bg-black/10 rounded px-2 py-1"
>
{inv.state === "result" ? "✓" : "⟳"} {inv.toolName}
</div>
))}
</div>
)}
</div>
</div>
))}
{/* 타이핑 인디케이터 */}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-purple-500 flex items-center justify-center text-white text-sm font-bold">
AI
</div>
<div className="bg-white border border-gray-200 rounded-2xl rounded-tl-sm px-4 py-3 shadow-sm">
<div className="flex gap-1">
{[0, 1, 2].map((i) => (
<div
key={i}
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* 에러 배너 */}
{error && (
<div className="mx-4 mb-2 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center justify-between">
<span className="text-red-700 text-sm">{error.message}</span>
<button
onClick={() => reload()}
className="text-red-600 text-sm underline ml-2"
>
다시 시도
</button>
</div>
)}
{/* 입력 영역 */}
<div className="border-t bg-white p-4">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
ref={inputRef}
value={input}
onChange={handleInputChange}
placeholder={placeholder}
disabled={isLoading}
className="flex-1 border border-gray-300 rounded-full px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (input.trim() && !isLoading) {
handleSubmit(e as unknown as React.FormEvent);
}
}
}}
/>
{isLoading ? (
<button
type="button"
onClick={stop}
className="bg-red-500 text-white px-4 py-2 rounded-full text-sm hover:bg-red-600"
>
중단
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="bg-blue-500 text-white px-4 py-2 rounded-full text-sm hover:bg-blue-600 disabled:opacity-50"
>
전송
</button>
)}
</form>
</div>
</div>
);
}
// app/api/chat/route.ts (재시도 버전)
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
delay = 1000
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (i === maxRetries - 1) throw error;
// 429 Too Many Requests면 더 길게 대기
const isRateLimited =
error instanceof Error && error.message.includes("429");
const waitTime = isRateLimited ? delay * (i + 1) * 2 : delay * (i + 1);
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
throw new Error("Unreachable");
}
export async function POST(req: Request) {
const { messages } = await req.json();
try {
const result = await withRetry(() =>
streamText({
model: anthropic("claude-opus-4-5"),
messages,
})
);
return result.toDataStreamResponse();
} catch (error) {
const message =
error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다";
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
// useChat의 onFinish 콜백으로 토큰 사용량 추적
const { messages } = useChat({
onFinish: (message, options) => {
console.log("완료:", {
finishReason: options.finishReason,
usage: options.usage,
});
},
onResponse: (response) => {
// HTTP 응답 상태 확인
if (!response.ok) {
console.error("서버 오류:", response.status);
}
},
});
다양한 페이지나 컨텍스트에 따라 AI 페르소나를 바꾸고 싶을 때:
// app/api/chat/route.ts
export async function POST(req: Request) {
const { messages, systemPrompt, userId } = await req.json();
// 유저별 컨텍스트 로드 (예: DB에서 유저 정보)
const userContext = await getUserContext(userId);
const dynamicSystemPrompt = systemPrompt
?? `당신은 ${userContext.companyName}의 고객 지원 담당자입니다.
유저 이름: ${userContext.name}
구독 플랜: ${userContext.plan}
언어 설정: ${userContext.language}`;
const result = await streamText({
model: anthropic("claude-opus-4-5"),
system: dynamicSystemPrompt,
messages,
});
return result.toDataStreamResponse();
}
AI SDK를 쓰면서 가장 좋았던 점 세 가지:
물론 단점도 있다. SDK가 추상화를 많이 해주다 보니, 내부에서 무슨 일이 일어나는지 파악하기 어려울 때가 있다. 문제가 생기면 디버깅이 까다롭다.
그래도 대부분의 프로젝트에서는 AI SDK를 쓰는 게 훨씬 낫다. 직접 구현에서 흔히 발생하는 스트리밍 버그, 상태 불일치 문제를 애초에 만날 일이 없어지기 때문이다.
AI 채팅을 Next.js에 붙이고 싶다면 AI SDK를 고려해봐라. 생각보다 훨씬 빠르게 production-ready 채팅을 만들 수 있다.