LLM한테 JSON을 뽑아달라고 하면 잘 되다가도 가끔씩 사고가 난다. 앞에 "네, 요청하신 데이터입니다:" 같은 말을 붙이거나, 필드 하나를 빼먹거나, 숫자여야 할 값을 문자열로 준다. 그럴 때마다 JSON.parse()가 터지고, 결국 정규식으로 코드 블록을 벗겨내는 지저분한 코드를 짜게 된다.
프롬프트에 "무조건 JSON만 응답해"라고 강하게 써봐도 100%는 아니다. 파싱 실패에 대비한 재시도 로직까지 붙이다 보면 배보다 배꼽이 커진다.
구조화 출력이 해결하는 것
Claude API의 Structured Outputs는 이걸 API 레벨에서 강제한다. 프롬프트로 부탁하는 게 아니라, 응답 포맷을 스키마에 맞게 제약을 건다. 프리필(assistant 메시지 미리 채우기) 같은 예전 트릭은 이제 Opus 4.6 이후 모델에서 400 에러가 나니까, 이 방식으로 넘어가는 게 맞다.
TypeScript SDK에서는 Zod 스키마와 messages.parse()를 쓰면 검증까지 자동으로 된다.
import Anthropic from '@anthropic-ai/sdk';
import { z } from 'zod';
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod';
const client = new Anthropic();
const ContactSchema = z.object({
name: z.string(),
email: z.string(),
plan: z.string(),
interests: z.array(z.string()),
demoRequested: z.boolean(),
});
const response = await client.messages.parse({
model: 'claude-opus-4-8',
max_tokens: 1024,
messages: [
{
role: 'user',
content:
'추출해줘: Jane Doe (jane@co.com), Enterprise 플랜 관심, API랑 SDK 궁금해함, 데모 요청함.',
},
],
output_config: {
format: zodOutputFormat(ContactSchema),
},
});
console.log(response.parsed_output!.name); // "Jane Doe"
parsed_output으로 바로 타입 안전한 객체가 나온다. 파싱 실패하면 null이니까 단언(!)보다는 가드를 하나 넣어주는 게 안전하다.
몇 가지 알아둘 점
- 스키마의 모든 객체에는
additionalProperties: false가 필요하다. Zod로 쓰면 SDK가 알아서 처리해준다. minimum,maxLength같은 숫자·문자열 제약이나 재귀 스키마는 지원 안 된다. 이런 건 클라이언트에서 검증해야 한다.- 새 스키마는 첫 요청에 컴파일 비용이 한 번 붙고, 이후 24시간은 캐시된다.
- Citations 기능과는 같이 못 쓴다(400 에러). 스트리밍이나 토큰 카운팅, 확장 사고랑은 잘 붙는다.
도구(tool)를 정의할 때 strict: true를 주면 툴 파라미터도 똑같이 스키마를 100% 보장받을 수 있다.
분류·추출 파이프라인처럼 응답 형태가 딱 정해진 작업이라면, 프롬프트로 애원하는 대신 이걸 쓰는 게 훨씬 마음이 편하다. 재시도 로직이랑 정규식 후처리를 통째로 지웠더니 코드가 눈에 띄게 깔끔해졌다.