Back

LLM 구조화 출력 2026: 정규식으로 JSON 파싱하던 시대는 끝났어요

다들 한 번쯤 겪어봤을 거예요. GPT한테 "사용자 이름이랑 이메일이랑 감정 점수를 JSON으로 줘"라고 했더니, JSON은 잘 만들어줬는데 마크다운 코드 블록으로 감싸서 줘요. 거기에 친절하게 설명까지 한 줄 달아주고. "저는 AI이므로..." 면책 조항도 빼먹지 않고.

그래서 코드 블록 벗기는 정규식 하나 짜고, 뒤에 붙은 해설 떼는 정규식도 하나 짜요. 근데 어느 날 갑자기 JSON이 아니라 JSONL로 와요. 또 어떤 날은 {"result": ...}로 한 겹 더 감싸서 오고. 10,000번은 멀쩡하다가 10,001번째에서 사용자 이름에 따옴표 하나 들어갔다고 통째로 터져요.

이게 구조화 출력 문제인데, 2026년에는 이걸 직접 손으로 해결할 이유가 없어요.

메이저 LLM 프로바이더 전부 네이티브 구조화 출력을 지원하고, Pydantic(Python)이랑 Zod(TypeScript) 생태계도 충분히 성숙했거든요. 근데 아직도 많은 개발자가 날것의 문자열을 파싱하거나, Function Calling을 꼼수로 쓰고 있어요.

이 글에서는 구조화 출력이 속으로 어떻게 돌아가는지부터, OpenAI/Anthropic/Gemini 각각의 구현법, Python과 TypeScript 생태계, 그리고 — 이게 제일 중요한데 — 프로덕션에서 진짜 물리는 함정들까지 전부 다뤄요.


구조화 출력, 생각보다 훨씬 중요해요

LLM을 프로덕션에 넣으면 마주치는 근본 문제가 있어요:

LLM은 텍스트 생성기예요.
여러분의 앱은 데이터 구조가 필요해요.
이 둘 사이의 간극에 버그가 산다.

LLM 응답을 JSON.parse()로 바로 파싱하는 순간, 여러 가지 위험한 가정 위에서 돌아가는 거예요:

  1. 출력이 유효한 JSON이다 (아닐 수도 있음)
  2. JSON에 내가 기대하는 필드가 있다 (없을 수도 있음)
  3. 필드 타입이 맞다 (문자열 vs 숫자 vs 불리언)
  4. 값이 예상 범위 안이다 (sentiment: -1~1이 아니라 "positive"가 올 수도)
  5. 요청하지 않은 추가 필드가 없다
  6. 다른 입력에서도 응답 포맷이 일관적이다

구조화 출력은 이 여섯 가지를 전부 해결해요. 나중에 파싱하는 게 아니라, 토큰이 생성되는 그 시점에 출력을 제약하는 거거든요.

출력 제어의 세 단계

레벨 1: 프롬프트 엔지니어링 (불안정)
  "name, email, score 필드가 있는 JSON을 돌려줘"
  → 80-95% 확률로 작동
  → 엣지 케이스에서 조용히 실패
  → 타입 보장 없음

레벨 2: 함수 호출 / 도구 사용 (그나마 나음)
  함수 스키마 정의 → 모델이 그걸 "호출"
  → 95-99% 확률로 작동
  → 스키마는 힌트일 뿐, 제약 조건이 아님
  → 유효한 타입 안에서 잘못된 값이 올 수 있음

레벨 3: 네이티브 구조화 출력 (최선)
  JSON Schema로 제약된 디코딩
  → 100% 동작 (스키마 준수 보장)
  → 유한 상태 머신으로 잘못된 토큰을 마스킹
  → 타입과 값 모두 생성 시점에 강제

프로덕션이라면 레벨 3을 써야 해요.


속을 들여다보면 어떻게 돌아가나

대부분 블랙박스로 생각하죠. "스키마 넘기면 유효한 JSON 나오는 거 아냐?" 근데 속을 알아야 디버깅도 되고 최적화도 할 수 있어요.

제약 디코딩 — 마법의 비밀

LLM이 텍스트를 생성할 때 ~100,000개 넘는 토큰 중에서 다음 토큰을 예측하잖아요. 보통은 아무 토큰이나 올 수 있는데, 구조화 출력은 여기에 제약 레이어를 하나 끼워요:

