LLM이 JSON Schema를 따르는 구조화된 응답을 생성하도록 설계하는 방법과 프로덕션 시스템 통합 전략을 다룹니다.
LLM의 출력을 프로덕션 시스템에서 사용하려면 프로그래밍적으로 파싱 가능한 형태가 필요합니다. 자유 텍스트 응답은 사람이 읽기에는 좋지만, 다운스트림 시스템이 소비하기에는 불안정합니다.
# 자유 텍스트 응답 (파싱 어려움)
"이 리뷰의 감성은 긍정적입니다. 평점은 4점 정도로 볼 수 있으며,
주요 키워드는 배송, 품질, 가격입니다."
# 구조화된 응답 (프로그래밍적 처리 용이)
{
"sentiment": "positive",
"score": 4,
"keywords": ["배송", "품질", "가격"]
}구조화된 출력의 핵심 이점은 세 가지입니다.
가장 기본적인 방법은 프롬프트에서 출력 형식을 명시하는 것입니다.
<task>
다음 제품 리뷰를 분석하세요.
</task>
<review>
배터리가 이틀은 거뜬히 갑니다. 화면도 선명하고 반응 속도도 빠릅니다.
다만 무게가 좀 무겁고, 기본 제공 케이스의 품질이 아쉽습니다.
가격 대비 전반적으로 만족합니다.
</review>
<output_format>
다음 JSON 형식으로만 응답하세요. JSON 외의 텍스트는 포함하지 마세요.
{
"sentiment": "positive | negative | neutral",
"score": "1-5 정수",
"pros": ["장점 목록"],
"cons": ["단점 목록"],
"summary": "한 줄 요약"
}
</output_format>단순히 형식만 보여주는 것보다 각 필드의 의미와 규칙을 설명하면 정확도가 높아집니다.
<output_schema>
응답은 다음 JSON 스키마를 정확히 따라야 합니다.
{
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"],
"description": "리뷰의 전반적 감성. 장점이 단점보다 많으면 positive"
},
"score": {
"type": "integer",
"minimum": 1,
"maximum": 5,
"description": "1=매우 불만, 2=불만, 3=보통, 4=만족, 5=매우 만족"
},
"pros": {
"type": "array",
"items": {"type": "string"},
"description": "리뷰에서 언급된 장점. 명시적으로 언급된 것만 포함"
},
"cons": {
"type": "array",
"items": {"type": "string"},
"description": "리뷰에서 언급된 단점. 명시적으로 언급된 것만 포함"
},
"summary": {
"type": "string",
"maxLength": 100,
"description": "리뷰 핵심을 한 문장으로 요약"
}
}
</output_schema>프롬프트만으로 JSON 형식을 강제하는 것에는 한계가 있습니다. 모델이 JSON 형식을 무시하거나 잘못된 JSON을 생성할 수 있기 때문입니다. 이를 해결하기 위해 주요 LLM 제공자들은 API 수준에서 구조화된 출력을 지원합니다.
Claude에서 구조화된 출력을 보장하는 가장 안정적인 방법은 Tool Use(도구 사용) 기능을 활용하는 것입니다. 도구의 입력 스키마를 정의하면 모델이 해당 스키마를 따르는 JSON을 생성합니다.
import anthropic
client = anthropic.Anthropic()
# 도구 정의로 출력 스키마 강제
tools = [
{
"name": "analyze_review",
"description": "제품 리뷰를 분석하여 구조화된 결과를 반환합니다",
"input_schema": {
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"],
"description": "리뷰의 전반적 감성"
},
"score": {
"type": "integer",
"minimum": 1,
"maximum": 5,
"description": "만족도 점수"
},
"pros": {
"type": "array",
"items": {"type": "string"},
"description": "장점 목록"
},
"cons": {
"type": "array",
"items": {"type": "string"},
"description": "단점 목록"
},
"summary": {
"type": "string",
"description": "한 줄 요약"
}
},
"required": ["sentiment", "score", "pros", "cons", "summary"]
}
}
]
response = client.messages.create(
model="claude-sonnet-4-5-20250514",
max_tokens=1024,
tools=tools,
tool_choice={"type": "tool", "name": "analyze_review"},
messages=[
{
"role": "user",
"content": "다음 리뷰를 분석하세요: 배터리가 오래 가고 화면이 선명합니다. 무게가 무거운 점은 아쉽습니다."
}
]
)
# 구조화된 결과 추출
tool_input = response.content[0].input
print(tool_input)
# {"sentiment": "positive", "score": 4, "pros": [...], "cons": [...], "summary": "..."}Claude의 Tool Use를 구조화된 출력에 활용할 때는 tool_choice를 특정 도구로 강제하세요. 이렇게 하면 모델이 반드시 해당 도구를 호출하여 스키마를 따르는 JSON을 반환합니다.
OpenAI는 Structured Outputs 기능을 통해 JSON Schema 준수를 보장합니다.
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
response_format={
"type": "json_schema",
"json_schema": {
"name": "review_analysis",
"strict": True,
"schema": {
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"]
},
"score": {"type": "integer"},
"summary": {"type": "string"}
},
"required": ["sentiment", "score", "summary"],
"additionalProperties": False
}
}
},
messages=[
{
"role": "user",
"content": "다음 리뷰를 분석하세요: 배터리가 오래 가고 화면이 선명합니다."
}
]
)import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
// Zod 스키마로 타입 정의
const ReviewAnalysisSchema = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
score: z.number().int().min(1).max(5),
pros: z.array(z.string()),
cons: z.array(z.string()),
summary: z.string().max(200),
});
type ReviewAnalysis = z.infer<typeof ReviewAnalysisSchema>;
async function analyzeReview(review: string): Promise<ReviewAnalysis> {
const client = new Anthropic();
const response = await client.messages.create({
model: "claude-sonnet-4-5-20250514",
max_tokens: 1024,
tools: [
{
name: "analyze_review",
description: "리뷰 분석 결과를 반환합니다",
input_schema: {
type: "object",
properties: {
sentiment: {
type: "string",
enum: ["positive", "negative", "neutral"],
},
score: { type: "integer", minimum: 1, maximum: 5 },
pros: { type: "array", items: { type: "string" } },
cons: { type: "array", items: { type: "string" } },
summary: { type: "string" },
},
required: ["sentiment", "score", "pros", "cons", "summary"],
},
},
],
tool_choice: { type: "tool", name: "analyze_review" },
messages: [{ role: "user", content: "리뷰를 분석하세요: " + review }],
});
const toolBlock = response.content.find(
(block) => block.type === "tool_use"
);
if (!toolBlock || toolBlock.type !== "tool_use") {
throw new Error("도구 호출 응답이 없습니다");
}
// Zod로 런타임 검증
return ReviewAnalysisSchema.parse(toolBlock.input);
}from pydantic import BaseModel, Field
import anthropic
import json
class ReviewAnalysis(BaseModel):
sentiment: str = Field(
description="리뷰 감성",
pattern="^(positive|negative|neutral)$"
)
score: int = Field(
ge=1, le=5,
description="만족도 점수"
)
pros: list[str] = Field(
description="장점 목록"
)
cons: list[str] = Field(
description="단점 목록"
)
summary: str = Field(
max_length=200,
description="한 줄 요약"
)
def get_tool_schema(model: type[BaseModel]) -> dict:
"""Pydantic 모델에서 Claude Tool 스키마를 생성합니다."""
schema = model.model_json_schema()
return {
"name": model.__name__.lower(),
"description": model.__doc__ or "구조화된 출력",
"input_schema": schema
}
def analyze_with_validation(review: str) -> ReviewAnalysis:
"""리뷰를 분석하고 Pydantic으로 검증합니다."""
client = anthropic.Anthropic()
tool = get_tool_schema(ReviewAnalysis)
response = client.messages.create(
model="claude-sonnet-4-5-20250514",
max_tokens=1024,
tools=[tool],
tool_choice={"type": "tool", "name": tool["name"]},
messages=[
{"role": "user", "content": "리뷰를 분석하세요: " + review}
]
)
tool_block = next(
b for b in response.content if b.type == "tool_use"
)
# Pydantic 검증 및 타입 변환
return ReviewAnalysis.model_validate(tool_block.input)class CodeIssue(BaseModel):
severity: str = Field(pattern="^(critical|warning|info)$")
line: int = Field(ge=1)
message: str
suggestion: str
class FileAnalysis(BaseModel):
file_path: str
language: str
issues: list[CodeIssue]
overall_quality: int = Field(ge=1, le=10)
class CodeReviewResult(BaseModel):
"""코드 리뷰 결과"""
files: list[FileAnalysis]
total_issues: int
critical_count: int
recommendation: str = Field(
description="전체 코드베이스에 대한 종합 권고"
)상황에 따라 포함 여부가 달라지는 필드를 설계할 수 있습니다.
from typing import Optional
class TaskResult(BaseModel):
status: str = Field(pattern="^(success|failure|partial)$")
result: Optional[str] = Field(
default=None,
description="성공 시 결과값. 실패 시 None"
)
error: Optional[str] = Field(
default=None,
description="실패 시 오류 메시지. 성공 시 None"
)
confidence: float = Field(
ge=0.0, le=1.0,
description="결과에 대한 신뢰도"
)API 수준의 구조화 출력을 사용하더라도, 의미적 검증(semantic validation)은 별도로 필요합니다. 형식은 올바르지만 내용이 잘못될 수 있기 때문입니다.
import anthropic
from pydantic import BaseModel, ValidationError
from typing import TypeVar, Type
T = TypeVar("T", bound=BaseModel)
class StructuredLLM:
def __init__(self):
self.client = anthropic.Anthropic()
def generate(
self,
prompt: str,
output_type: Type[T],
max_retries: int = 3
) -> T:
"""구조화된 출력을 생성하고 검증합니다."""
tool = self._create_tool(output_type)
last_error = None
for attempt in range(max_retries):
try:
response = self.client.messages.create(
model="claude-sonnet-4-5-20250514",
max_tokens=1024,
tools=[tool],
tool_choice={"type": "tool", "name": tool["name"]},
messages=self._build_messages(
prompt, last_error, attempt
)
)
tool_block = next(
b for b in response.content
if b.type == "tool_use"
)
# Pydantic 검증
result = output_type.model_validate(tool_block.input)
return result
except ValidationError as e:
last_error = str(e)
continue
raise RuntimeError(
"최대 재시도 횟수 초과. 마지막 오류: " + str(last_error)
)
def _build_messages(self, prompt, last_error, attempt):
messages = [{"role": "user", "content": prompt}]
if last_error and attempt > 0:
messages[0]["content"] += (
"\n\n이전 시도에서 검증 오류가 발생했습니다: "
+ last_error
+ "\n이 오류를 수정하여 다시 시도하세요."
)
return messages
def _create_tool(self, model_type):
schema = model_type.model_json_schema()
return {
"name": model_type.__name__.lower(),
"description": model_type.__doc__ or "구조화된 출력",
"input_schema": schema
}복잡한 스키마일수록 모델의 응답 시간이 길어지고 오류 확률이 높아집니다. 필요한 필드만 포함하는 것이 중요합니다.
# 과도하게 복잡한 스키마 (비효율적)
class OverEngineered(BaseModel):
id: str
timestamp: str
version: str
metadata: dict
processing_info: dict
# ... 20개 이상의 필드
# 핵심만 포함한 스키마 (효율적)
class Focused(BaseModel):
sentiment: str
score: int
summary: str필드가 10개를 넘으면 스키마를 분할하는 것을 고려하세요. 하나의 복잡한 호출보다 여러 번의 단순한 호출이 정확도와 안정성 모두에서 유리할 수 있습니다.
JSON Schema의 description 필드는 모델이 각 필드의 의미를 이해하는 데 핵심적인 역할을 합니다.
# description 없음 (모호)
class Result(BaseModel):
status: str
value: float
tags: list[str]
# description 있음 (명확)
class Result(BaseModel):
status: str = Field(description="처리 상태: success, failure, pending 중 하나")
value: float = Field(description="0.0~1.0 범위의 정규화된 점수")
tags: list[str] = Field(description="관련 태그. 최대 5개")이 장에서는 LLM의 구조화된 출력을 설계하고 프로덕션에 통합하는 방법을 다루었습니다.
다음 장에서는 시스템 프롬프트 설계 패턴을 다루겠습니다. 프로덕션 환경에서 일관된 모델 행동을 보장하는 시스템 프롬프트의 구조와 설계 원칙을 살펴보겠습니다.
이 글이 도움이 되셨나요?
프로덕션 환경에서 일관된 모델 행동을 보장하는 시스템 프롬프트의 구조, 설계 원칙, 그리고 실전 패턴을 체계적으로 다룹니다.
프롬프트의 구조를 명확히 하는 XML, JSON, 마크다운 기반 입력 설계 기법과 모델별 최적 전략을 다룹니다.
메타 프롬프팅, 프롬프트 체이닝, 자기 성찰, Tree-of-Thought 등 복잡한 작업을 해결하는 고급 프롬프트 엔지니어링 기법을 다룹니다.