본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 6장: 구조화된 출력 - JSON Schema와 타입 안전 응답
2026년 1월 22일·AI / ML·

6장: 구조화된 출력 - JSON Schema와 타입 안전 응답

LLM이 JSON Schema를 따르는 구조화된 응답을 생성하도록 설계하는 방법과 프로덕션 시스템 통합 전략을 다룹니다.

16분876자8개 섹션
llmprompt-engineeringstructured-outputtraining
공유
prompt-engineering6 / 10
12345678910
이전5장: 구조화된 입력 - XML, JSON, 마크다운 활용다음7장: 시스템 프롬프트 설계 패턴

구조화된 출력이 필요한 이유

LLM의 출력을 프로덕션 시스템에서 사용하려면 프로그래밍적으로 파싱 가능한 형태가 필요합니다. 자유 텍스트 응답은 사람이 읽기에는 좋지만, 다운스트림 시스템이 소비하기에는 불안정합니다.

text
# 자유 텍스트 응답 (파싱 어려움)
"이 리뷰의 감성은 긍정적입니다. 평점은 4점 정도로 볼 수 있으며,
주요 키워드는 배송, 품질, 가격입니다."
 
# 구조화된 응답 (프로그래밍적 처리 용이)
{
  "sentiment": "positive",
  "score": 4,
  "keywords": ["배송", "품질", "가격"]
}

구조화된 출력의 핵심 이점은 세 가지입니다.

  1. 안정적 파싱: 정해진 형식을 따르므로 파싱 실패가 줄어듭니다.
  2. 타입 안전성: 필드 타입이 보장되어 다운스트림 처리가 안정적입니다.
  3. 통합 용이성: API, 데이터베이스, UI 컴포넌트와 직접 연결할 수 있습니다.

프롬프트 기반 구조화 출력

가장 기본적인 방법은 프롬프트에서 출력 형식을 명시하는 것입니다.

기본 JSON 출력 요청

xml
<task>
다음 제품 리뷰를 분석하세요.
</task>
 
<review>
배터리가 이틀은 거뜬히 갑니다. 화면도 선명하고 반응 속도도 빠릅니다.
다만 무게가 좀 무겁고, 기본 제공 케이스의 품질이 아쉽습니다.
가격 대비 전반적으로 만족합니다.
</review>
 
<output_format>
다음 JSON 형식으로만 응답하세요. JSON 외의 텍스트는 포함하지 마세요.
 
{
  "sentiment": "positive | negative | neutral",
  "score": "1-5 정수",
  "pros": ["장점 목록"],
  "cons": ["단점 목록"],
  "summary": "한 줄 요약"
}
</output_format>

스키마와 설명을 함께 제공

단순히 형식만 보여주는 것보다 각 필드의 의미와 규칙을 설명하면 정확도가 높아집니다.

xml
<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>

API 수준의 구조화 출력

프롬프트만으로 JSON 형식을 강제하는 것에는 한계가 있습니다. 모델이 JSON 형식을 무시하거나 잘못된 JSON을 생성할 수 있기 때문입니다. 이를 해결하기 위해 주요 LLM 제공자들은 API 수준에서 구조화된 출력을 지원합니다.

Anthropic Claude - Tool Use 활용

Claude에서 구조화된 출력을 보장하는 가장 안정적인 방법은 Tool Use(도구 사용) 기능을 활용하는 것입니다. 도구의 입력 스키마를 정의하면 모델이 해당 스키마를 따르는 JSON을 생성합니다.

python
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": "..."}
Info

Claude의 Tool Use를 구조화된 출력에 활용할 때는 tool_choice를 특정 도구로 강제하세요. 이렇게 하면 모델이 반드시 해당 도구를 호출하여 스키마를 따르는 JSON을 반환합니다.

OpenAI - Structured Outputs

OpenAI는 Structured Outputs 기능을 통해 JSON Schema 준수를 보장합니다.

python
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": "다음 리뷰를 분석하세요: 배터리가 오래 가고 화면이 선명합니다."
        }
    ]
)

타입 안전 통합 패턴

TypeScript에서의 타입 정의와 검증

typescript
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);
}

Python Pydantic 통합

python
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)