일반 생성:
  토큰 확률: {"hello": 0.3, "{": 0.1, "The": 0.2, ...}
  → 아무 토큰이나 선택 가능

제약 생성 (JSON 객체 시작을 기대하는 상태):
  토큰 확률: {"hello": 0.3, "{": 0.1, "The": 0.2, ...}
  마스크: {"hello": 0, "{": 1, "The": 0, ...}
  → "{" 와 공백 토큰만 유효
  → 모델은 반드시 "{"를 출력

이걸 **유한 상태 머신(FSM)**으로 구현하는데, JSON 스키마에서 "지금 어디까지 왔나"를 계속 추적하는 거예요:

{"name": string, "age": integer} 스키마의 상태 머신:

시작 → "{" 기대
  → "\"name\"" 기대
    → ":" 기대
      → 문자열 값 기대
        → "," 또는 "}" 기대
          → ","면: "\"age\"" 기대
            → ":" 기대
              → 정수 값 기대
                → "}" 기대
                  → 완료

매 상태마다 FSM이 스키마를 어기는 토큰을 전부 차단해요. 구조는 확실히 보장하면서, 모델은 가장 확률 높은 유효한 토큰을 고를 수 있으니까 품질도 지켜져요.

프롬프트 엔지니어링이랑 뭐가 다른데요?

프롬프트: "'score'를 0에서 1 사이의 숫자로 된 JSON 객체를 돌려줘"

제약 디코딩 없이:
  모델 출력: {"score": "0.85"}     ← 숫자가 아니라 문자열
  모델 출력: {"score": 0.85, "confidence": "high"}  ← 요청 안 한 필드
  모델 출력: {"score": 85}         ← 범위 초과
  모델 출력: 네! JSON이요: {"score": 0.85}  ← 서론이 붙음

제약 디코딩 있으면:
  모델 출력: {"score": 0.85}        ← 항상 유효

구현: OpenAI

세 프로바이더 중에서 OpenAI가 가장 완성도 높아요. Chat Completions API에 response_format 하나 넘기면 돼요.

기본 사용법

from openai import OpenAI from pydantic import BaseModel client = OpenAI() class SentimentAnalysis(BaseModel): sentiment: str # "positive", "negative", "neutral" confidence: float key_phrases: list[str] reasoning: str response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "주어진 텍스트의 감정을 분석하세요."}, {"role": "user", "content": "이 제품 정말 최악이에요. 인생 최악의 구매."} ], response_format=SentimentAnalysis, ) result = response.choices[0].message.parsed print(result.sentiment) # "negative" print(result.confidence) # 0.95 print(result.key_phrases) # ["정말 최악", "인생 최악의 구매"]

Enum과 중첩 객체

from enum import Enum from pydantic import BaseModel, Field class Sentiment(str, Enum): positive = "positive" negative = "negative" neutral = "neutral" mixed = "mixed" class Entity(BaseModel): name: str type: str = Field(description="person, organization, product, or location") sentiment: Sentiment class FullAnalysis(BaseModel): overall_sentiment: Sentiment confidence: float = Field(ge=0.0, le=1.0) entities: list[Entity] summary: str = Field(max_length=200) topics: list[str] = Field(min_length=1, max_length=5) response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "텍스트에서 구조화된 분석을 추출하세요."}, {"role": "user", "content": "애플 신형 맥북 프로는 대박인데 팀 쿡 키노트는 졸렸어요."} ], response_format=FullAnalysis, ) result = response.choices[0].message.parsed # result.entities = [ # Entity(name="Apple", type="organization", sentiment="positive"), # Entity(name="MacBook Pro", type="product", sentiment="positive"), # Entity(name="Tim Cook", type="person", sentiment="negative"), # ]

TypeScript + Zod

