LLM 응답을 타입 안전한 객체로 받아내는 법: Vercel AI SDK의 `streamText`와 `generateObject` 사이에서
LLM을 처음 백엔드에 붙여보는 순간, 대부분 같은 길을 걷게 됩니다. fetch로 OpenAI API를 직접 호출하고, 응답을 JSON.parse로 파싱하고, 그러다 모델이 예상과 다른 JSON 구조를 뱉으면 런타임 에러가 터지는 경험. 저도 그랬습니다. 처음에는 프롬프트에 "반드시 이 JSON 형식으로 답해줘"를 대문자로 세 번씩 강조했고, 그래도 모델이 앞뒤에 ```json ... ```를 붙이면 정규식으로 코드펜스를 벗겨냈고, 필드가 하나 빠지면 옵셔널 처리로 얼버무렸습니다. 그러다 필수 필드에 null이 들어와서 다운스트림 로직이 통째로 터진 날, "이건 아닌데"라는 생각이 들어 Vercel AI SDK로 갈아탔습니다.
AI SDK 4.x의 핵심 가치는 단순함이 아닙니다. 여러 LLM 제공사를 model 파라미터 하나로 교체할 수 있는 추상화, 그리고 Zod 스키마 하나로 스키마 전달 + 런타임 검증 + TypeScript 타입 추론을 동시에 처리하는 구조적 타입 안전성이 진짜 가치입니다. 특히 LLM 응답을 다운스트림 로직이나 DB에 바로 넘겨야 하는 파이프라인에서 이 차이는 극명하게 드러납니다.
이 글에서는 streamText와 generateObject라는 두 함수를 중심으로, 언제 어떤 함수를 선택하는지, Zod 스키마가 실제로 어떤 역할을 하는지, 그리고 프로덕션에서 문서만 봐서는 잘 안 보이는 함정들을 실제 코드와 함께 풀어보려 합니다.
네 함수의 역할 분담을 먼저 정리하고 가기
SDK 코어에는 생성 함수가 네 개 있습니다. 처음 보면 비슷해 보이는데, 선택 기준은 스트리밍 필요 여부와 구조화 필요 여부라는 두 축으로 깔끔하게 갈립니다.
generateText: 비스트리밍 텍스트 생성. 분류나 배치 처리처럼 UX가 대기를 허용할 때.streamText: 청크 단위로 텍스트를 실시간 전송. 채팅처럼 응답 지연이 UX에 직접 영향을 줄 때.generateObject: Zod 스키마 기반의 구조화 객체를 단번에 반환. 다운스트림 로직에 타입 안전한 데이터를 넘겨야 할 때.streamObject:generateObject의 스트리밍 버전. 필드 단위로 점진적으로 채워지는 UI에.
설치부터 시작합니다.
npm install ai @ai-sdk/openai @ai-sdk/anthropic zodZod 스키마가 진짜 하는 일
솔직히 저도 처음엔 Zod를 "그냥 검증 라이브러리"로만 봤는데, AI SDK에서는 세 가지를 동시에 처리합니다.
import { z } from 'zod';
const LeadSchema = z.object({
name: z.string().describe('고객 이름'),
email: z.string().email().describe('이메일 주소'),
company: z.string().optional().describe('회사명'),
intent: z.enum(['purchase', 'demo', 'inquiry']).describe('문의 의도'),
urgency: z.number().min(1).max(10).describe('긴급도 1-10'),
});
type Lead = z.infer<typeof LeadSchema>;이 스키마 하나가:
- 모델에 전달되는 스키마 정의로 변환되어 요청에 포함됩니다 (내부적으로 SDK가 처리)
- 응답이 도착하면 런타임 검증을 돌립니다
z.infer<typeof LeadSchema>로 TypeScript 타입을 자동 추론합니다
.describe()가 중요한 이유가 여기 있습니다. 모델이 스키마의 description을 읽고 필드 의미를 파악하기 때문에, 이 설명이 잘 쓰여 있을수록 구조화 품질이 올라갑니다.
generateObject로 구조화 데이터 추출하기
기본 사용 패턴
비정형 텍스트에서 구조화 데이터를 뽑아내는 파이프라인 예시입니다.
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const LeadSchema = z.object({
name: z.string().describe('고객 이름'),
email: z.string().email().describe('이메일 주소'),
company: z.string().optional().describe('회사명'),
intent: z.enum(['purchase', 'demo', 'inquiry']).describe('문의 의도'),
urgency: z.number().min(1).max(10).describe('긴급도 1-10'),
});
async function extractLead(rawInput: string) {
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: LeadSchema,
prompt: `다음 문의 내용에서 리드 정보를 추출하세요:\n\n${rawInput}`,
});
console.log(object.intent);
console.log(object.urgency);
return object;
}object는 이미 Lead 타입으로 좁혀져 있어서 자동완성이 그대로 됩니다. 스키마 검증에 실패하면 함수가 예외를 던지기 때문에, try/catch로 실패 경로를 명시적으로 분리하는 편이 안전합니다.
generateObject의 내부 동작은 제공사마다 다릅니다. OpenAI 프로바이더는 최근 모델에 대해 Structured Outputs(JSON Schema 기반 강제)를 활용하고, Anthropic 프로바이더는 tool-use 기반 추출을 활용합니다. 겉으로는 동일한 코드지만 내부 메커니즘이 다르다는 점은 뒤의 트레이드오프 섹션에서 다시 짚습니다.
모델 교체는 파라미터 하나
import { anthropic } from '@ai-sdk/anthropic';
const { object } = await generateObject({
model: anthropic('claude-opus-5'),
schema: LeadSchema,
prompt: `...`,
});streamText로 채팅 인터페이스 만들기
Next.js App Router의 Route Handler에서 streamText를 사용하는 표준 패턴입니다. Edge Runtime과도 호환됩니다. onError 콜백은 별도 문단으로 다루기보다는 옵션의 하나이므로 기본 예시에 함께 넣습니다.
// app/api/chat/route.ts
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export const runtime = 'edge';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages,
system: '당신은 친절한 고객 지원 담당자입니다.',
onError: ({ error }) => {
console.error('스트리밍 에러:', error);
},
});
return result.toDataStreamResponse();
}클라이언트에서는 useChat 훅이 이 스트림을 소비합니다.
// app/chat/page.tsx
'use client';
import { useChat } from 'ai/react';
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: '/api/chat',
});
return (
<div>
{messages.map(m => (
<div key={m.id}>
<strong>{m.role}:</strong> {m.content}
</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
<button type="submit">전송</button>
</form>
</div>
);
}streamObject로 AI가 채우는 UI 만들기
구조화 데이터를 스트리밍으로 받아서 UI 필드가 하나씩 채워지는 패턴입니다.
// 서버 사이드 (API Route)
import { streamObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const ProductAnalysisSchema = z.object({
summary: z.string().describe('제품 요약'),
strengths: z.array(z.string()).describe('강점 목록'),
weaknesses: z.array(z.string()).describe('약점 목록'),
targetAudience: z.string().describe('타깃 고객층'),
pricePoint: z.enum(['budget', 'mid-range', 'premium']).describe('가격대'),
});
export async function POST(req: Request) {
const { productDescription } = await req.json();
const result = streamObject({
model: openai('gpt-4o'),
schema: ProductAnalysisSchema,
prompt: `다음 제품을 분석하세요:\n\n${productDescription}`,
});
return result.toTextStreamResponse();
}클라이언트에서는 useObject 훅으로 부분 데이터를 실시간으로 렌더링합니다. useObject는 toTextStreamResponse()가 내보내는 JSON 텍스트 스트림을 파싱해 부분 객체로 조립하는 방식으로 동작합니다. 서버 응답 방식과 클라이언트 훅이 짝을 이루도록 문서 예시에 맞춰 사용하는 게 안전합니다.
'use client';
import { experimental_useObject as useObject } from 'ai/react';
import { z } from 'zod';
const ProductAnalysisSchema = z.object({
summary: z.string(),
strengths: z.array(z.string()),
weaknesses: z.array(z.string()),
targetAudience: z.string(),
pricePoint: z.enum(['budget', 'mid-range', 'premium']),
});
export default function ProductAnalysis() {
const { object, submit, isLoading } = useObject({
api: '/api/analyze',
schema: ProductAnalysisSchema,
});
return (
<div>
<button onClick={() => submit({ productDescription: '...' })}>
분석 시작
</button>
{object?.summary && <p>{object.summary}</p>}
{object?.strengths?.map((s, i) => <li key={i}>{s}</li>)}
{object?.pricePoint && <span>가격대: {object.pricePoint}</span>}
</div>
);
}streamObject에서 배열은 어떻게 흘러오는가
streamObject의 기본 모드에서 object는 Partial<T>로 노출됩니다. 즉 문자열 필드는 토큰이 들어오는 대로 조금씩 길어지고, 배열 필드도 요소가 채워지는 중간 상태가 그대로 반영됩니다. 실무에서는 배열의 마지막 요소가 완전한지 확인이 어려워서, 화면에 표시할 때 마지막 요소는 스켈레톤 처리하거나 완성이 확인된 요소만 렌더링하는 방어 로직을 넣는 편입니다.
배열이 완성될 때마다 하나씩 처리하고 싶다면, streamObject의 output: 'array' 모드를 별도로 활용하는 방법도 있습니다. 이 모드에서는 요소 하나가 스키마 검증을 통과할 때마다 elementStream으로 전달됩니다. 자세한 시그니처는 SDK 버전마다 다를 수 있으니 사용 중인 버전의 문서를 확인하는 게 좋습니다.
streamText와 구조화 출력을 동시에 활용하기 (실험적)
텍스트를 스트리밍하면서 동시에 구조화 데이터도 뽑고 싶은 경우가 있습니다. 예를 들어 채팅 응답을 스트리밍하면서 감정 분석 결과를 별도로 받고 싶을 때입니다. AI SDK는 이를 위한 실험적 옵션을 제공합니다. 다만 experimental_ 접두사가 붙은 API는 마이너 버전에서도 시그니처가 바뀔 수 있고, 아래 코드는 개념적 예시로 봐주세요. 실제 옵션명·import 경로·소비 방식은 사용 중인 SDK 버전 문서에서 재확인해야 합니다.
// 개념적 예시 — 실제 API명은 버전마다 다를 수 있음
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const SentimentSchema = z.object({
sentiment: z.enum(['positive', 'neutral', 'negative']),
confidence: z.number().min(0).max(1),
});
// 실험적 구조화 출력 옵션을 사용해 텍스트와 객체를 함께 얻는 그림
const result = streamText({
model: openai('gpt-4o'),
prompt: '오늘 새 프로젝트를 시작했는데 정말 설레네요!',
// experimental_output: ... (SDK 버전별 정확한 형태는 공식 문서 참고)
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}프로덕션에서 실험적 API를 쓸 때는 ai 패키지의 정확한 패치 버전까지 락파일에 고정해두는 편이 안전합니다.
멀티스텝 툴 호출 패턴
streamText에 툴 정의를 결합하면, 에이전트가 외부 API나 DB를 체인으로 호출하는 구조를 만들 수 있습니다. maxSteps가 재귀 깊이 상한이고, 이 값을 지정하지 않으면 툴 호출이 예상보다 길게 이어질 수 있습니다.
import { streamText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// 아래 db, priceService는 개념적 예시입니다. 실제로는 프로젝트의
// 데이터 액세스 레이어와 도메인 서비스를 여기에 연결한다고 가정하세요.
declare const db: {
products: { search(q: string, n: number): Promise<unknown[]> };
};
declare const priceService: {
calculate(productId: string, quantity: number): Promise<number>;
};
const result = streamText({
model: openai('gpt-4o'),
maxSteps: 5,
tools: {
searchProducts: tool({
description: '제품 데이터베이스 검색',
parameters: z.object({
query: z.string().describe('검색어'),
maxResults: z.number().default(10),
}),
execute: async ({ query, maxResults }) => {
const results = await db.products.search(query, maxResults);
return results;
},
}),
calculatePrice: tool({
description: '가격 계산',
parameters: z.object({
productId: z.string(),
quantity: z.number(),
}),
execute: async ({ productId, quantity }) => {
const price = await priceService.calculate(productId, quantity);
return { totalPrice: price };
},
}),
},
prompt: '노트북 5대의 총 가격을 알려주세요.',
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}maxSteps가 없으면 툴 호출이 자기 자신을 반복적으로 참조하는 상황이 종종 발생합니다. 운영 환경에서는 상한을 명시적으로 잡아두는 편을 권합니다.
트레이드오프: 문서만 봐서는 잘 안 보이는 것들
비교 표
| 항목 | generateText |
generateObject |
streamObject |
streamText |
|---|---|---|---|---|
| 반환 형태 | 문자열 | 완전한 객체 | Partial<T> 스트리밍 |
텍스트 청크 |
| 타입 안전성 | 텍스트만 | 완전한 타입 | 부분 타입 | 텍스트만 |
| 구조화 데이터 | 없음 | 응답 완료 후 | 필드 단위 점진적 | 실험적 옵션으로 병행 가능 |
| UX | 완료 후 표시 | 로딩 후 전체 표시 | 필드가 하나씩 채워짐 | 텍스트 스트리밍 |
| 배열 처리 | 해당 없음 | 완성 후 전달 | 요소가 채워지는 중간 상태 노출 | 해당 없음 |
실무에서 자주 밟는 함정들
1. 큰 배열은 응집도가 떨어질 수 있다 (경험칙)
이건 벤치마크가 아니라 어디까지나 제 경험이지만, 배열 요소가 많아질수록 후반부로 갈수록 필드가 누락되거나 JSON이 잘리는 확률이 눈에 띄게 올라갑니다. 요소 개수의 임계는 모델·프롬프트 길이·스키마 복잡도에 따라 크게 달라지기 때문에 "몇 개 이상이면 위험" 같은 숫자로 못을 박기는 어렵습니다. 저는 실패가 반복되면 배치 크기를 절반으로 줄여가며 안정 지점을 찾고, 그 값 근처에서 병렬 호출 후 병합하는 방식을 씁니다.
import { chunk } from 'es-toolkit'; // 또는 lodash의 chunk
async function extractLargeList(items: string[]): Promise<Result[]> {
const BATCH_SIZE = 10; // 프로젝트별 안정 지점을 실험으로 찾으세요
const batches = chunk(items, BATCH_SIZE);
const results = await Promise.all(
batches.map(batch =>
generateObject({
model: openai('gpt-4o'),
schema: z.object({ items: z.array(ResultSchema) }),
prompt: `다음 항목들을 처리하세요: ${JSON.stringify(batch)}`,
})
)
);
return results.flatMap(r => r.object.items);
}2. 비용 추적과 레이트 제한은 직접 붙여야 한다
SDK가 응답에 usage를 실어주긴 하지만, 비용 집계나 레이트 제한은 애플리케이션 레이어의 몫입니다. 매 호출마다 usage를 읽는 코드를 흩뿌리기보다는, wrapLanguageModel과 미들웨어로 모델 자체를 감싸서 관측을 한 곳에 모으는 방식이 관리하기 편합니다.
import { wrapLanguageModel, generateObject, type LanguageModelV1Middleware } from 'ai';
import { openai } from '@ai-sdk/openai';
// 개념적 예시 — 미들웨어 훅 이름과 시그니처는 사용 중인 SDK 버전 문서로 확인하세요
const telemetryMiddleware: LanguageModelV1Middleware = {
wrapGenerate: async ({ doGenerate, params }) => {
const started = Date.now();
const result = await doGenerate();
metrics.track({
model: params.mode.type,
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
latencyMs: Date.now() - started,
});
return result;
},
};
const observedModel = wrapLanguageModel({
model: openai('gpt-4o'),
middleware: telemetryMiddleware,
});
// 이후 모든 호출은 observedModel을 사용
const { object } = await generateObject({
model: observedModel,
schema: LeadSchema,
prompt: '...',
});레이트 제한도 같은 자리에서 처리하기 좋은데, 미들웨어 안에서 토큰 버킷을 확인하고 초과 시 예외를 던지는 방식으로 확장할 수 있습니다.
3. 구조화 출력과 툴 강제 사용은 상성이 나쁠 수 있다
제공사에 따라 구조화 출력 모드와 toolChoice를 동시에 강제하면 에러가 나거나 한쪽이 무시되는 경우가 있습니다. 구조화 응답과 툴을 함께 쓰고 싶다면, 단계를 분리해서 먼저 툴로 정보 수집을 마친 뒤 마지막 스텝에서 구조화 응답을 뽑는 편이 안전합니다.
4. 제공사별 동작 차이
동일한 Zod 스키마라도 내부 실행 경로가 다릅니다. OpenAI 프로바이더는 최근 모델에 대해 Structured Outputs(JSON Schema 강제)를, Anthropic 프로바이더는 tool-use 기반 추출을 활용합니다. 그래서 옵셔널 필드 처리, enum 외 값 반환, 배열 요소 누락 같은 엣지 케이스에서 미묘한 차이가 납니다. 제공사를 전환할 때는 동일한 골든 세트로 회귀 테스트를 다시 돌려보는 걸 권합니다.
5. 잘린 JSON을 자동 복구하는 옵션
일부 버전의 generateObject는 모델이 파싱 불가능한 JSON을 반환했을 때 텍스트를 수정해서 다시 파싱해볼 수 있는 실험적 훅을 제공합니다. 아래는 개념적 형태이며, 정확한 옵션명과 콜백 시그니처는 사용 중인 SDK 버전 문서에서 재확인하세요.
// 개념적 예시 — 옵션명·파라미터명은 SDK 버전마다 다를 수 있음
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: LeadSchema,
prompt: '...',
// experimental_repairText 형태의 훅이 제공되는 버전에서는
// 텍스트를 조작해 재파싱을 시도하는 콜백을 넘길 수 있습니다.
});생태계 현황
Vercel 공식 블로그에 정리된 릴리스 하이라이트 기준으로 최근 흐름을 정리하면 다음과 같습니다.
| 버전 | 공식 블로그에서 강조된 변화 |
|---|---|
| 4.1 (2025) | 이미지 생성(experimental_generateImage) 도입, 논블로킹 데이터 스트리밍 등 (블로그) |
| 4.2 (2025) | onError 콜백, 텍스트 리페어, useChat 메시지 파트 재설계 등 (블로그) |
이후 버전의 변경 사항은 사용 시점의 공식 릴리스 노트를 직접 확인하는 편이 정확합니다. AI SDK는 마이너 업데이트에서도 실험적 API 시그니처가 자주 조정되기 때문에, 자신이 쓰는 버전의 문서에서 옵션명을 재확인하는 습관이 유용합니다.
마무리
이 글의 관점은 처음에 그렸던 2×2 매트릭스로 되돌아옵니다. 스트리밍이 필요한가와 구조화가 필요한가, 이 두 축이 SDK 함수 선택의 거의 전부를 결정합니다. 자유 텍스트를 완결된 형태로 받으면 되는 배치 처리에는 generateText, 채팅처럼 첫 토큰까지의 체감 지연이 중요한 곳에는 streamText, 다운스트림 로직이나 DB에 그대로 넘길 데이터를 뽑아야 하면 generateObject, "AI가 채우는 UI"라는 패턴에는 streamObject가 놓입니다.
Zod 스키마를 서버·클라이언트가 공유 파일로 참조하는 구조로만 잡아두면, 스키마 파일 하나가 곧 API 계약서가 되고 양쪽 모두 타입 추론의 혜택을 받습니다. 나머지 프로덕션 이슈들 — 대량 배열의 응집도, 비용·레이트 관측, 실험적 API의 버전 리스크 — 은 모델을 미들웨어로 감싸고, 배치 크기를 데이터로 튜닝하고, 패치 버전을 락파일에 고정하는 세 가지 습관으로 상당 부분 완화됩니다.