LLM 앱 개발 실전 #5 구조화된 출력 받기
4편에서 프롬프트로 출력 형식을 좁히는 법을 봤습니다. 그런데 “한 단어로만 답해"라고 부탁해도 모델이 가끔 문장으로 답할 수 있습니다. 앱에서 그 결과를 코드로 바로 다루려면 형식이 흔들려서는 곤란합니다. 이번 글에서는 출력 형식을 강제해서, 받은 결과를 곧바로 코드에 꽂아 쓰는 방법을 다룹니다.
프롬프트만으로는 부족하다 #
4편의 분류 예제를 떠올려 보겠습니다. “긍정, 부정, 중립 중 하나로만 답해"라고 지시했습니다. 대부분은 잘 따르지만, 모델이 어쩌다 “약간 부정적입니다” 같은 문장으로 답할 수도 있습니다. 사람이 보기엔 문제없지만, 코드가 그 결과를 이렇게 쓴다면 어떨까요?
if answer == "부정":
flag_review(review)답이 “부정"이 아니라 “약간 부정적입니다"로 오면 이 조건은 그냥 빗나갑니다. 한 번이라도 형식이 어긋나면 처리가 깨집니다. 앱에서 결과를 기계적으로 쓰려면 형식이 100% 보장돼야 합니다.
구조화된 출력(structured output)이 이 문제를 해결합니다. 우리가 정한 JSON 스키마에 응답이 반드시 맞도록 강제하는 기능입니다. 모델은 그 형식을 벗어날 수 없습니다.
Pydantic으로 출력 형식 정의하기 #
Python에서 가장 편한 방법은 Pydantic 모델로 원하는 구조를 정의하고 messages.parse에 넘기는 것입니다. 응답이 검증된 객체로 돌아옵니다.
from typing import Literal
from pydantic import BaseModel
import anthropic
class Review(BaseModel):
sentiment: Literal["긍정", "부정", "중립"]
summary: str
score: int
client = anthropic.Anthropic()
response = client.messages.parse(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{
"role": "user",
"content": "다음 리뷰를 분석해줘: 배송은 빨랐지만 제품이 불량이었어요. 환불은 받았습니다.",
}],
output_format=Review,
)
review = response.parsed_output
print(review.sentiment) # 반드시 긍정/부정/중립 중 하나
print(review.score) # 정수response.parsed_output은 검증을 마친 Review 인스턴스입니다. sentiment는 반드시 셋 중 하나이고, score는 정수입니다. JSON 문자열을 직접 파싱하거나 형식을 검사할 필요가 없습니다. 모델이 형식을 지켰는지 걱정하지 않고, 객체의 속성을 바로 꺼내 쓰면 됩니다.
스키마를 직접 쓰기 #
Pydantic을 쓰지 않는다면 output_config에 JSON 스키마를 직접 넣습니다.
import json
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{
"role": "user",
"content": "다음 리뷰를 분석해줘: 배송은 빨랐지만 제품이 불량이었어요. 환불은 받았습니다.",
}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"sentiment": {"type": "string", "enum": ["긍정", "부정", "중립"]},
"summary": {"type": "string"},
"score": {"type": "integer"},
},
"required": ["sentiment", "summary", "score"],
"additionalProperties": False,
},
},
},
)
text = next(b.text for b in response.content if b.type == "text")
data = json.loads(text)
print(data["sentiment"])결과의 첫 text 블록이 스키마를 따르는 유효한 JSON입니다. enum에 든 값만 나오고, required로 지정한 필드는 모두 들어 있습니다. 그래서 json.loads 후 바로 키로 접근해도 안전합니다.
텍스트에서 구조화된 데이터 뽑기 #
구조화된 출력이 진짜 힘을 발휘하는 곳은 자유 형식의 텍스트를 정돈된 데이터로 바꿀 때입니다. 이메일, 회의록, 리뷰처럼 사람이 쓴 글에서 필요한 정보만 추출해 구조로 담을 수 있습니다. 중첩된 객체나 목록도 됩니다.
from typing import Literal
from pydantic import BaseModel
class Task(BaseModel):
title: str
priority: Literal["높음", "보통", "낮음"]
class MeetingNotes(BaseModel):
summary: str
tasks: list[Task]
notes = """오늘 회의에서 다음 분기 출시 일정을 정했다.
디자인 시안은 다음 주까지 급하게 마무리하기로 했고,
QA 인력 충원은 시간 날 때 천천히 알아보기로 했다."""
response = client.messages.parse(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": f"다음 회의록을 정리해줘:\n\n{notes}"}],
output_format=MeetingNotes,
)
result = response.parsed_output
for task in result.tasks:
print(f"[{task.priority}] {task.title}")자유롭게 쓴 회의록이 summary 한 줄과 tasks 목록으로 정돈되어 돌아옵니다. 각 task는 제목과 우선순위를 가진 객체이고, 우선순위는 정해 둔 세 값 중 하나입니다. 이런 추출 작업을 손으로 정규식이나 문자열 처리로 짜려면 까다롭지만, 구조화된 출력으로는 모델이 한 번에 해결합니다.
claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5에서 쓸 수 있습니다.흔히 걸려 넘어지는 곳 #
- JSON이 중간에 잘린다 —
max_tokens가 작으면 JSON이 닫히기 전에 잘려 파싱이 실패합니다. 구조화된 출력은 결과 길이를 가늠해 넉넉하게 잡습니다. - 거부 시에는 형식이 보장되지 않는다 — 모델이 안전상의 이유로 답을 거부하면(
stop_reason이refusal) 스키마를 지키지 않을 수 있습니다. 사용자 입력을 다루는 앱이라면 이 경우를 따로 처리합니다. - 스키마를 너무 자주 바꾼다 — 새 스키마는 첫 호출에서 한 번 컴파일 비용이 들고, 같은 스키마는 24시간 동안 캐시됩니다. 매 호출마다 스키마를 조금씩 바꾸면 이 캐시를 못 씁니다. 스키마는 고정해 두고 쓰는 편이 좋습니다.
마무리 #
이번 글에서는 출력 형식을 강제하는 구조화된 출력을 다뤘습니다.
- 프롬프트는 형식을 좁힐 뿐 보장하지는 못하지만, 구조화된 출력은 스키마에 맞도록 강제합니다.
- Python에서는 Pydantic 모델과
messages.parse로 검증된 객체를 바로 받습니다. - Pydantic 없이
output_config에 JSON 스키마를 직접 넣을 수도 있습니다. - 자유 형식 텍스트에서 중첩 객체나 목록을 포함한 구조화된 데이터를 뽑아낼 수 있습니다.
여기까지는 Claude가 텍스트나 정돈된 데이터를 “답"으로 돌려주는 흐름이었습니다. 다음 글인 “LLM 앱 개발 실전 #6 툴 콜링으로 외부 기능 연결"에서는 방향이 바뀝니다. Claude가 우리가 정의한 함수를 직접 호출하게 해서, 검색이나 데이터베이스, 외부 API 같은 바깥 세상과 연결합니다.