import OpenAI from 'openai'; import { z } from 'zod'; import { zodResponseFormat } from 'openai/helpers/zod'; const client = new OpenAI(); const SentimentSchema = z.object({ sentiment: z.enum(['positive', 'negative', 'neutral', 'mixed']), confidence: z.number().min(0).max(1), entities: z.array(z.object({ name: z.string(), type: z.enum(['person', 'organization', 'product', 'location']), sentiment: z.enum(['positive', 'negative', 'neutral']), })), summary: z.string(), topics: z.array(z.string()).min(1).max(5), }); type Sentiment = z.infer<typeof SentimentSchema>; const response = await client.beta.chat.completions.parse({ model: 'gpt-5-mini', messages: [ { role: 'system', content: '텍스트에서 구조화된 분석을 추출하세요.' }, { role: 'user', content: '새 React 컴파일러 진짜 대박인데 마이그레이션 문서가 부실해요.' }, ], response_format: zodResponseFormat(SentimentSchema, 'sentiment_analysis'), }); const result: Sentiment = response.choices[0].message.parsed!; console.log(result.sentiment); // "mixed"

구현: Anthropic (Claude)

Anthropic은 좀 다른 접근이에요. 도구 사용(tool use) 방식으로 구조화 출력을 제공하거든요. 도구에 JSON 스키마를 정의해두면, Claude가 그 도구를 호출하는 척하면서 구조화된 데이터를 돌려줘요.

기본 사용법

import anthropic from pydantic import BaseModel client = anthropic.Anthropic() class ExtractedData(BaseModel): name: str email: str company: str role: str urgency: str # "low", "medium", "high", "critical" response = client.messages.create( model="claude-sonnet-4-20260514", max_tokens=1024, tools=[{ "name": "extract_contact", "description": "이메일에서 연락처 정보를 추출합니다.", "input_schema": ExtractedData.model_json_schema(), }], tool_choice={"type": "tool", "name": "extract_contact"}, messages=[{ "role": "user", "content": """이 이메일에서 연락처를 추출해주세요: 안녕하세요, DataFlow Inc.의 Sarah Chen입니다. 프로덕션 파이프라인이 다운됐고 즉각 도움이 필요합니다. [email protected] 로 연락해주세요. 저는 VP of Engineering입니다.""", }], ) # 도구 사용 결과 추출 tool_result = next( block for block in response.content if block.type == "tool_use" ) data = ExtractedData(**tool_result.input) print(data.name) # "Sarah Chen" print(data.urgency) # "critical"

TypeScript + Zod + Anthropic

