RSC에서 UI로 마이그레이션
RSC에서 UI로 마이그레이션 (Migrating from RSC to UI)
AI SDK RSC에서 AI SDK UI로 마이그레이션하는 방법을 안내하는 문서예요. 스트리밍 채팅 완성, 생성형 UI, 클라이언트 상호작용, 채팅 저장/복원 등을 단계별로 다뤄요.
출처: 문서
본문
이 가이드는 AI SDK RSC에서 AI SDK UI로 마이그레이션하는 데 도움을 줘요.
배경 (Background)
AI SDK에는 애플리케이션의 프론트엔드를 구축하는 데 도움이 되는 두 패키지가 있어요 – AI SDK UI와 AI SDK RSC.
우리는 RSC를 지원하는 프레임워크에서 생성형 사용자 인터페이스를 더 쉽게 구축하기 위해, React Server Components(RSC)를 AI SDK 내에서 지원하는 것을 도입했어요.
하지만 이 기술의 한계를 밀어붙이고 있기 때문에, AI SDK RSC는 현재 안정적인 프로덕션 사용에 적합하지 않게 만드는 상당한 제한이 있어요.
- 서버 액션을 사용해 스트림을 중단(abort)할 수 없어요. 이는 향후 React와 Next.js 릴리스에서 개선될 예정이에요 (1122).
createStreamableUI와streamUI를 사용할 때 컴포넌트가.done()에서 다시 마운트되어 깜빡임(flicker)이 발생해요 (2939).- 많은 suspense 경계가 크래시를 유발할 수 있어요 (2843).
createStreamableUI를 사용하면 이차(quadratic) 데이터 전송이 발생할 수 있어요. 대신createStreamableValue를 사용하고 컴포넌트를 클라이언트 측에서 렌더링하면 피할 수 있어요.- 닫힌 RSC 스트림이 업데이트 문제를 일으켜요 (3007).
이러한 제한 때문에 AI SDK RSC는 실험 단계로 표시되어 있으며, 안정적인 프로덕션 환경에서는 사용을 권장하지 않아요.
그 결과, 더 안정적이고 프로덕션 등급의 경험을 제공하기 위해 폭넓게 개발된 AI SDK UI로 마이그레이션하는 것을 강력히 권장해요.
v0을 구축하면서 우리는 웹에서 최고의 채팅 경험을 만드는 방법을 탐구하는 데 상당한 시간을 투자했어요. AI SDK UI는 이러한 모범 사례와 언어 모델 미들웨어, 다단계 툴 호출, 첨부 파일, 텔레메트리, 프로바이더 레지스트리 같은 흔한 패턴을 많이 포함하고 있어요. 이러한 기능들은 AI를 애플리케이션에 안정적으로 통합하는 데 사용할 수 있는 깔끔한 추상화로 신중하게 설계됐어요.
스트리밍 채팅 완성 (Streaming Chat Completions)
기본 설정 (Basic Setup)
streamUI 함수는 아래와 같이 서버 액션의 일부로 실행돼요.
이전: 단일 서버 액션에서 생성과 렌더링 처리
import { openai } from '@ai-sdk/openai';
import { getMutableAIState, streamUI } from '@ai-sdk/rsc';
export async function sendMessage(message: string) {
'use server';
const messages = getMutableAIState('messages');
messages.update([...messages.get(), { role: 'user', content: message }]);
const { value: stream } = await streamUI({
model: openai('gpt-6-astra'),
instructions: 'you are a friendly assistant!',
messages: messages.get(),
text: async function* ({ content, done }) {
// process text
},
tools: {
// tool definitions
},
});
return stream;
}
이전: 서버 액션 호출 및 UI 상태 업데이트
채팅 인터페이스가 서버 액션을 호출해요. 응답은 useUIState 훅으로 저장돼요.
'use client';
import { useState, ReactNode } from 'react';
import { useActions, useUIState } from '@ai-sdk/rsc';
export default function Page() {
const { sendMessage } = useActions();
const [input, setInput] = useState('');
const [messages, setMessages] = useUIState();
return (
<div>
{messages.map(message => message)}
<form
onSubmit={async () => {
const response: ReactNode = await sendMessage(input);
setMessages(msgs => [...msgs, response]);
}}
>
<input type="text" />
<button type="submit">Submit</button>
</form>
</div>
);
}
streamUI 함수는 텍스트 생성과 사용자 인터페이스 렌더링을 결합해요. AI SDK UI로 마이그레이션하려면 이 관심사를 분리해야 해요 – streamText로 생성 스트리밍과 useChat으로 UI 렌더링을 분리하세요.
이후: 서버 액션을 라우트 핸들러로 교체
streamText 함수는 라우트 핸들러의 일부로 실행되며 응답을 클라이언트로 스트리밍해요. 클라이언트의 useChat 훅이 이 스트림을 디코딩해 채팅 인터페이스 안에 응답을 렌더링해요.
import {
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
} from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(request) {
const { messages } = await request.json();
const result = streamText({
model: __MODEL__,
instructions: 'you are a friendly assistant!',
messages,
tools: {
// tool definitions
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}
이후: 클라이언트를 채팅 훅으로 업데이트
'use client';
import { useChat } from '@ai-sdk/react';
export default function Page() {
const { messages, input, setInput, handleSubmit } = useChat();
return (
<div>
{messages.map(message => (
<div key={message.id}>
<div>{message.role}</div>
<div>{message.content}</div>
</div>
))}
<form onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={event => {
setInput(event.target.value);
}}
/>
<button type="submit">Send</button>
</form>
</div>
);
}
병렬 툴 호출 (Parallel Tool Calls)
AI SDK RSC에서 streamUI는 병렬 툴 호출을 지원하지 않아요. streamText, createStreamableUI, createStreamableValue를 조합해 사용해야 해요.
AI SDK UI에서는 useChat이 병렬 툴 호출을 내장 지원해요. streamText에 여러 툴을 정의하고 병렬로 호출되게 할 수 있어요. 그러면 useChat 훅이 병렬 툴 호출을 자동으로 처리해요.
다단계 툴 호출 (Multi-Step Tool Calls)
AI SDK RSC에서 streamUI는 다단계 툴 호출을 지원하지 않아요. streamText, createStreamableUI, createStreamableValue를 조합해 사용해야 해요.
AI SDK UI에서는 useChat이 다단계 툴 호출을 내장 지원해요. streamText 함수에 stopWhen을 설정해 모델이 툴 호출을 언제 멈춰야 하는지 정의할 수 있어요. 그러면 useChat 훅이 다단계 툴 호출을 자동으로 처리해요.
생성형 사용자 인터페이스 (Generative User Interfaces)
streamUI 함수는 tools를 사용해 사용자 입력에 따라 함수를 실행하고, 함수 출력에 따라 React 컴포넌트를 렌더링해 채팅 인터페이스에서 텍스트를 넘어서는 경험을 제공해요.
이전: 서버 액션 안에서 컴포넌트를 렌더링하고 클라이언트로 스트리밍
import { z } from 'zod';
import { streamUI } from '@ai-sdk/rsc';
import { openai } from '@ai-sdk/openai';
import { getWeather } from '@/utils/queries';
import { Weather } from '@/components/weather';
const { value: stream } = await streamUI({
model: openai('gpt-6-astra'),
instructions: 'you are a friendly assistant!',
messages,
text: async function* ({ content, done }) {
// process text
},
tools: {
displayWeather: {
description: 'Display the weather for a location',
inputSchema: z.object({
latitude: z.number(),
longitude: z.number(),
}),
generate: async function* ({ latitude, longitude }) {
yield <div>Loading weather...</div>;
const { value, unit } = await getWeather({ latitude, longitude });
return <Weather value={value} unit={unit} />;
},
},
},
});
앞서 언급했듯이 streamUI는 단일 서버 액션 호출에서 텍스트를 생성하고 React 컴포넌트를 렌더링해요.
이후: 라우트 핸들러로 교체하고 props 데이터를 클라이언트로 스트리밍
streamText 함수는 props 데이터를 응답으로 클라이언트에 스트리밍하고, useChat은 스트림을 toolInvocations로 디코딩해 채팅 인터페이스를 렌더링해요.
import { z } from 'zod';
import { openai } from '@ai-sdk/openai';
import { getWeather } from '@/utils/queries';
import {
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
} from 'ai';
export async function POST(request) {
const { messages } = await request.json();
const result = streamText({
model: __MODEL__,
instructions: 'you are a friendly assistant!',
messages,
tools: {
displayWeather: {
description: 'Display the weather for a location',
inputSchema: z.object({
latitude: z.number(),
longitude: z.number(),
}),
execute: async function ({ latitude, longitude }) {
const props = await getWeather({ latitude, longitude });
return props;
},
},
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}
이후: 클라이언트를 채팅 훅으로 업데이트하고 툴 호출로 컴포넌트 렌더링
'use client';
import { useChat } from '@ai-sdk/react';
import { Weather } from '@/components/weather';
export default function Page() {
const { messages, input, setInput, handleSubmit } = useChat();
return (
<div>
{messages.map(message => (
<div key={message.id}>
<div>{message.role}</div>
<div>{message.content}</div>
<div>
{message.toolInvocations.map(toolInvocation => {
const { toolName, toolCallId, state } = toolInvocation;
if (state === 'result') {
const { result } = toolInvocation;
return (
<div key={toolCallId}>
{toolName === 'displayWeather' ? (
<Weather weatherAtLocation={result} />
) : null}
</div>
);
} else {
return (
<div key={toolCallId}>
{toolName === 'displayWeather' ? (
<div>Loading weather...</div>
) : null}
</div>
);
}
})}
</div>
</div>
))}
<form onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={event => {
setInput(event.target.value);
}}
/>
<button type="submit">Send</button>
</form>
</div>
);
}
클라이언트 상호작용 처리 (Handling Client Interactions)
AI SDK RSC에서 클라이언트로 스트리밍된 컴포넌트는 useActions 훅을 사용해 관련 서버 액션을 호출함으로써 후속 생성을 트리거할 수 있어요. 컴포넌트가 <AI/> 컨텍스트 프로바이더의 하위 요소인 한 이것이 가능해요.
이전: 액션 훅을 사용해 메시지 전송
'use client';
import { useActions, useUIState } from '@ai-sdk/rsc';
export function ListFlights({ flights }) {
const { sendMessage } = useActions();
const [_, setMessages] = useUIState();
return (
<div>
{flights.map(flight => (
<div
key={flight.id}
onClick={async () => {
const response = await sendMessage(
`I would like to choose flight ${flight.id}!`,
);
setMessages(msgs => [...msgs, response]);
}}
>
{flight.name}
</div>
))}
</div>
);
}
이후: 컴포넌트에서 같은 ID를 가진 다른 채팅 훅 사용
AI SDK UI로 전환한 뒤에는, 부모 컴포넌트와 같은 id로 컴포넌트에서 useChat 훅을 초기화해 이러한 메시지들이 동기화돼요.
'use client';
import { useChat } from '@ai-sdk/react';
export function ListFlights({ chatId, flights }) {
const { append } = useChat({
id: chatId,
body: { id: chatId },
});
return (
<div>
{flights.map(flight => (
<div
key={flight.id}
onClick={async () => {
await append({
role: 'user',
content: `I would like to choose flight ${flight.id}!`,
});
}}
>
{flight.name}
</div>
))}
</div>
);
}
로딩 표시기 (Loading Indicators)
AI SDK RSC에서 streamUI의 initial 매개변수를 사용해 생성이 진행 중일 때 표시할 컴포넌트를 정의할 수 있어요.
이전: loading을 사용해 로딩 표시기 표시
import { openai } from '@ai-sdk/openai';
import { streamUI } from '@ai-sdk/rsc';
const { value: stream } = await streamUI({
model: openai('gpt-6-astra'),
instructions: 'you are a friendly assistant!',
messages,
initial: <div>Loading...</div>,
text: async function* ({ content, done }) {
// process text
},
tools: {
// tool definitions
},
});
return stream;
AI SDK UI에서는 툴 호출 상태를 사용해 툴이 실행되는 동안 로딩 표시기를 보여줄 수 있어요.
이후: 툴 호출 상태로 로딩 표시기 표시
'use client';
export function Message({ role, content, toolInvocations }) {
return (
<div>
<div>{role}</div>
<div>{content}</div>
{toolInvocations && (
<div>
{toolInvocations.map(toolInvocation => {
const { toolName, toolCallId, state } = toolInvocation;
if (state === 'result') {
const { result } = toolInvocation;
return (
<div key={toolCallId}>
{toolName === 'getWeather' ? (
<Weather weatherAtLocation={result} />
) : null}
</div>
);
} else {
return (
<div key={toolCallId}>
{toolName === 'getWeather' ? (
<Weather isLoading={true} />
) : (
<div>Loading...</div>
)}
</div>
);
}
})}
</div>
)}
</div>
);
}
채팅 저장 (Saving Chats)
streamUI를 서버 액션으로 구현하기 전에 <AI/> 프로바이더를 만들고 루트 레이아웃에서 애플리케이션을 감싸 AI와 UI 상태를 동기화해야 해요. 초기화 중에는 일반적으로 onSetAIState 콜백 함수를 사용해 AI 상태의 업데이트를 추적하고 done(...)이 호출될 때 데이터베이스에 저장해요.
이전: 컨텍스트 프로바이더의 콜백 함수로 채팅 저장
import { createAI } from '@ai-sdk/rsc';
import { saveChat } from '@/utils/queries';
export const AI = createAI({
initialAIState: {},
initialUIState: {},
actions: {
// server actions
},
onSetAIState: async ({ state, done }) => {
'use server';
if (done) {
await saveChat(state);
}
},
});
이후: streamText의 콜백 함수로 채팅 저장
AI SDK UI에서는 라우트 핸들러의 streamText onEnd 콜백 함수로 채팅을 저장해요.
import { openai } from '@ai-sdk/openai';
import { saveChat } from '@/utils/queries';
import {
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
convertToModelMessages,
} from 'ai';
export async function POST(request) {
const { id, messages } = await request.json();
const coreMessages = await convertToModelMessages(messages);
const result = streamText({
model: __MODEL__,
instructions: 'you are a friendly assistant!',
messages: coreMessages,
onEnd: async ({ responseMessages }) => {
try {
await saveChat({
id,
messages: [...coreMessages, ...responseMessages],
});
} catch (error) {
console.error('Failed to save chat');
}
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}
채팅 복원 (Restoring Chats)
AI SDK RSC를 사용할 때 useUIState 훅은 채팅의 UI 상태를 담고 있어요. 이전에 저장된 채팅을 복원할 때 UI 상태는 메시지로 로드되어야 해요.
AI SDK RSC에서 채팅을 저장하는 방식과 유사하게, onGetUIState 콜백 함수를 사용해 데이터베이스에서 채팅을 가져오고, UI 상태로 변환해 useUIState를 통해 접근할 수 있게 반환해야 해요.
이전: 컨텍스트 프로바이더의 콜백 함수로 데이터베이스에서 채팅 로드
import { createAI } from '@ai-sdk/rsc';
import { loadChatFromDB, convertToUIState } from '@/utils/queries';
export const AI = createAI({
actions: {
// server actions
},
onGetUIState: async () => {
'use server';
const chat = await loadChatFromDB();
const uiState = convertToUIState(chat);
return uiState;
},
});
AI SDK UI는 useChat의 messages 필드를 사용해 메시지를 저장해요. useChat이 마운트될 때 메시지를 로드하려면 initialMessages를 사용해야 해요.
메시지는 일반적으로 데이터베이스에서 로드되므로, Page 컴포넌트 안의 서버 액션을 사용해 정적 생성 중에 이전 채팅을 데이터베이스에서 가져오고 그 메시지를 props로 <Chat/> 컴포넌트에 전달할 수 있어요.
이후: 페이지 정적 생성 중에 데이터베이스에서 채팅 로드
import { Chat } from '@/app/components/chat';
import { getChatById } from '@/utils/queries';
// link to example implementation: https://github.com/vercel/ai-chatbot/blob/00b125378c998d19ef60b73fe576df0fe5a0e9d4/lib/utils.ts#L87-L127
import { convertToUIMessages } from '@/utils/functions';
export default async function Page({ params }: { params: any }) {
const { id } = params;
const chatFromDb = await getChatById({ id });
const chat: Chat = {
...chatFromDb,
messages: convertToUIMessages(chatFromDb.messages),
};
return <Chat key={id} id={chat.id} initialMessages={chat.messages} />;
}
이후: 채팅 메시지를 props로 전달하고 채팅 훅에 로드
'use client';
import { Message } from 'ai';
import { useChat } from '@ai-sdk/react';
export function Chat({
id,
initialMessages,
}: {
id;
initialMessages: Array<Message>;
}) {
const { messages } = useChat({
id,
initialMessages,
});
return (
<div>
{messages.map(message => (
<div key={message.id}>
<div>{message.role}</div>
<div>{message.content}</div>
</div>
))}
</div>
);
}
스트리밍 객체 생성 (Streaming Object Generation)
createStreamableValue 함수는 서버에서 클라이언트로 직렬화 가능한 모든 데이터를 스트리밍해요. 결과적으로 이 함수는 streamText 및 Output과 함께 사용하면 서버에서 클라이언트로 객체 생성을 스트리밍할 수 있게 해 줘요.
이전: streamable value로 객체 생성 스트리밍
import { Output, streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { createStreamableValue } from '@ai-sdk/rsc';
import { notificationsSchema } from '@/utils/schemas';
export async function generateSampleNotifications() {
'use server';
const stream = createStreamableValue();
(async () => {
const { partialOutputStream } = streamText({
model: __MODEL__,
instructions: 'generate sample ios messages for testing',
prompt: 'messages from a family group chat during diwali, max 4',
output: Output.object({ schema: notificationsSchema }),
});
for await (const partialObject of partialOutputStream) {
stream.update(partialObject);
}
})();
stream.done();
return { partialNotificationsStream: stream.value };
}
이전: streamable value를 읽고 객체 업데이트
'use client';
import { useState } from 'react';
import { readStreamableValue } from '@ai-sdk/rsc';
import { generateSampleNotifications } from '@/app/actions';
export default function Page() {
const [notifications, setNotifications] = useState(null);
return (
<div>
<button
onClick={async () => {
const { partialNotificationsStream } =
await generateSampleNotifications();
for await (const partialNotifications of readStreamableValue(
partialNotificationsStream,
)) {
if (partialNotifications) {
setNotifications(partialNotifications.notifications);
}
}
}}
>
Generate
</button>
</div>
);
}
AI SDK UI로 마이그레이션하려면 useObject 훅을 사용하고 라우트 핸들러에서 Output과 함께 streamText를 구현해야 해요.
이후: 라우트 핸들러로 교체하고 텍스트 스트림 응답
import { Output, createTextStreamResponse, streamText, toTextStream } from 'ai';
import { openai } from '@ai-sdk/openai';
import { notificationSchema } from '@/utils/schemas';
export async function POST(req: Request) {
const context = await req.json();
const result = streamText({
model: __MODEL__,
output: Output.object({ schema: notificationSchema }),
prompt:
`Generate 3 notifications for a messages app in this context:` + context,
});
return createTextStreamResponse({
stream: toTextStream({ stream: result.stream }),
});
}
이후: 객체 훅으로 스트림 디코딩 및 객체 업데이트
'use client';
import { useObject } from '@ai-sdk/react';
import { notificationSchema } from '@/utils/schemas';
export default function Page() {
const { object, submit } = useObject({
api: '/api/object',
schema: notificationSchema,
});
return (
<div>
<button onClick={() => submit('Messages during finals week.')}>
Generate notifications
</button>
{object?.notifications?.map((notification, index) => (
<div key={index}>
<p>{notification?.name}</p>
<p>{notification?.message}</p>
</div>
))}
</div>
);
}