LLM 구조화된 출력(Structured Outputs)은 JSON Schema를 API 요청에 첨부해 모델이 반환할 응답의 필드 이름·타입·필수 여부를 디코딩 단계에서 강제하는 기능이다. 2026년 기준 OpenAI는 response_format={"type":"json_schema","strict":true}로 99.9%+ 스키마 준수를, Anthropic Claude는 tool_use 우회 방식으로 99.8%를, Google Gemini는 response_json_schema로 99.7%의 준수율을 보인다. 솔직히 말하자면, 나는 지원 티켓 라우터를 처음 프로덕션에 올렸을 때 정규식 파싱과 try/except로 3주간 씨름했다. Structured Outputs로 갈아탄 후 파싱 실패 알림이 그날 밤부터 조용해졌다. 이 글은 세 프로바이더의 스키마 강제 방식 차이, Pydantic + Instructor 재시도 패턴, 그리고 함수 호출(Function Calling)과의 경계를 실전 코드로 정리한다.
OpenAI Structured Outputs는 strict:true 사용 시 문법 수준에서 스키마를 강제하지만, 모든 필드를 required에 넣고 additionalProperties:false를 지정해야 한다.
Claude는 별도 JSON 모드가 없다. 대신 "가짜 도구(dummy tool)"의 input_schema로 출력 구조를 강제하며, OpenAI와 달리 additionalProperties:false가 필수가 아니라 스키마 설계 자유도가 높다.
Gemini의 response_json_schema는 60~100 토큰의 오버헤드로 가장 낮은 실패율을 보이지만, 3~4단계 이상 중첩 스키마에서 1~2% 실패율이 발생하므로 프로덕션 배포 전 반드시 테스트해야 한다.
Structured Outputs는 "모델의 최종 답을 특정 모양으로 강제"하고, Function Calling은 "모델이 어떤 액션을 취할지 결정"한다. 결정 여지가 없으면 전자, 있으면 후자가 원칙이다.
Instructor + Pydantic 조합은 스키마 위반 시 검증 오류를 대화에 다시 넣어 재시도하며, 첫 재시도에서 95% 이상 자가 수정된다.
스키마 준수는 보장되지만 의미론적 정확성은 보장되지 않는다. 문법을 맞추기 위해 모델이 "안전한 기본값"으로 응답할 수 있으므로 Pydantic·Zod의 비즈니스 로직 검증을 추가로 걸어야 한다.
구조화된 출력이란 무엇인가
구조화된 출력(Structured Outputs)은 LLM 응답을 사전에 정의한 JSON Schema에 맞춰 반환하도록 강제하는 기능이다. 프롬프트에 "JSON으로 답해줘"라고 부탁하는 것과는 근본적으로 다르다. 프로바이더의 디코딩 계층이 토큰 샘플링을 제약해서 스키마를 위반하는 토큰 자체가 생성될 수 없도록 만드는 방식이다. 그 덕분에 응답을 파싱하는 try/except json.JSONDecodeError 블록이 사라지고, 다운스트림 파이프라인의 신뢰성이 급격히 올라간다.
2023년의 JSON 모드는 "유효한 JSON을 보장"할 뿐이었다. 필드 이름이 오타 나거나, 배열이어야 할 것이 문자열로 오거나, 필수 필드가 빠지는 문제는 여전했다. 2026년의 Structured Outputs는 여기서 한 걸음 더 나아가 스키마 자체를 지킨다. 다만 이는 문법적 준수(structural compliance)이지 의미론적 정확성(semantic correctness)이 아니다. 스키마는 "이 필드가 반드시 string이어야 한다"까지만 강제하고, "그 string이 사실인지"는 여전히 프롬프트 품질과 모델 능력에 달려 있다.
이 특성은 데이터 추출, 분류, 라우팅, 툴 인자 생성, UI 렌더링 페이로드 생성 같은 워크로드에서 특히 강력하다. 예를 들어 지원 티켓을 {"category": "billing" | "technical" | "account", "urgency": 1..5, "summary": string}으로 뽑아내면 다운스트림 라우터가 정규식 파싱 없이 곧바로 사용할 수 있다. 관련해 AI 에이전트 컨텍스트 엔지니어링 가이드에서 논의한 툴 스키마 설계 원칙과도 직접 연결된다.
OpenAI Structured Outputs와 strict 모드
OpenAI는 response_format={"type": "json_schema", "json_schema": {...}, "strict": true}로 구조화된 출력을 켠다. strict: true가 켜져 있으면 GPT-4o 이상 모델은 스키마를 100%에 가까운 확률로 준수한다. 다만 OpenAI strict 모드에는 세 가지 엄격한 제약이 있다. (1) 스키마의 모든 필드는 required 배열에 나열되어야 하고, (2) 각 객체에 additionalProperties: false가 지정되어야 하며, (3) 루트는 anyOf가 될 수 없다. 선택 필드가 필요하면 유니언 타입(["string", "null"])으로 해결해야 한다.
Pydantic 모델을 직접 넘길 수 있는 .parse() 헬퍼가 실무에서 가장 쓰기 쉽다. 아래는 회의 일정을 자연어에서 추출하는 최소 예제다.
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal
client = OpenAI()
class MeetingRequest(BaseModel):
title: str = Field(..., description="회의 제목")
attendees: list[str] = Field(..., description="참석자 이메일 목록")
duration_minutes: int = Field(..., ge=15, le=480)
urgency: Literal["low", "medium", "high"]
agenda: list[str]
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "회의 요청을 구조화된 데이터로 추출한다."},
{"role": "user", "content": (
"다음 주 화요일 오후 2시에 [email protected], [email protected]과 "
"45분간 Q3 로드맵 리뷰. 급함."
)},
],
response_format=MeetingRequest,
)
meeting: MeetingRequest = completion.choices[0].message.parsed
print(meeting.urgency) # "high"
.parse()는 내부적으로 Pydantic 모델을 JSON Schema로 변환하고, 응답을 다시 인스턴스로 역직렬화해 반환한다. 스키마를 손으로 쓰지 않아도 되고, IDE 타입 힌트가 그대로 살아 있어서 meeting.duration_minutes에 자동완성이 뜬다. 스트리밍이 필요하면 client.beta.chat.completions.stream()을 사용해 필드 단위로 프리렌더링할 수 있다.
주의할 함정 하나. 모델이 안전상의 이유로 요청을 거부할 수 있는데, 이 경우 응답이 스키마를 따르지 않는다. 나도 처음 배포했을 때 이 지점을 놓쳐서 모니터링 대시보드가 30분간 빨갛게 물든 적이 있다. OpenAI는 이를 위해 message.refusal 필드를 별도로 두었으므로 프로덕션 코드에서는 if completion.choices[0].message.refusal: ...를 반드시 분기 처리해야 한다. OpenAI Structured Outputs 공식 소개와 개발자 가이드에 강제 조건과 제한 사항이 상세히 정리되어 있다.
Claude에서 tool_use로 스키마 강제하기
Anthropic Claude는 OpenAI 스타일의 response_format 필드가 없다. 대신 tool_use 메커니즘을 스키마 강제 도구로 재활용한다. 실제로 호출할 함수가 없더라도, 원하는 출력 구조를 input_schema로 정의한 "더미 도구"를 하나 만들고 tool_choice={"type": "tool", "name": "..."}로 반드시 그 도구를 부르도록 강제하면 된다. 반환된 tool_use.input이 곧 구조화된 페이로드다.
이 패턴의 장점은 두 가지다. 첫째, Claude의 tool 스키마는 additionalProperties: false를 요구하지 않는다. 유니언 타입 없이도 자연스러운 선택 필드 설계가 가능하다. 둘째, Claude 4/4.5는 함수 호출 정확도 벤치마크에서 최상위권을 유지하며, 잘못된 인자 값을 만들어내는 일이 드물다. 단점은 스트리밍 시 tool_use 블록이 스트림 끝에서 한 번에 반환된다는 점. 필드 단위로 프리렌더링이 필요한 UI에는 OpenAI나 Gemini가 더 적합하다. Anthropic Tool Use 공식 문서에 tool_choice 옵션과 스트리밍 동작이 상세히 나와 있다.
Claude의 프롬프트 캐싱과 함께 쓰면 도구 스키마가 캐시 대상이 되어 반복 호출 시 상당한 비용 절감이 가능하다. 이 흐름은 LLM 프롬프트 캐싱 가이드에서 다룬 캐시 브레이크포인트 전략과 그대로 결합된다.
Gemini의 response_json_schema 사용법
Google Gemini 2.5 Flash·Pro는 response_mime_type="application/json"과 response_json_schema(구버전에서는 response_schema)로 구조화된 출력을 활성화한다. Gemini의 큰 강점은 enum·nullable 필드에 대한 네이티브 지원과 60~100 토큰 수준의 낮은 오버헤드다. 특히 라우팅·분류처럼 단순한 스키마에서는 실패율이 0.3% 미만으로 가장 안정적이다.
from google import genai
from google.genai import types
from pydantic import BaseModel
from typing import Literal
class ProductReview(BaseModel):
sentiment: Literal["positive", "neutral", "negative"]
aspects: list[str]
rating: int
would_recommend: bool
client = genai.Client()
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="배터리가 하루도 못 가지만 화면은 정말 예쁩니다. 별 3개.",
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=ProductReview,
),
)
review: ProductReview = response.parsed
print(review.sentiment, review.aspects)
Gemini는 Pydantic 모델을 response_schema에 직접 넘길 수 있고, response.parsed가 인스턴스로 역직렬화된 결과를 돌려준다. 다만 주의할 점은 3~4단계 이상 깊게 중첩된 스키마나 방대한 enum 리스트에서 실패율이 1~2%까지 오른다는 것. 실제 프로덕션에 넣기 전 최악의 케이스 스키마로 반드시 로드 테스트를 돌려야 한다. Gemini 구조화된 출력 공식 문서에 지원되는 JSON Schema 서브셋이 명시되어 있다.
Gemini는 함수 호출(Function Calling)과 response_schema가 별개 트랙이다. OpenAI가 "함수 호출을 데이터 추출 용도로도 쓴다"는 방향으로 통합됐다면, Gemini는 두 기능을 분리해 둔다. 툴 실행이 필요 없는 순수 데이터 추출은 response_schema, 실제 외부 API 호출이 필요한 워크로드는 tools를 쓰는 것이 명료하다.
Pydantic + Instructor로 재시도와 검증 자동화
세 프로바이더가 스키마 준수를 보장하더라도, 여전히 남는 것이 비즈니스 로직 검증이다. "이메일 문자열이면 실제로 유효한 이메일 형식인지", "코드가 실제로 컴파일되는지", "가격 필드가 음수가 아닌지" 같은 것은 JSON Schema로 다 표현할 수 없다. 여기서 등장하는 것이 Instructor 라이브러리다. 월 300만 회 이상 다운로드되는 이 라이브러리는 Pydantic 검증 실패를 자동으로 감지해 오류 메시지를 대화에 다시 주입하고, 모델이 스스로 수정하도록 만든다. 벤치마크상 첫 재시도에서 95% 이상 자가 수정 성공률을 보인다.
import instructor
from openai import OpenAI
from pydantic import BaseModel, field_validator
from typing import Literal
import re
client = instructor.from_openai(OpenAI())
class UserProfile(BaseModel):
email: str
age: int
plan: Literal["free", "pro", "enterprise"]
referral_code: str | None = None
@field_validator("email")
@classmethod
def valid_email(cls, v: str) -> str:
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", v):
raise ValueError(f"유효하지 않은 이메일: {v}")
return v.lower()
@field_validator("age")
@classmethod
def realistic_age(cls, v: int) -> int:
if not 13 <= v <= 120:
raise ValueError(f"나이 {v}는 허용 범위 밖")
return v
profile = client.chat.completions.create(
model="gpt-4o-2024-08-06",
response_model=UserProfile,
max_retries=3,
messages=[{
"role": "user",
"content": "Kim Minjun, [email protected], 32세, 프로 플랜."
}]
)
print(profile.email, profile.plan)
여기서 핵심은 @field_validator가 던진 ValueError가 모델의 응답으로 되돌아간다는 점이다. 모델은 "지난번 응답이 이런 이유로 거부됐다"는 컨텍스트를 받고 다시 시도한다. 문법적으로는 맞지만 의미적으로 틀린 응답을 자연스럽게 걸러낸다. max_retries=3은 실무의 스윗스팟이다. 3회 이후 반복 실패는 대개 프롬프트 문제이지 모델 문제가 아니므로 계속 재시도해도 소용이 없다.
Instructor는 프로바이더 어댑터를 통해 instructor.from_anthropic(), instructor.from_gemini(), instructor.from_litellm() 등으로 단일 API로 15개 이상 프로바이더를 지원한다. 프로바이더 교체 시 코드 변경은 from_* 팩토리 한 줄이 전부다.
구조화된 출력과 함수 호출의 차이
많은 팀이 혼동하는 지점이 이 둘의 경계다. 원칙은 명료하다. Structured Outputs는 "모델의 최종 답이 특정 모양이어야 한다"일 때 쓴다. Function Calling은 "모델이 어떤 액션을 취할지 결정하고, 그 액션에 필요한 인자를 채운다"일 때 쓴다. 결정 여지가 없으면 전자, 있으면 후자다.
구분
Structured Outputs
Function Calling
주 목적
응답의 모양 강제
액션 선택과 인자 생성
모델의 결정
없음 (단일 스키마)
어떤 도구를 부를지 선택
후처리
파싱된 데이터 사용
도구 실행 후 결과 재주입
대표 사용례
추출·분류·라우팅·UI 렌더링 페이로드
에이전트·RAG 검색·API 호출·DB 쿼리
스트리밍
OpenAI·Gemini 필드 단위 가능
도구 호출 완료 후 결과 스트림
OpenAI 신뢰도
99.9% (strict)
99.5%+ (strict tools)
실패 시 리스크
파싱 실패 → 파이프라인 멈춤
잘못된 도구 호출 → 부작용 발생
실무에서는 두 방식이 자주 결합된다. 에이전트가 도구를 호출해 결과를 받은 뒤, 최종 사용자에게 보여줄 요약을 response_format으로 강제하는 식이다. 멀티에이전트 오케스트레이션 가이드에서 다룬 LangGraph 상태 그래프에서도 각 노드의 반환값을 Structured Outputs로 고정해 다운스트림 노드가 안전하게 소비할 수 있게 한다.
프로바이더별 신뢰도와 비용 비교
2026년 벤치마크 기준 세 프로바이더의 스키마 준수율은 통계적으로 유의미한 차이가 나지 않을 만큼 좋아졌다. 결정은 신뢰도가 아니라 다른 축에서 이뤄져야 한다. 스트리밍 요구, 스키마 자유도, 비용, 그리고 다른 워크로드와의 결합이 실제 선택을 좌우한다.
속성
OpenAI GPT-5
Claude Opus 4.8
Gemini 2.5 Flash
스키마 준수율
99.9%+
99.8%
99.7%
강제 방식
response_format strict
tool_use + tool_choice
response_json_schema
스트리밍 필드 단위
가능
불가 (블록 종료 후)
가능
additionalProperties:false
필수
불필요
불필요
선택 필드 처리
유니언 nullable
optional 키
optional 키
거부(refusal) 필드
있음
일반 텍스트 응답
일반 텍스트 응답
중첩 3단계 이상
안정
안정
실패율 1~2% 상승
Pydantic 직접 지원
.parse()
어댑터(Instructor)
response_schema
비용 관점에서는 "스키마 복잡도에 따라 프로바이더를 라우팅"하는 하이브리드 전략이 가장 효과적이다. 단순 분류·라우팅은 Gemini Flash로 저렴하게, 중첩된 문서 추출이나 안전이 중요한 워크로드는 GPT-5나 Claude Opus로 보낸다. 지난 프로젝트에서 이 하이브리드 라우팅으로 월 API 비용을 절반 이하로 줄인 적이 있는데, 스키마 준수 지표는 그대로였다. TokenMix.ai 데이터도 40~60% 비용 절감을 보고한다.
프로덕션 실전 패턴과 함정
실제 배포 환경에서 마주하는 함정은 스키마 자체가 아니라 스키마 주변의 운영 이슈다. 아래는 대형 워크로드 운영 팀이 반복적으로 부딪히는 이슈와 대응 패턴이다.
1. Refusal과 타임아웃을 스키마와 동등하게 취급
Structured Outputs는 모델이 응답한 경우에만 스키마를 보장한다. 안전 정책에 의한 거부, 컨텍스트 초과에 의한 잘림, 프로바이더 타임아웃은 별도 코드 경로로 처리해야 한다. OpenAI는 message.refusal이 별도 필드로 제공되므로 이를 감지해 사용자에게 다른 메시지를 보여주는 것이 표준이다.
2. 스키마 진화와 버전 관리
스키마를 바꾸면 로그된 과거 응답이 새 스키마로 파싱되지 않는다. Pydantic v2의 model_validate에 strict=False와 마이그레이션 함수를 함께 두어 과거 이벤트를 새 모델로 승격하는 파이프라인을 만들어 두는 것이 좋다. 스키마 파일을 별도 리포지토리로 분리하고 시맨틱 버저닝을 붙이는 팀도 늘고 있다.
3. 폴백 체인과 프로바이더 다이버시티
단일 프로바이더가 100% 신뢰할 수는 없다. 프로덕션 크리티컬 경로에는 폴백 체인을 두어 첫 프로바이더가 5xx를 던지거나 스키마 검증에 실패하면 두 번째 프로바이더로 자동 재시도한다. Instructor + LiteLLM 조합으로 6줄 안에 구현된다.
import instructor
from litellm import completion
client = instructor.from_litellm(completion)
def extract_with_fallback(user_input: str, schema):
for model in ["gpt-4o-2024-08-06", "claude-opus-4-8", "gemini/gemini-2.5-flash"]:
try:
return client.chat.completions.create(
model=model,
response_model=schema,
max_retries=2,
messages=[{"role": "user", "content": user_input}],
)
except Exception as exc:
print(f"{model} 실패: {exc}. 다음 프로바이더로 폴백.")
raise RuntimeError("모든 프로바이더가 실패했다.")
4. 열거형(enum) 폭발과 라우팅
enum이 30개를 넘어가면 모델이 랜덤에 가까운 값을 고르기 시작한다. 이 경우 (a) 상위 카테고리로 먼저 라우팅한 뒤 하위 enum을 별도 호출로 분리하거나, (b) 임베딩 기반 유사도 검색으로 후보를 5~10개로 좁힌 뒤 LLM에게 최종 선택을 맡기는 방식이 훨씬 안정적이다. 후자는 벡터 데이터베이스 기반 RAG 패턴과 겹치는데, 실무 파이프라인 구성은 FastMCP MCP 서버 가이드의 도구 라우팅 챕터에서 다룬 방식과 유사하다.
5. 관찰 가능성과 데이터 계약
구조화된 출력의 최대 강점은 테스트 가능성이다. 응답이 타입이 있는 데이터이므로, 실제 유닛 테스트를 작성할 수 있다. 매 응답을 스키마 버전·모델 이름·검증 결과와 함께 로깅하면 회귀와 스키마 드리프트를 조기에 감지할 수 있다. PydanticAI, LangSmith, Braintrust 같은 관찰 도구가 이 계약을 UI로 시각화해준다.
자주 묻는 질문
구조화된 출력이 JSON 모드보다 무엇이 나은가요?
JSON 모드는 "유효한 JSON"만 보장하고, 필드 이름·타입·필수 여부는 강제하지 않는다. Structured Outputs는 JSON Schema를 디코딩 단계에서 강제해 필드 이름 오타나 잘못된 타입 자체가 생성되지 않도록 만든다. 결과적으로 파싱 실패율이 8~15%에서 0.1% 미만으로 떨어진다.
Claude에서는 왜 tool_use로 스키마를 강제하나요?
Claude는 별도 response_format 필드가 없다. 대신 tool 정의의 input_schema가 이미 JSON Schema를 받도록 설계되어 있어, "실행하지 않는 더미 도구"를 만들고 tool_choice로 반드시 호출시키면 tool_use.input이 곧 구조화된 출력이 된다. 이 방식은 additionalProperties:false 강제가 없어 스키마 설계가 더 자유롭다.
Pydantic 검증 오류가 나면 어떻게 대응해야 하나요?
Instructor 라이브러리를 쓰면 자동 처리된다. @field_validator가 던진 ValueError가 대화에 다시 삽입되고, 모델이 오류 메시지를 컨텍스트로 받아 재시도한다. max_retries=3이 실무 스윗스팟이며, 첫 재시도에서 95% 이상이 자가 수정된다.
스트리밍 응답에서도 구조화된 출력이 되나요?
OpenAI와 Gemini는 필드 단위 스트리밍이 가능하며, OpenAI SDK의 client.beta.chat.completions.stream()이 부분 파싱된 객체를 이벤트로 준다. Anthropic Claude의 tool_use는 스트림 끝에 하나의 블록으로 반환되므로 UI에서 필드별 프리렌더링이 필요하면 OpenAI나 Gemini가 유리하다.
함수 호출과 구조화된 출력 중 어떤 것을 언제 써야 하나요?
모델이 "결정할 여지가 없고 데이터를 특정 모양으로만 반환하면 되면" Structured Outputs를 쓴다. 모델이 "어떤 도구를 호출할지, 인자를 어떻게 채울지 결정해야 하면" Function Calling을 쓴다. 실제 프로덕션에서는 두 방식이 자주 결합되어, 도구 호출 결과를 다시 Structured Outputs로 요약·정형화한다.