블로그 목록
AI

AI SDK generateObject()로 타입 안전한 구조화 응답 받기

AI API를 쓰다 보면 텍스트 생성보다 구조화된 데이터가 필요한 경우가 많다. 상품 정보 추출, 분류 라벨링, 폼 자동 완성 같은 경우다.

기존에는 JSON으로 답해달라고 프롬프트에 적고, 응답이 오면 JSON.parse()하는 방식이었다. 근데 이게 실패율이 생각보다 높다. 모델이 JSON 앞뒤에 설명 텍스트를 붙이거나, 스키마가 살짝 다르게 오는 경우가 꽤 있다.

Vercel AI SDK의 generateObject()는 이 문제를 깔끔하게 해결한다.

기본 사용법

import { generateObject } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';

const { object } = await generateObject({
  model: anthropic('claude-haiku-4-5-20251001'),
  schema: z.object({
    title: z.string(),
    date: z.string().describe('YYYY-MM-DD 형식'),
    location: z.string().optional(),
    priority: z.enum(['low', 'medium', 'high']),
  }),
  prompt: '내일 오후 3시에 강남에서 중요한 미팅 있어요',
});

console.log(object.priority); // 'high' — TypeScript 타입이 보장됨

object는 Zod 스키마에서 추론된 타입을 그대로 가진다. object.priority를 쓰면 'low' | 'medium' | 'high'로 타입이 잡힌다.

실용적인 예시: 텍스트 감성 분석

const { object } = await generateObject({
  model: anthropic('claude-haiku-4-5-20251001'),
  schema: z.object({
    sentiment: z.enum(['positive', 'neutral', 'negative']),
    confidence: z.number().min(0).max(1),
    keywords: z.array(z.string()).max(5),
    summary: z.string().max(100),
  }),
  prompt: `다음 리뷰를 분석해줘: "${reviewText}"`,
});

모델이 직접 수치 추정도 해주니까, 별도 파싱 로직 없이 바로 DB에 저장하거나 UI에 표시할 수 있다.

배열로 여러 항목 한번에 추출

const { object } = await generateObject({
  model: anthropic('claude-haiku-4-5-20251001'),
  schema: z.object({
    tags: z.array(
      z.object({
        name: z.string(),
        category: z.enum(['기술', '비즈니스', '라이프스타일']),
      }),
    ),
  }),
  prompt: `이 블로그 글에 적합한 태그를 5개 뽑아줘: "${articleContent}"`,
});

streamObject로 점진적 수신

응답이 긴 구조라면 streamObject로 부분적으로 먼저 받을 수 있다.

import { streamObject } from 'ai';

const { partialObjectStream } = streamObject({
  model: anthropic('claude-haiku-4-5-20251001'),
  schema: z.object({
    sections: z.array(z.object({
      heading: z.string(),
      content: z.string(),
    })),
  }),
  prompt: `다음 주제로 블로그 글의 목차와 각 섹션 요약을 작성해줘: ${topic}`,
});

for await (const partial of partialObjectStream) {
  console.log(partial); // 완성된 필드부터 순차적으로 채워짐
}

목록이 길 때 첫 번째 항목부터 UI에 표시할 수 있어서 체감 응답 속도가 빨라진다.

스키마 설계가 핵심

모델이 잘 따르는 스키마가 따로 있다. 필드명이 명확하고, enum 범위를 좁게 잡을수록 정확도가 높아진다.

// 덜 명확한 스키마
const vague = z.object({
  data: z.string(),  // 뭔지 모호함
  type: z.string(),  // 너무 열려있음
});

// 더 나은 스키마
const clear = z.object({
  productName: z.string().describe('상품의 공식 이름'),
  category: z.enum(['전자제품', '의류', '식품', '기타']),
  priceRange: z.object({
    min: z.number(),
    max: z.number(),
  }).describe('원 단위 가격 범위'),
});

.describe()로 필드 설명을 추가하면 모델이 의도를 더 정확히 파악한다. 특히 필드명만으로 의미가 애매한 경우에 효과가 있다.

.describe()로 모델에게 힌트 주기

z.object({
  date: z.string().describe('ISO 8601 형식. 연도가 없으면 올해 기준으로 처리'),
  duration: z.number().describe('분 단위 정수. 명시되지 않으면 60으로 기본값'),
});

이게 없으면 모델이 포맷을 임의로 결정해버리는 경우가 있다. 날짜나 숫자 단위처럼 여러 해석이 가능한 필드에는 꼭 붙여두는 게 낫다.

Next.js API Route에서 쓰는 방법

// app/api/analyze/route.ts
import { generateObject } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';

export async function POST(req: Request) {
  const { text } = await req.json();

  const { object } = await generateObject({
    model: anthropic('claude-sonnet-4-6'),
    schema: z.object({
      category: z.enum(['질문', '요청', '피드백', '기타']),
      priority: z.number().min(1).max(5),
      summary: z.string().max(100),
    }),
    prompt: `다음 사용자 메시지를 분류해줘: "${text}"`,
  });

  return Response.json(object);
}

응답 타입이 명확하니까 프론트에서도 그냥 쓰면 된다.


generateText()로 시작했다가 generateObject()로 넘어오면 코드가 확실히 깔끔해진다. 파싱 에러 처리하는 코드가 사라지고, 타입이 자동으로 붙어서 IDE 자동완성도 잘 된다. AI 기능을 앱에 붙이는 작업이 많다면 Zod 스키마부터 정의하는 습관을 들이는 게 좋다.