LLM에게 "JSON으로 응답해줘"라고 프롬프트에 적어본 적 있으신가요? 아마 대부분의 개발자가 한 번쯤은 겪어봤을 겁니다. 처음엔 잘 되는 것 같다가, 어느 날 갑자기 마크다운 코드 블록으로 감싼 JSON이 돌아오거나, "Here's your JSON:" 같은 서문이 붙거나, 쉼표 하나가 빠져서 파서가 터지죠. 솔직히 말하면, 테스트에서는 멀쩡하던 프롬프트가 프로덕션에서 터지는 건 시간문제입니다.
2026년 현재, 이 문제를 근본적으로 해결하는 기술이 구조화된 출력(Structured Output)입니다. OpenAI, Anthropic(Claude), Google 등 주요 LLM 프로바이더 모두 네이티브로 지원하고 있고, PydanticAI 같은 프레임워크는 모델에 구애받지 않는 통합 인터페이스까지 제공합니다.
이 글에서는 각 플랫폼의 실전 구현 코드부터 프로덕션 베스트 프랙티스까지 정리해봤습니다. 바로 복붙해서 쓸 수 있는 수준으로요.
구조화된 출력이란 무엇이고 왜 필수인가?
문자열 파싱의 한계
전통적으로 LLM의 JSON 응답을 다루는 방식은 세 단계로 나뉩니다. 각 단계마다 신뢰도 차이가 꽤 큽니다.
- 레벨 1 — 프롬프트 엔지니어링: "JSON으로 응답하세요"라고 지시하는 방식입니다. 성공률 80~95% 수준이고, 엣지 케이스에서 조용히 실패합니다. 타입 보장이 전혀 없어서 프로토타이핑 정도에만 쓸 수 있습니다.
- 레벨 2 — 함수 호출(Function Calling): 도구 정의를 통해 스키마를 힌트로 제공합니다. 성공률 95~99%로 꽤 개선되지만, 여전히 스키마가 "힌트"일 뿐 "제약"은 아닙니다.
- 레벨 3 — 네이티브 구조화된 출력: JSON 스키마를 문법(grammar)으로 컴파일하여 토큰 생성 자체를 제약합니다. 유효하지 않은 토큰을 아예 생성할 수 없으니 스키마 준수율 100%가 보장됩니다. 2026년 프로덕션 환경이라면 반드시 이 레벨이어야 합니다.
구조화된 출력의 동작 원리
네이티브 구조화된 출력은 단순히 "JSON으로 응답해달라"고 요청하는 것과는 근본적으로 다릅니다. 여러분이 제공한 JSON 스키마를 문맥 자유 문법(Context-Free Grammar)으로 컴파일하고, 추론 과정에서 유효하지 않은 토큰을 마스킹해버립니다.
쉽게 말하면, 모델이 스키마를 위반하는 텍스트를 물리적으로 생성할 수 없게 만드는 겁니다. 그리고 스키마는 최대 24시간 캐시되기 때문에 반복 호출 시 컴파일 지연도 거의 없습니다.
OpenAI Structured Output 실전 구현
response_format과 Pydantic 모델
OpenAI는 2026년 현재 JSON Mode(레거시)와 Structured Output(권장) 두 가지를 제공합니다. JSON Mode는 유효한 JSON 문법만 보장하고, Structured Output은 스키마 준수까지 보장하죠. OpenAI 공식 입장도 "가능하면 항상 Structured Output 쓰세요"입니다.
그럼 Python SDK에서 Pydantic 모델을 활용한 기본 사용법부터 볼까요.
from pydantic import BaseModel, Field
from typing import List, Optional
from openai import OpenAI
client = OpenAI()
class CodeReviewResult(BaseModel):
"""코드 리뷰 결과를 구조화된 형식으로 반환"""
file_path: str = Field(description="리뷰 대상 파일 경로")
severity: str = Field(description="심각도", enum=["critical", "warning", "info"])
issues: List[str] = Field(description="발견된 이슈 목록")
suggested_fix: str = Field(description="수정 제안")
confidence_score: float = Field(description="신뢰도 점수", ge=0.0, le=1.0)
completion = client.chat.completions.parse(
model="gpt-4o",
messages=[
{"role": "system", "content": "당신은 시니어 코드 리뷰어입니다."},
{"role": "user", "content": "다음 코드를 리뷰해주세요: eval(user_input)"}
],
response_format=CodeReviewResult,
)
review = completion.choices[0].message.parsed
print(f"심각도: {review.severity}") # critical
print(f"이슈: {review.issues}") # ["eval()은 임의 코드 실행 취약점..."]
여기서 핵심은 .parse() 메서드입니다. 일반 .create()와 달리 Pydantic 모델을 response_format에 직접 전달할 수 있고, 응답이 자동으로 파싱되어 .parsed 속성으로 타입 안전한 객체를 돌려줍니다. JSON 문자열을 직접 파싱할 필요가 없어서 정말 편합니다.
거부(Refusal) 처리
이건 의외로 많이들 놓치는 부분인데요. 모델이 안전상의 이유로 응답을 거부하는 경우가 있습니다. 이때는 JSON 대신 거부 객체가 반환되거든요.
프로덕션 코드에서는 반드시 이걸 처리해야 합니다.
response = client.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": user_input}],
response_format=CodeReviewResult,
)
message = response.choices[0].message
# 거부 여부를 먼저 확인
if message.refusal:
print(f"모델이 응답을 거부했습니다: {message.refusal}")
# 폴백 로직 실행
else:
review = message.parsed
process_review(review)
Claude API 구조화된 출력 실전 구현
JSON 출력 모드
Anthropic의 Claude는 2026년 현재 Claude Opus 4.6, Sonnet 4.6, Sonnet 4.5, Opus 4.5, Haiku 4.5 모델에서 구조화된 출력을 정식 지원합니다. Claude의 접근 방식은 두 가지 보완적 기능으로 나뉘는데, 이게 나름 영리한 설계입니다.
- JSON 출력 (
output_config.format): Claude의 응답 자체를 특정 JSON 형식으로 강제합니다. 데이터 추출이나 보고서 생성 같은 경우에 적합합니다. - Strict 도구 사용 (
strict: true): 도구 호출 시 입력 파라미터의 스키마 준수를 보장합니다. 에이전트 워크플로우에서 거의 필수라고 보면 됩니다.
Python SDK로 JSON 출력 모드를 구현하는 코드를 보겠습니다.
from pydantic import BaseModel
from anthropic import Anthropic
class SentimentAnalysis(BaseModel):
text: str
sentiment: str # "positive", "negative", "neutral"
confidence: float
key_phrases: list[str]
client = Anthropic()
response = client.messages.create(
model="claude-sonnet-4-5-20250514",
max_tokens=1024,
messages=[{
"role": "user",
"content": "다음 리뷰를 분석해주세요: 이 제품 정말 대박이에요! 배송도 빠르고 품질도 최고입니다."
}],
output_config={
"format": {
"type": "json_schema",
"json_schema": {
"name": "sentiment_analysis",
"schema": SentimentAnalysis.model_json_schema()
}
}
}
)
import json
result = json.loads(response.content[0].text)
print(result)
# {"text": "이 제품 정말 대박이에요!...", "sentiment": "positive", "confidence": 0.95, ...}
Strict 도구 사용으로 에이전트 입력 보장
에이전트가 외부 도구를 호출할 때 파라미터 타입이 잘못되면 어떻게 될까요? 당연히 치명적인 오류입니다. strict: true를 설정하면 Claude가 도구를 호출할 때 입력이 반드시 스키마에 맞도록 보장됩니다.
실제 예시를 보시죠.
response = client.messages.create(
model="claude-opus-4-6-20250828",
max_tokens=1024,
messages=[{
"role": "user",
"content": "서울에서 부산까지 KTX 예약해줘. 성인 2명, 3월 15일 오전 출발."
}],
tools=[{
"name": "book_train",
"description": "KTX 열차를 예약합니다",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"departure": {"type": "string", "description": "출발역"},
"arrival": {"type": "string", "description": "도착역"},
"date": {"type": "string", "description": "출발일 (YYYY-MM-DD)"},
"time_preference": {
"type": "string",
"enum": ["morning", "afternoon", "evening"]
},
"passengers": {"type": "integer", "description": "승객 수"}
},
"required": ["departure", "arrival", "date", "time_preference", "passengers"],
"additionalProperties": False
}
}]
)
# strict: true 덕분에 passengers는 반드시 정수로 보장
# "2명" → passengers: 2 (문자열 "2"나 "two"가 아닌 정수 2)
strict: true 없이는 Claude가 passengers: "2"(문자열)나 심지어 passengers: "two" 같은 값을 반환할 가능성이 있습니다. strict 모드에서는 스키마에 정의된 대로 반드시 passengers: 2(정수)를 돌려주죠. 결제나 예약 같은 크리티컬한 기능에서는 이 차이가 정말 큽니다.
JSON 출력 + Strict 도구를 함께 사용하기
실무에서 특히 강력한 패턴은 두 기능을 결합하는 겁니다. 도구 호출 시 입력 파라미터가 보장되고, 최종 응답도 구조화된 JSON으로 반환되죠.
이 조합은 에이전틱 워크플로우에서 도구 호출의 신뢰성과 최종 응답의 파싱 용이성을 동시에 확보할 수 있어서, 개인적으로 Claude 기반 에이전트를 만들 때 가장 선호하는 패턴입니다.
PydanticAI로 모델 독립적 구조화된 에이전트 만들기
PydanticAI란?
PydanticAI는 Pydantic 팀이 만든 모델 독립적(model-agnostic) 에이전트 프레임워크입니다. OpenAI, Anthropic, Gemini, Mistral 등 거의 모든 LLM 프로바이더를 지원하고, Pydantic의 데이터 검증 강점을 LLM 에이전트에 그대로 적용합니다.
핵심은 output_type 파라미터입니다. Pydantic 모델을 지정하면 에이전트 응답이 해당 스키마에 맞도록 강제되거든요.
output_type으로 에이전트 응답 강제하기
보안 취약점 분석 에이전트를 예로 들어보겠습니다.
from pydantic import BaseModel, Field
from pydantic_ai import Agent
from typing import List
class VulnerabilityReport(BaseModel):
"""보안 취약점 분석 보고서"""
vulnerability_type: str = Field(description="취약점 유형 (XSS, SQLi, CSRF 등)")
severity: str = Field(description="심각도", enum=["critical", "high", "medium", "low"])
affected_lines: List[int] = Field(description="영향받는 코드 라인 번호")
description: str = Field(description="취약점 설명")
remediation: str = Field(description="수정 방법")
# 에이전트 생성 — output_type으로 응답 스키마 강제
security_agent = Agent(
"anthropic:claude-sonnet-4-5",
system_prompt="당신은 보안 코드 리뷰 전문가입니다. 제출된 코드의 보안 취약점을 분석하세요.",
output_type=VulnerabilityReport,
)
# 동기 실행
result = security_agent.run_sync(
"다음 코드를 분석해주세요: query = f'SELECT * FROM users WHERE id = {user_id}'"
)
report = result.output
print(f"취약점: {report.vulnerability_type}") # "SQL Injection"
print(f"심각도: {report.severity}") # "critical"
print(f"수정법: {report.remediation}")
PydanticAI의 강점은 output_type만 바꾸면 동일한 에이전트 코드에서 다양한 출력 형식을 쉽게 전환할 수 있다는 점입니다. 모델도 "openai:gpt-4o"에서 "anthropic:claude-sonnet-4-5"로 한 줄만 바꾸면 끝이에요.
도구 주입과 의존성 관리
실제 프로덕션 에이전트에서는 외부 데이터 소스와의 연동이 필수입니다. PydanticAI는 의존성 주입(Dependency Injection) 패턴으로 이걸 깔끔하게 처리하는데, Spring이나 FastAPI의 DI에 익숙하신 분이라면 금방 감이 오실 겁니다.
from dataclasses import dataclass
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
from typing import List
@dataclass
class AnalyticsDeps:
db_connection: object # 데이터베이스 커넥션
user_id: str
class UserInsight(BaseModel):
summary: str = Field(description="사용자 행동 요약")
top_actions: List[str] = Field(description="가장 빈번한 행동 Top 5")
churn_risk: float = Field(description="이탈 위험도 (0.0~1.0)", ge=0.0, le=1.0)
recommendation: str = Field(description="추천 액션")
analytics_agent = Agent(
"openai:gpt-4o",
deps_type=AnalyticsDeps,
output_type=UserInsight,
system_prompt="사용자 행동 데이터를 분석하여 인사이트를 도출하세요.",
)
@analytics_agent.tool
async def get_user_events(ctx: RunContext[AnalyticsDeps], days: int) -> str:
"""최근 N일간의 사용자 이벤트를 조회합니다."""
events = await ctx.deps.db_connection.query(
"SELECT event_type, COUNT(*) FROM events WHERE user_id = ? AND date > ? GROUP BY event_type",
ctx.deps.user_id, days
)
return str(events)
@analytics_agent.tool
async def get_user_profile(ctx: RunContext[AnalyticsDeps]) -> str:
"""사용자 프로필 정보를 조회합니다."""
profile = await ctx.deps.db_connection.query(
"SELECT * FROM users WHERE id = ?", ctx.deps.user_id
)
return str(profile)
@analytics_agent.tool 데코레이터로 등록된 함수들은 에이전트가 필요할 때 자동으로 호출합니다. RunContext를 통해 의존성(DB 커넥션, 사용자 ID)에 타입 안전하게 접근할 수 있어서, 전역 변수나 클로저 없이도 깔끔한 코드를 유지할 수 있습니다.
프로덕션 베스트 프랙티스
1. 2단계 접근법: 자유 사고 → 구조화 포맷팅
이건 꽤 중요한 포인트인데요. 연구 결과에 따르면 LLM에게 추론 작업을 시키면서 동시에 JSON 출력을 강제하면 성능이 10~15% 하락합니다(Tam et al., 2024 "Let Me Speak Freely?" 연구). 추론하면서 동시에 형식까지 맞추려니 연산 자원이 분산되는 거죠.
해결책은 2단계 접근법입니다. 먼저 자유롭게 생각하게 하고, 그다음 결과를 정리하게 하는 거예요.
# 1단계: 자유로운 추론 (구조화 제약 없음)
thinking_response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "복잡한 분석을 수행하세요. 형식에 신경쓰지 말고 깊이 생각하세요."},
{"role": "user", "content": complex_analysis_query}
]
)
raw_analysis = thinking_response.choices[0].message.content
# 2단계: 구조화된 포맷팅 (제약된 디코딩)
structured_response = client.chat.completions.parse(
model="gpt-4o",
messages=[
{"role": "system", "content": "다음 분석 결과를 구조화된 형식으로 정리하세요."},
{"role": "user", "content": raw_analysis}
],
response_format=AnalysisReport,
)
API 호출이 두 번이라 비용이 걱정될 수 있지만, 수학 문제나 복잡한 추론 같은 작업에서는 정확도 향상 폭이 상당히 큽니다. 결국 잘못된 결과를 재처리하는 비용보다 저렴한 경우가 많습니다.
2. 스키마 복잡도 관리
구조화된 출력의 숨겨진 비용은 스키마 복잡도입니다. 복잡한 스키마일수록 문법 컴파일에 시간이 더 걸리고, API 비용도 올라갑니다.
실전에서 꼭 기억해야 할 팁 세 가지입니다.
- 옵셔널 필드 최소화: 옵셔널 파라미터 하나마다 문법의 상태 공간이 약 2배로 늘어납니다. 가능하면 필수(required) 필드로 만들고, 값이 없을 수 있는 경우엔 nullable 타입을 사용하세요.
- 중첩 구조 평탄화: 깊게 중첩된 객체에 옵셔널 필드가 있으면 복잡도가 기하급수적으로 증가합니다. 3단계 이상 중첩은 피하는 게 좋습니다.
- 스키마 분할: strict 도구가 많다면 여러 요청이나 서브 에이전트로 분할하는 것을 고려하세요.
3. 폴백 체인과 멀티 프로바이더 전략
프로덕션에서 단일 프로바이더에 의존하는 건 솔직히 좀 위험합니다. 장애는 예고 없이 오니까요. 구조화된 출력에서 발생할 수 있는 실패 모드를 대비한 폴백 전략이 필요합니다.
from pydantic import BaseModel, ValidationError
import json
class ExtractedData(BaseModel):
name: str
category: str
score: float
async def extract_with_fallback(text: str) -> ExtractedData:
"""멀티 프로바이더 폴백 체인"""
# 1차: OpenAI Structured Output
try:
response = openai_client.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": text}],
response_format=ExtractedData,
)
if not response.choices[0].message.refusal:
return response.choices[0].message.parsed
except Exception as e:
logger.warning(f"OpenAI 실패: {e}")
# 2차: Claude Structured Output
try:
response = anthropic_client.messages.create(
model="claude-sonnet-4-5-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": text}],
output_config={
"format": {
"type": "json_schema",
"json_schema": {
"name": "extracted_data",
"schema": ExtractedData.model_json_schema()
}
}
}
)
data = json.loads(response.content[0].text)
return ExtractedData.model_validate(data)
except Exception as e:
logger.warning(f"Claude 실패: {e}")
# 3차: 최후의 수단
raise RuntimeError("모든 프로바이더에서 구조화된 출력 실패")
4. 공통 함정과 해결책
실무에서 자주 만나는 문제들인데, 미리 알아두면 디버깅 시간을 꽤 아낄 수 있습니다.
- OpenAI Optional 필드 문제: OpenAI는 모든 필드를 required로 요구합니다. Pydantic의
Optional[T]는Union[T, None]으로 변환해야 합니다.Field(default=None)대신 nullable 타입을 명시하세요. (이거 처음에 모르면 에러 메시지가 좀 모호해서 한참 헤맬 수 있습니다.) - 재귀 스키마 미지원: OpenAI와 Claude 모두
$ref를 통한 재귀 스키마를 지원하지 않습니다. 트리 구조가 필요하면 최대 깊이를 정해놓고 평탄화하세요. - 수치 제약 미적용:
minimum,maximum같은 수치 제약은 스키마 레벨에서 강제되지 않습니다. Pydantic 검증 단계에서 별도로 확인해야 합니다. - Claude Citations 비호환: Claude의 Citations 기능은 구조화된 출력과 함께 사용할 수 없습니다. 400 에러가 반환되니, 인용이 필요한 경우에는 별도 요청으로 분리하세요.
자주 묻는 질문 (FAQ)
구조화된 출력과 JSON 모드의 차이점은 무엇인가요?
JSON 모드는 유효한 JSON 문법만 보장합니다. 구조화된 출력은 여기에 더해 여러분이 정의한 JSON 스키마를 준수하는 것까지 보장하죠. 예를 들어 JSON 모드에서는 {"name": 123} 같은 타입 불일치도 통과하지만, 구조화된 출력에서는 name이 string으로 정의되어 있으면 반드시 문자열만 반환합니다.
2026년 기준으로 JSON 모드는 사실상 레거시입니다. 프로덕션이라면 구조화된 출력을 쓰세요.
구조화된 출력을 사용하면 LLM 추론 성능이 떨어지나요?
네, 떨어집니다. 연구에 따르면 추론 작업 중 JSON 출력을 강제하면 10~15% 성능 하락이 발생합니다. 모델이 형식 준수에 연산 자원을 써야 하기 때문이죠.
해결책은 앞서 설명한 2단계 접근법입니다. 1단계에서 자유롭게 사고하고, 2단계에서 결과를 구조화하면 정확도 저하 없이 구조화된 출력을 얻을 수 있습니다.
PydanticAI와 직접 API 호출의 차이는 무엇인가요?
직접 API 호출은 특정 프로바이더에 종속되고, 스키마 변환, 검증, 재시도 로직을 직접 구현해야 합니다. PydanticAI는 모델 독립적 추상화 계층으로, output_type에 Pydantic 모델을 지정하면 스키마 변환, 응답 검증, 실패 시 재시도를 자동 처리합니다.
한 줄 변경으로 OpenAI에서 Claude로 전환 가능하다는 게 가장 큰 장점인데, 반면 추상화 오버헤드가 추가됩니다. 단순한 단일 프로바이더 프로젝트라면 직접 API 호출이 더 나을 수도 있어요.
어떤 LLM이 구조화된 출력을 가장 잘 지원하나요?
2026년 3월 기준으로, OpenAI(GPT-4o 이상)와 Anthropic(Claude Sonnet 4.5 이상) 모두 네이티브 구조화된 출력을 정식 지원합니다. OpenAI는 .parse() 메서드를 통한 SDK 수준 통합이 성숙해 있고, Claude는 JSON 출력과 Strict 도구 사용을 분리 제공하여 에이전트 워크플로우에 더 유연합니다. Google Gemini도 response_schema를 지원하고요.
솔직한 추천을 드리자면, 실무에서는 폴백 체인으로 복수 프로바이더를 조합하는 게 가장 안정적입니다.
strict: true는 모든 도구에 적용해야 하나요?
아닙니다. Anthropic 공식 문서에서도 모든 도구가 아닌 스키마 위반이 실제 문제를 일으키는 핵심 도구에만 strict를 적용하라고 권장합니다.
strict 모드는 문법 컴파일 오버헤드가 있으므로, 간단한 도구에는 Claude의 자연스러운 스키마 준수에 의존하고, 결제 처리나 데이터 변경 같은 크리티컬한 도구에만 strict를 적용하는 게 효율적입니다. 무조건 다 걸면 오히려 비용만 늘어납니다.