import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const client = new Anthropic(); const ContactSchema = z.object({ name: z.string(), email: z.string().email(), company: z.string(), role: z.string(), urgency: z.enum(['low', 'medium', 'high', 'critical']), }); const response = await client.messages.create({ model: 'claude-sonnet-4-20260514', max_tokens: 1024, tools: [{ name: 'extract_contact', description: '이메일에서 연락처 정보를 추출합니다.', input_schema: zodToJsonSchema(ContactSchema) as Anthropic.Tool.InputSchema, }], tool_choice: { type: 'tool' as const, name: 'extract_contact' }, messages: [{ role: 'user', content: '추출해주세요: 안녕하세요, Acme Corp CTO John Park입니다 ([email protected]). 급하진 않습니다.', }], }); const toolBlock = response.content.find( (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use' ); const data = ContactSchema.parse(toolBlock!.input); console.log(data.urgency); // "low"

구현: Google Gemini

Gemini도 네이티브로 지원해요. response_schema 파라미터 하나면 되고, OpenAI처럼 제약 디코딩 방식이에요.

기본 사용법

import google.generativeai as genai from pydantic import BaseModel from enum import Enum class Priority(str, Enum): low = "low" medium = "medium" high = "high" critical = "critical" class TaskExtraction(BaseModel): title: str assignee: str priority: Priority deadline: str | None tags: list[str] model = genai.GenerativeModel( "gemini-2.5-flash", generation_config=genai.GenerationConfig( response_mime_type="application/json", response_schema=TaskExtraction, ), ) response = model.generate_content( "태스크를 추출해주세요: 'John이 금요일까지 로그인 버그를 고쳐야 해요. 프로덕션 블로킹이에요. 백엔드랑 auth 태그로.'" ) import json result = TaskExtraction(**json.loads(response.text)) print(result.priority) # "critical" print(result.tags) # ["backend", "auth"]

프로바이더 비교표

프로바이더 고르기 전에 기능별로 한번 비교해볼게요:

기능                  OpenAI           Anthropic         Gemini
─────────────────    ──────────────   ──────────────    ──────────────
방식                  네이티브 SO       도구 사용          네이티브 SO
제약 디코딩            지원              부분 지원          지원
100% 스키마 유효       보장              99%+              보장
스트리밍 지원           지원              지원               지원
Pydantic 네이티브      .parse() 지원     스키마 직접 변환    스키마 직접 변환
Zod 네이티브           헬퍼 함수 제공     직접 변환 필요      직접 변환 필요
중첩 객체              지원              지원               지원
Enum                 지원              지원               지원
Optional 필드         지원              지원               지원
재귀 스키마            제한적             지원               제한적
최대 스키마 깊이        5레벨             제한 없음           제한 없음
거부(Refusal) 처리     지원              해당 없음           해당 없음

추천: 스키마 100% 준수가 필요하면 OpenAI나 Gemini의 네이티브 구조화 출력을 쓰세요. Claude를 이미 쓰고 있다면 도구 사용 패턴도 잘 작동하지만, Pydantic/Zod 검증을 안전망으로 추가하세요.


현업에서 진짜 쓰는 패턴들

패턴 1: 검증 샌드위치

구조화 출력 달았다고 LLM 출력을 그냥 믿으면 큰일 나요. 무조건 한 번 더 검증해야 해요.

from pydantic import BaseModel, Field, field_validator from openai import OpenAI client = OpenAI() class ProductReview(BaseModel): rating: int = Field(ge=1, le=5) title: str = Field(min_length=5, max_length=100) pros: list[str] = Field(min_length=1, max_length=5) cons: list[str] = Field(max_length=5) would_recommend: bool @field_validator('title') @classmethod def title_not_generic(cls, v: str) -> str: generic_titles = ['good', 'bad', 'ok', 'fine', 'great'] if v.lower().strip() in generic_titles: raise ValueError(f'Title too generic: {v}') return v def extract_review(text: str) -> ProductReview: response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "구조화된 제품 리뷰를 추출하세요."}, {"role": "user", "content": text}, ], response_format=ProductReview, ) result = response.choices[0].message.parsed if response.choices[0].message.refusal: raise ValueError(f"모델 거부: {response.choices[0].message.refusal}") # OpenAI가 스키마 준수를 보장하더라도 재검증 # (JSON Schema로 표현 못 하는 비즈니스 로직 위반을 잡아냄) return ProductReview.model_validate(result.model_dump())

패턴 2: 재시도 + 단계적 후퇴

구조화 출력이 실패하는 일은 드문데, 아예 안 일어나진 않아요. 터졌을 때 깔끔하게 후퇴할 수 있게 짜놔야 해요:

from tenacity import retry, stop_after_attempt, wait_exponential @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), ) def extract_with_retry(text: str, schema: type[BaseModel]) -> BaseModel: try: response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "구조화된 데이터를 정확하게 추출하세요."}, {"role": "user", "content": text}, ], response_format=schema, ) result = response.choices[0].message.parsed return schema.model_validate(result.model_dump()) except Exception as e: print(f"시도 실패: {e}") raise # 사용법 try: review = extract_with_retry(user_text, ProductReview) except Exception: # 폴백: 더 단순한 스키마 또는 수동 처리 review = extract_with_retry(user_text, SimpleReview)

패턴 3: 멀티 프로바이더 폴백

한 프로바이더에 몰빵하면 안 돼요. 폴백 체인 깔아놓으세요:

import OpenAI from 'openai'; import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; import { zodResponseFormat } from 'openai/helpers/zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const schema = z.object({ intent: z.enum(['question', 'complaint', 'feedback', 'request']), urgency: z.enum(['low', 'medium', 'high']), summary: z.string().max(200), action_required: z.boolean(), }); type TicketClassification = z.infer<typeof schema>; async function classifyTicket(text: string): Promise<TicketClassification> { // OpenAI 먼저 시도 (가장 빠른 구조화 출력) try { const openai = new OpenAI(); const response = await openai.beta.chat.completions.parse({ model: 'gpt-5-mini', messages: [ { role: 'system', content: '지원 티켓을 분류하세요.' }, { role: 'user', content: text }, ], response_format: zodResponseFormat(schema, 'ticket'), }); return schema.parse(response.choices[0].message.parsed); } catch (openaiError) { console.warn('OpenAI 실패, Claude로 폴백:', openaiError); } // Anthropic 폴백 try { const anthropic = new Anthropic(); const response = await anthropic.messages.create({ model: 'claude-sonnet-4-20260514', max_tokens: 512, tools: [{ name: 'classify', description: '티켓을 분류합니다.', input_schema: zodToJsonSchema(schema) as Anthropic.Tool.InputSchema, }], tool_choice: { type: 'tool' as const, name: 'classify' }, messages: [{ role: 'user', content: `분류하세요: ${text}` }], }); const toolBlock = response.content.find( (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use' ); return schema.parse(toolBlock!.input); } catch (anthropicError) { console.error('두 프로바이더 모두 실패:', anthropicError); throw new Error('모든 LLM 프로바이더에서 구조화 출력 실패'); } }