복잡한 출력 구조 설계

중첩된 객체

python
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="전체 코드베이스에 대한 종합 권고"
    )

조건부 필드

상황에 따라 포함 여부가 달라지는 필드를 설계할 수 있습니다.

python
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)은 별도로 필요합니다. 형식은 올바르지만 내용이 잘못될 수 있기 때문입니다.

검증 및 재시도 구현

python
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
        }

성능 최적화

스키마 단순화

복잡한 스키마일수록 모델의 응답 시간이 길어지고 오류 확률이 높아집니다. 필요한 필드만 포함하는 것이 중요합니다.

python
# 과도하게 복잡한 스키마 (비효율적)
class OverEngineered(BaseModel):
    id: str
    timestamp: str
    version: str
    metadata: dict
    processing_info: dict
    # ... 20개 이상의 필드
 
# 핵심만 포함한 스키마 (효율적)
class Focused(BaseModel):
    sentiment: str
    score: int
    summary: str
Tip

필드가 10개를 넘으면 스키마를 분할하는 것을 고려하세요. 하나의 복잡한 호출보다 여러 번의 단순한 호출이 정확도와 안정성 모두에서 유리할 수 있습니다.

설명(description)의 중요성

JSON Schema의 description 필드는 모델이 각 필드의 의미를 이해하는 데 핵심적인 역할을 합니다.

python
# 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의 구조화된 출력을 설계하고 프로덕션에 통합하는 방법을 다루었습니다.

  • 프롬프트 기반 JSON 출력 요청은 기본적이지만, 형식 준수가 보장되지 않습니다.
  • Claude의 Tool Use와 OpenAI의 Structured Outputs는 API 수준에서 스키마 준수를 보장합니다.
  • Zod(TypeScript)와 Pydantic(Python)을 활용하면 런타임 타입 검증이 가능합니다.
  • 형식 검증과 의미 검증을 구분하고, 재시도 로직을 포함하는 것이 안정적입니다.
  • 스키마는 필요한 필드만 포함하여 단순하게 유지하고, description을 충실히 작성합니다.

다음 장에서는 시스템 프롬프트 설계 패턴을 다루겠습니다. 프로덕션 환경에서 일관된 모델 행동을 보장하는 시스템 프롬프트의 구조와 설계 원칙을 살펴보겠습니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#llm#prompt-engineering#structured-output#training

관련 글

AI / ML

7장: 시스템 프롬프트 설계 패턴

프로덕션 환경에서 일관된 모델 행동을 보장하는 시스템 프롬프트의 구조, 설계 원칙, 그리고 실전 패턴을 체계적으로 다룹니다.

2026년 1월 24일·20분
AI / ML

5장: 구조화된 입력 - XML, JSON, 마크다운 활용

프롬프트의 구조를 명확히 하는 XML, JSON, 마크다운 기반 입력 설계 기법과 모델별 최적 전략을 다룹니다.

2026년 1월 20일·18분
AI / ML

8장: 고급 기법 - 메타 프롬프팅, 프롬프트 체이닝, 자기 성찰

메타 프롬프팅, 프롬프트 체이닝, 자기 성찰, Tree-of-Thought 등 복잡한 작업을 해결하는 고급 프롬프트 엔지니어링 기법을 다룹니다.

2026년 1월 26일·22분
이전 글5장: 구조화된 입력 - XML, JSON, 마크다운 활용
다음 글7장: 시스템 프롬프트 설계 패턴

댓글

목차

약 16분 남음
  • 구조화된 출력이 필요한 이유
  • 프롬프트 기반 구조화 출력
    • 기본 JSON 출력 요청
    • 스키마와 설명을 함께 제공
  • API 수준의 구조화 출력
    • Anthropic Claude - Tool Use 활용
    • OpenAI - Structured Outputs
  • 타입 안전 통합 패턴
    • TypeScript에서의 타입 정의와 검증
    • Python Pydantic 통합
  • 복잡한 출력 구조 설계
    • 중첩된 객체
    • 조건부 필드
  • 출력 검증과 재시도 전략
    • 검증 및 재시도 구현
  • 성능 최적화
    • 스키마 단순화
    • 설명(description)의 중요성
  • 정리