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 스키마부터 정의하는 습관을 들이는 게 좋다.