AI Structured Output — JSON 모드와 함수 호출

왜 구조화된 출력이 필요한가?

LLM은 자유로운 텍스트를 생성하지만, 실제 애플리케이션에서는 정해진 형식의 데이터가 필요합니다. API 응답은 JSON이어야 하고, 데이터베이스에 저장하려면 필드가 정확해야 합니다.

레스토랑에서 주문하는 것에 비유하면, “맛있는 거 주세요”(자유 텍스트)보다 주문서 양식을 채워주세요(구조화된 출력)가 정확한 결과를 얻을 수 있습니다.

구조화된 출력이 필요한 대표적 상황:

상황필요한 형식예시
API 응답 생성JSON{"name": "김철수", "age": 30}
데이터 추출스키마 기반 객체이메일에서 일정 정보 추출
함수 호출함수 인자search(query="날씨", city="서울")
분류 작업Enum"sentiment": "positive"
워크플로우 제어다음 단계 결정{"action": "search", "params": {...}}

방법 1: JSON 모드

OpenAI, Anthropic 등 주요 API는 JSON 모드를 지원합니다. 응답이 반드시 유효한 JSON임을 보장합니다.

from openai import OpenAI

client = OpenAI()

# JSON 모드 활성화
response = client.chat.completions.create(
    model="gpt-4o",
    response_format={"type": "json_object"},  # JSON 모드 활성화
    messages=[
        {
            "role": "system",
            "content": "사용자의 요청을 분석하여 JSON으로 응답하세요."
        },
        {
            "role": "user",
            "content": "서울 강남구에 사는 30세 김철수의 프로필을 만들어줘"
        }
    ]
)

import json
data = json.loads(response.choices[0].message.content)
print(json.dumps(data, ensure_ascii=False, indent=2))
# 출력:
# {
#   "name": "김철수",
#   "age": 30,
#   "address": {
#     "city": "서울",
#     "district": "강남구"
#   }
# }

JSON 모드의 한계: 유효한 JSON은 보장하지만, 스키마 준수는 보장하지 않습니다. age 필드가 문자열일 수도, 숫자일 수도 있습니다.

방법 2: Structured Outputs (스키마 강제)

OpenAI의 Structured Outputs는 JSON Schema를 정의하면 100% 스키마를 준수하는 응답을 생성합니다.

from openai import OpenAI
from pydantic import BaseModel

client = OpenAI()

# Pydantic으로 출력 스키마 정의
class UserProfile(BaseModel):
    name: str
    age: int
    email: str
    skills: list[str]
    is_active: bool

# Structured Outputs — 스키마 강제
response = client.beta.chat.completions.parse(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "사용자 프로필을 생성하세요."},
        {"role": "user", "content": "Python과 JavaScript를 하는 25세 이영희"}
    ],
    response_format=UserProfile  # Pydantic 모델로 스키마 지정
)

# 타입이 보장된 객체 반환
user = response.choices[0].message.parsed
print(f"이름: {user.name}")        # 출력: 이름: 이영희
print(f"나이: {user.age}")         # 출력: 나이: 25 (반드시 int)
print(f"스킬: {user.skills}")      # 출력: 스킬: ['Python', 'JavaScript']
print(f"활성: {user.is_active}")   # 출력: 활성: True (반드시 bool)

복잡한 스키마 예시

from pydantic import BaseModel, Field
from enum import Enum

class Sentiment(str, Enum):
    positive = "positive"
    negative = "negative"
    neutral = "neutral"

class Entity(BaseModel):
    name: str = Field(description="엔티티 이름")
    type: str = Field(description="엔티티 유형 (인물, 장소, 조직 등)")

class TextAnalysis(BaseModel):
    """텍스트 분석 결과 스키마"""
    summary: str = Field(description="텍스트 요약 (1~2문장)")
    sentiment: Sentiment = Field(description="감성 분석 결과")
    entities: list[Entity] = Field(description="추출된 엔티티 목록")
    keywords: list[str] = Field(description="핵심 키워드 (최대 5개)")
    confidence: float = Field(description="분석 신뢰도 (0.0~1.0)")

response = client.beta.chat.completions.parse(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "텍스트를 분석하세요."},
        {"role": "user", "content": "삼성전자가 서울 강남에서 AI 반도체 신제품을 발표했다. 시장 반응은 매우 긍정적이다."}
    ],
    response_format=TextAnalysis
)