패턴 4: 스트리밍 구조화 출력

긴 구조화 응답에서는 부분 결과를 스트리밍하세요:

from openai import OpenAI from pydantic import BaseModel client = OpenAI() class Article(BaseModel): title: str sections: list[dict] # {"heading": str, "content": str} tags: list[str] word_count: int # 구조화 출력 + 스트리밍 with client.beta.chat.completions.stream( model="gpt-5", messages=[ {"role": "system", "content": "상세한 섹션이 포함된 글 개요를 생성하세요."}, {"role": "user", "content": "2026년 WebAssembly에 대해 작성해주세요."} ], response_format=Article, ) as stream: for event in stream: snapshot = event.snapshot if snapshot and snapshot.choices[0].message.content: partial = snapshot.choices[0].message.content print(f"수신 중: {len(partial)} chars...") # 최종 파싱된 결과 final = stream.get_final_completion() article = final.choices[0].message.parsed print(f"제목: {article.title} ({article.word_count}자)")

삽질 안 하려면 알아둬야 할 것들

함정 1: 스키마가 복잡하면 느려진다

스키마에 뭘 추가할 때마다 레이턴시가 올라가요. 깊게 중첩된 스키마 넣으면 응답 시간이 2~3배 뛸 수 있어요.

스키마 복잡도 vs 레이턴시 (gpt-5-mini, 평균):

스키마                            tok/s       첫 토큰      전체 시간
─────────────────────────────    ──────────  ──────────   ──────────
스키마 없음 (프리 텍스트)           85 tok/s    ~200ms       ~500ms
단순 (필드 3개)                    78 tok/s    ~250ms       ~550ms
중간 (필드 10개, Enum 1개)         65 tok/s    ~350ms       ~800ms
복잡 (필드 20개+, 중첩)            45 tok/s    ~500ms       ~1.5s
매우 복잡 (재귀)                   30 tok/s    ~800ms       ~3s

해결법: 큰 스키마를 작은 여러 호출로 쪼개서 병렬로 날리세요:

# ❌ 거대한 스키마 하나 class FullDocumentAnalysis(BaseModel): entities: list[Entity] # 각각 20+ 필드 sentiment: SentimentDetail # 10+ 필드 summary: Summary # 5 필드 classification: Classification # 8 필드 # ... 총 50+ 필드 # ✅ 작은 스키마 파이프라인 class Step1_Entities(BaseModel): entities: list[SimpleEntity] # 각각 5 필드 class Step2_Sentiment(BaseModel): overall: str confidence: float aspects: list[str] class Step3_Classification(BaseModel): category: str subcategory: str priority: str # 병렬 실행 import asyncio entities, sentiment, classification = await asyncio.gather( extract(text, Step1_Entities), extract(text, Step2_Sentiment), extract(text, Step3_Classification), )

함정 2: 스키마 바꿨더니 다 깨졌다

앱이 바뀌면 스키마도 바뀌잖아요. 근데 LLM은 지난 화요일에 user_namename으로 바꿨다는 걸 모르죠.

# 버전 1 (1월 배포) class UserProfile_v1(BaseModel): user_name: str email_address: str age: int # 버전 2 (2월 배포) class UserProfile_v2(BaseModel): name: str # 이름 변경! email: str # 이름 변경! age: int location: str | None # 새 필드 # 문제: 캐시된 옛날 프롬프트가 v1 필드명을 참조하고 있음 # 문제: 다운스트림 소비자가 v1 포맷을 기대하고 있음 # 문제: A/B 테스트에서 두 버전이 동시에 돌아감

