왜 구조화된 출력이 필요한가?
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 Outputs | Function Calling |
|---|---|---|---|
| 스키마 보장 | 아니오 (유효 JSON만) | 예 (100%) | 예 (인자 스키마) |
| 용도 | 단순 JSON 응답 | 복잡한 데이터 추출 | 외부 함수 연동 |
| 지원 모델 | 대부분 | GPT-4o 등 일부 | 대부분 |
| Pydantic 통합 | 수동 파싱 | 자동 파싱 | 수동 파싱 |
| 중첩 구조 | 가능 | 완벽 지원 | 가능 |
실전 팁
- Structured Outputs를 우선 사용하세요: 스키마 준수가 100% 보장되므로 파싱 에러 처리 코드가 불필요합니다.
- Pydantic 모델을 적극 활용하세요: 타입 힌트와 검증을 동시에 얻을 수 있고, IDE 자동완성도 지원됩니다.
- 함수 설명을 구체적으로 작성하세요:
description필드가 LLM이 올바른 도구를 선택하는 핵심 기준입니다. “무엇을 하는지”와 “언제 사용하는지” 모두 포함하세요. required필드를 명시하세요: 필수 인자를 지정하지 않으면 LLM이 임의로 생략할 수 있습니다.- Enum으로 선택지를 제한하세요: 자유 텍스트 대신
enum으로 허용 값을 제한하면 일관된 출력을 얻을 수 있습니다. - fallback 전략을 마련하세요: Structured Outputs를 지원하지 않는 모델에서는 JSON 모드 + Pydantic 검증 조합으로 대체할 수 있습니다.