LLM으로 구조화된 JSON을 받을 때 generateObject를 자주 쓴다. Zod 스키마 하나 넘기면 타입까지 딱 맞는 객체가 돌아오니까 편하다. 그런데 실전에서 하나 걸리는 게 있었다. 응답이 다 완성될 때까지 화면에 아무것도 못 띄운다는 것.
레시피 생성 기능을 만들었는데, 재료 10개에 조리 단계 8개짜리 객체를 받으려니 5~6초씩 걸렸다. 그동안 사용자는 스피너만 본다. 텍스트 챗봇은 토큰 단위로 흘려주면서, 정작 구조화 응답은 왜 통째로 기다려야 하나 싶었다.
streamObject는 부분 객체를 흘려준다
streamObject는 같은 스키마를 쓰지만, 완성되기 전의 부분 객체를 계속 밀어준다. 필드가 하나씩 채워지는 걸 실시간으로 받을 수 있다.
// app/api/recipe/route.ts
import { streamObject } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
const recipeSchema = z.object({
title: z.string(),
ingredients: z.array(z.object({ name: z.string(), amount: z.string() })),
steps: z.array(z.string()),
});
export async function POST(req: Request) {
const { dish } = await req.json();
const result = streamObject({
model: anthropic('claude-sonnet-5'),
schema: recipeSchema,
prompt: `${dish} 레시피를 만들어줘.`,
});
return result.toTextStreamResponse();
}
클라이언트는 experimental_useObject로 받는다
React 쪽은 훅 하나면 된다. object가 부분 객체라 아직 없는 필드는 undefined로 들어온다. 옵셔널 체이닝만 잘 챙기면 된다.
'use client';
import { experimental_useObject as useObject } from '@ai-sdk/react';
import { recipeSchema } from './schema';
export function Recipe() {
const { object, submit, isLoading } = useObject({
api: '/api/recipe',
schema: recipeSchema,
});
return (
<div>
<button onClick={() => submit({ dish: '김치볶음밥' })}>생성</button>
<h2>{object?.title}</h2>
<ul>
{object?.ingredients?.map((ing, i) => (
<li key={i}>{ing?.name} {ing?.amount}</li>
))}
</ul>
</div>
);
}
이렇게 하면 제목이 먼저 뜨고, 재료가 한 줄씩 쌓이고, 조리 단계가 이어서 채워진다. 체감 대기 시간이 확 줄었다. 실제 총 응답 시간은 그대로인데, 사용자는 첫 필드가 뜨는 순간부터 뭔가 진행되고 있다고 느낀다.
한 가지 주의점
부분 객체는 말 그대로 미완성이라, 배열 중간 원소가 아직 undefined거나 문자열이 잘려 있을 수 있다. 그래서 화면에 뿌릴 때는 관대하게 렌더하고, 실제 저장이나 후처리는 isLoading이 끝난 뒤 완성된 값으로만 하는 게 안전하다.
정리하면 — 사용자에게 바로 보여줄 구조화 응답이면 streamObject, 백엔드에서 통째로 처리하고 끝낼 거면 generateObject. 이 기준으로 나눠 쓰니까 깔끔했다.