해결법: 명시적 스키마 버전과 마이그레이션을 쓰세요:

from pydantic import BaseModel, Field from typing import Literal class UserProfile(BaseModel): schema_version: Literal["2.0"] = "2.0" name: str = Field(alias="user_name") # 이전 필드명도 수용 email: str = Field(alias="email_address") age: int location: str | None = None class Config: populate_by_name = True # 별칭과 필드명 모두 수용

함정 3: 빈 배열? LLM은 못 참아요

뽑아낼 게 정말 하나도 없을 때도, LLM은 빈 배열을 돌려주는 걸 극혐해요. "뭐라도 채워야 될 것 같아서" 없는 걸 지어내죠.

# 입력: "오늘 날씨 좋네요." # 기대값: {"entities": [], "topics": ["weather"]} # 실제: {"entities": [{"name": "날씨", "type": "concept"}], "topics": ["weather"]} # 모델은 빈 배열을 극도로 싫어해요. # 해결법: 빈 배열이 유효하다고 명시적으로 알려주세요 class Extraction(BaseModel): entities: list[Entity] = Field( default_factory=list, description="텍스트에서 발견된 개체명. 없으면 빈 리스트 []를 반환해주세요." )

함정 4: 비슷한 Enum 값은 LLM도 헷갈려해요

class Priority(str, Enum): low = "low" medium = "medium" high = "high" critical = "critical" urgent = "urgent" # ← "critical"이랑 뭐가 다른 거야? # 모델이 "critical"과 "urgent" 사이에서 왔다갔다해요. # 걔네도 차이를 모르거든요. # 해결법: 확실히 구분되는 적은 수의 Enum 값 + 설명 class Priority(str, Enum): low = "low" # 며칠/몇 주 후에 해도 됨 medium = "medium" # 이번 스프린트에 처리 high = "high" # 오늘 안에 처리 critical = "critical" # 프로덕션 다운됨, 지금 당장

함정 5: 토큰 다 쓰면 JSON도 잘려요

구조화 출력이라고 토큰 제한이 사라지는 건 아니에요. summary 필드에 max_length=500 걸어뒀는데 JSON 완성 전에 max_tokens를 다 써버리면:

{"title": "분석", "summary": "이 제품은 뛰어난 성능을 보여주

유효하지 않은 JSON이에요. 잘려버린 거죠.

해결법: max_tokens를 예상 출력보다 넉넉하게 잡고, finish_reason을 체크하세요:

response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[...], response_format=MySchema, max_tokens=4096, # 넉넉하게 ) if response.choices[0].finish_reason == "length": # 응답이 잘렸어요! max_tokens를 올리거나 스키마를 단순화하세요. raise ValueError("응답 잘림 — max_tokens를 올리거나 스키마를 단순화하세요")

함정 6: 모델이 대답을 거부할 수도 있어요 (OpenAI만)

OpenAI의 구조화 출력은 입력이 안전 필터에 걸리면 아예 안 만들어줘요. message.parsedNone이고 message.refusal에 거부 이유가 달려요.

response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "user", "content": "이 고객 불만을 분석하세요: [잠재적으로 민감한 내용]"} ], response_format=Analysis, ) parsed = response.choices[0].message.parsed refusal = response.choices[0].message.refusal if refusal: # 터뜨리지 마세요! 우아하게 처리하세요. print(f"모델 거부: {refusal}") # 폴백: 다른 모델 사용, 리프레이징, 또는 사람 리뷰 플래그 elif parsed: process(parsed)

Pydantic vs Zod: 뭘 써야 되나

Python이면 Pydantic, TypeScript면 Zod — 이건 거의 정해진 건데, 기능 차이는 알아두면 좋아요:

기능                    Pydantic (Python)     Zod (TypeScript)
───────────────────    ────────────────────  ────────────────────
타입 추론               어노테이션 기반         .infer<> 기반
런타임 검증             내장                   내장
JSON Schema 내보내기    .model_json_schema()  zodToJsonSchema()
기본값                 Field(default=...)     .default(value)
커스텀 검증자           @field_validator       .refine() / .transform()
중첩 객체               네이티브               네이티브
판별 유니온             지원                   .discriminatedUnion()
재귀 스키마             지원                   z.lazy()
직렬화                 .model_dump()          N/A (일반 객체)
ORM 연동               지원 (SQLAlchemy)      Drizzle/Prisma
커뮤니티 규모           매우 큼                 매우 큼
OpenAI 네이티브 지원    .parse() 지원           zodResponseFormat 지원
Anthropic 연동         .model_json_schema()   zodToJsonSchema()