analysis = response.choices[0].message.parsed
print(f"감성: {analysis.sentiment}")     # 출력: 감성: positive
print(f"엔티티: {analysis.entities}")    # 출력: [Entity(name='삼성전자', type='조직'), ...]
print(f"신뢰도: {analysis.confidence}")  # 출력: 신뢰도: 0.92

방법 3: Function Calling (Tool Use)

Function Calling은 LLM이 어떤 함수를 호출할지, 어떤 인자를 전달할지 결정하는 메커니즘입니다. 에이전트 패턴의 핵심입니다.

from openai import OpenAI
import json

client = OpenAI()

# 도구(함수) 정의
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "특정 도시의 현재 날씨를 조회합니다",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "도시 이름 (예: 서울, 부산)"
                    },
                    "unit": {
                        "type": "string",
                        "enum": ["celsius", "fahrenheit"],
                        "description": "온도 단위"
                    }
                },
                "required": ["city"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "search_restaurants",
            "description": "주변 맛집을 검색합니다",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {"type": "string", "description": "검색 위치"},
                    "cuisine": {"type": "string", "description": "음식 종류"},
                    "max_results": {"type": "integer", "description": "최대 결과 수"}
                },
                "required": ["location"]
            }
        }
    }
]

# LLM이 적절한 함수를 선택
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "user", "content": "서울 날씨 어때? 그리고 강남 근처 이탈리안 맛집 추천해줘"}
    ],
    tools=tools,
    tool_choice="auto"  # LLM이 자동으로 도구 선택
)

# 함수 호출 결과 처리
for tool_call in response.choices[0].message.tool_calls:
    func_name = tool_call.function.name
    func_args = json.loads(tool_call.function.arguments)
    print(f"호출 함수: {func_name}")
    print(f"인자: {func_args}")
# 출력:
# 호출 함수: get_weather
# 인자: {"city": "서울"}
# 호출 함수: search_restaurants
# 인자: {"location": "강남", "cuisine": "이탈리안", "max_results": 5}

Anthropic Claude의 Tool Use

Claude API도 유사한 도구 사용 기능을 제공합니다.

import anthropic

client = anthropic.Anthropic()

# 도구 정의 (Claude 형식)
tools = [
    {
        "name": "calculate",
        "description": "수학 계산을 수행합니다",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "계산할 수식 (예: 2 + 3 * 4)"
                }
            },
            "required": ["expression"]
        }
    }
]

response = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    tools=tools,
    messages=[
        {"role": "user", "content": "15의 제곱에서 100을 빼면?"}
    ]
)

# 도구 사용 결과 확인
for block in response.content:
    if block.type == "tool_use":
        print(f"도구: {block.name}")
        print(f"입력: {block.input}")
        # 출력:
        # 도구: calculate
        # 입력: {'expression': '15**2 - 100'}

방법 비교

기준JSON 모드Structured OutputsFunction Calling
스키마 보장아니오 (유효 JSON만)예 (100%)예 (인자 스키마)
용도단순 JSON 응답복잡한 데이터 추출외부 함수 연동
지원 모델대부분GPT-4o 등 일부대부분
Pydantic 통합수동 파싱자동 파싱수동 파싱
중첩 구조가능완벽 지원가능

실전 팁

  • Structured Outputs를 우선 사용하세요: 스키마 준수가 100% 보장되므로 파싱 에러 처리 코드가 불필요합니다.
  • Pydantic 모델을 적극 활용하세요: 타입 힌트와 검증을 동시에 얻을 수 있고, IDE 자동완성도 지원됩니다.
  • 함수 설명을 구체적으로 작성하세요: description 필드가 LLM이 올바른 도구를 선택하는 핵심 기준입니다. “무엇을 하는지”와 “언제 사용하는지” 모두 포함하세요.
  • required 필드를 명시하세요: 필수 인자를 지정하지 않으면 LLM이 임의로 생략할 수 있습니다.
  • Enum으로 선택지를 제한하세요: 자유 텍스트 대신 enum으로 허용 값을 제한하면 일관된 출력을 얻을 수 있습니다.
  • fallback 전략을 마련하세요: Structured Outputs를 지원하지 않는 모델에서는 JSON 모드 + Pydantic 검증 조합으로 대체할 수 있습니다.

이 글이 도움이 되었나요?