블로그 목록
ai

streamObject로 구조화 응답 스트리밍하기 — generateObject의 로딩 스피너를 없애다

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. 이 기준으로 나눠 쓰니까 깔끔했다.