Pydantic을 쓸 때

# 복잡한 데이터 파이프라인에서 Pydantic이 빛나요: from pydantic import BaseModel, Field, field_validator, model_validator class Invoice(BaseModel): items: list[LineItem] subtotal: float tax_rate: float = Field(ge=0, le=0.5) total: float @model_validator(mode='after') def validate_total(self) -> 'Invoice': expected = self.subtotal * (1 + self.tax_rate) if abs(self.total - expected) > 0.01: raise ValueError( f'Total {self.total}이 ' f'subtotal {self.subtotal} × (1 + {self.tax_rate}) = {expected}과 안 맞아요' ) return self

Zod를 쓸 때

// API 검증과 타입 안전 파이프라인에서 Zod가 빛나요: const InvoiceSchema = z.object({ items: z.array(LineItemSchema), subtotal: z.number().positive(), taxRate: z.number().min(0).max(0.5), total: z.number().positive(), }).refine( (data) => Math.abs(data.total - data.subtotal * (1 + data.taxRate)) < 0.01, { message: 'total이 subtotal × (1 + taxRate)과 맞지 않아요' } ); // 타입이 자동으로 추론돼요 — 별도 interface 필요 없어요 type Invoice = z.infer<typeof InvoiceSchema>;

고급 패턴: 스키마를 조합해서 파이프라인 만들기

실무에서 스키마 하나로 끝나는 일은 거의 없어요. 여러 스키마를 단계별로 엮어서 쓰는 법이에요:

from pydantic import BaseModel, Field from enum import Enum from openai import OpenAI client = OpenAI() # 1단계: 빠른 분류 (저렴하고 빠른 모델) class TicketType(str, Enum): bug = "bug" feature = "feature" question = "question" billing = "billing" class QuickClassification(BaseModel): type: TicketType language: str = Field(description="해당하는 프로그래밍 언어, 없으면 'N/A'") needs_human: bool # 2단계: 상세 추출 (버그일 때만, 더 똑똑한 모델 사용) class BugReport(BaseModel): title: str = Field(max_length=100) steps_to_reproduce: list[str] = Field(min_length=1) expected_behavior: str actual_behavior: str environment: dict[str, str] # {"os": "...", "browser": "...", 등} severity: str = Field(description="minor, major, or critical") # 3단계: 자동 라우팅 class RoutingDecision(BaseModel): team: str = Field(description="backend, frontend, infra, or billing") priority: int = Field(ge=1, le=5) suggested_assignee: str | None auto_reply: str = Field(max_length=500) async def process_ticket(text: str): # 1단계: 분류 (저렴하고 빠르게) classification = await extract(text, QuickClassification, model="gpt-5-mini") if classification.needs_human: return route_to_human(text) # 2단계: 상세 추출 (버그인 경우만) details = None if classification.type == TicketType.bug: details = await extract(text, BugReport, model="gpt-5") # 3단계: 라우팅 context = f"유형: {classification.type}. " if details: context += f"심각도: {details.severity}. 재현 단계: {details.steps_to_reproduce}" routing = await extract(context, RoutingDecision, model="gpt-5-mini") return { "classification": classification, "details": details, "routing": routing, }

비용 최적화: 구조화 출력은 공짜가 아니에요

구조화 출력에는 오버헤드가 있어요:

구조화 출력 비용 요소:

1. 스키마 토큰: JSON 스키마가 시스템 프롬프트에 포함돼요.
   단순 스키마 (필드 3개):   ~50 토큰  ($0.00001)
   복잡 스키마 (필드 20개):  ~500 토큰 ($0.0001)
   매우 복잡 (중첩):         ~2000 토큰 ($0.0004)

2. 출력 토큰: 구조화 출력은 프리 텍스트보다 토큰을 더 많이 써요.
   "감정은 긍정적입니다" = 5 토큰
   {"sentiment": "positive"}  = 7 토큰 (~40% 더 많음)
   전체 구조화 응답 = 프리 텍스트 요약의 2~3배

3. 레이턴시 오버헤드: 제약 디코딩이 ~10-30% 레이턴시를 추가.

월간 비용 영향 (일 100만 요청):
────────────────────────────────────────────────────
접근 방식              토큰/요청   월 비용       레이턴시
프리 텍스트 응답        50         $1,500       200ms
단순 구조화             70         $2,100       250ms
복잡 구조화             200        $6,000       400ms

절약 전략:
  → 구조화 출력은 진짜 필요한 곳에서만 쓰세요
  → 요약에는 프리 텍스트, 데이터 추출에는 구조화
  → 응답을 적극적으로 캐싱 (같은 입력 = 같은 출력)
  → 분류에는 gpt-5-mini, 복잡한 추출에는 gpt-5

써야 할 때 vs 안 써도 될 때

다 구조화 출력으로 할 필요는 없어요:

구조화 출력 쓸 때:
  ✅ 출력이 코드로 바로 들어갈 때 (API 응답, DB 삽입)
  ✅ 타입 보장이 필요할 때 (숫자가 문자열이면 안 되는 경우)
  ✅ 여러 다운스트림이 일관된 포맷을 기대할 때
  ✅ 자동화 파이프라인 (사람 개입 없이 돌아가는 시스템)
  ✅ 비정형 텍스트에서 데이터 추출할 때 (이메일, 문서, 로그)

구조화 출력 안 써도 될 때:
  ❌ 출력을 사용자에게 직접 보여줄 때 (채팅, 콘텐츠 생성)
  ❌ 자유로운 창의적 응답이 필요할 때
  ❌ 스키마가 태스크보다 복잡해지는 경우
  ❌ 프로토타이핑 중이고 스키마가 매일 바뀔 때
  ❌ 비용이 중요하고 프리 텍스트로도 충분할 때

앞으로 뭐가 오나

2026 Q1-Q2 (지금)

  • ✅ OpenAI 구조화 출력 GA + 스트리밍
  • ✅ Anthropic 도구 사용 Claude Sonnet/Opus 안정화
  • ✅ Gemini 2.5 네이티브 JSON 모드 + 스키마 강제
  • 🔄 Pydantic v3 베타, 네이티브 LLM 연동 훅
  • 🔄 Zod v4, JSON Schema 호환성 개선

2026 Q3-Q4

  • 크로스 프로바이더 스키마 이식성 (스키마 하나로 아무 LLM에서나)
  • 스트리밍 부분 객체 + 필드 단위 콜백
  • TypeScript 인터페이스에서 스키마 자동 생성 (Zod 불필요)
  • 이미지/오디오 제약 디코딩 (멀티모달 구조화 출력)

2027 이후

  • 구조화 출력이 기본값이 됨 (프리 텍스트가 옵트인으로)
  • LLM이 런타임에 스키마 변경을 협상
  • 모델 가중치에 검증 로직 직접 내장 (FSM 불필요)

정리하면

2026년 프로덕션 LLM 앱에서 구조화 출력은 더 이상 선택이 아니에요. GPT 응답을 정규식으로 파싱하고 기도하던 시대는 끝났어요.

핵심 정리:

  1. 네이티브 구조화 출력을 쓰세요 (OpenAI의 .parse(), Gemini의 response_schema). JSON 파서를 직접 만들지 마세요.
  2. 항상 검증하세요. 프로바이더가 스키마 준수를 보장하더라도 Pydantic이나 Zod로. JSON Schema가 못 잡는 비즈니스 로직 위반을 잡아줘요.
  3. 비용을 주시하세요. 복잡한 스키마는 비싸요. 작은 병렬 호출로 쪼개세요.
  4. 엣지 케이스를 처리하세요: 거부, 잘림, 빈 배열, Enum 혼동이 프로덕션에서 물어요.
  5. 폴백 체인을 만드세요. 하나의 프로바이더가 100% 신뢰할 수 있는 건 아니에요. 크리티컬 패스에는 멀티 프로바이더 패턴을 쓰세요.

진짜 질문은 "구조화 출력 써야 하나?"가 아니에요. **"2026년인데 아직도 왜 정규식으로 파싱하고 있어요?"**예요.

LLMStructured OutputOpenAIAnthropicGeminiPydanticZodAI EngineeringTypeScriptPython

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요