LLM 앱 개발 실전 #5 구조화된 출력 받기

5 분 소요

4편에서 프롬프트로 출력 형식을 좁히는 법을 봤습니다. 그런데 “한 단어로만 답해"라고 부탁해도 모델이 가끔 문장으로 답할 수 있습니다. 앱에서 그 결과를 코드로 바로 다루려면 형식이 흔들려서는 곤란합니다. 이번 글에서는 출력 형식을 강제해서, 받은 결과를 곧바로 코드에 꽂아 쓰는 방법을 다룹니다.

프롬프트만으로는 부족하다 #

4편의 분류 예제를 떠올려 보겠습니다. “긍정, 부정, 중립 중 하나로만 답해"라고 지시했습니다. 대부분은 잘 따르지만, 모델이 어쩌다 “약간 부정적입니다” 같은 문장으로 답할 수도 있습니다. 사람이 보기엔 문제없지만, 코드가 그 결과를 이렇게 쓴다면 어떨까요?

if answer == "부정":
    flag_review(review)

답이 “부정"이 아니라 “약간 부정적입니다"로 오면 이 조건은 그냥 빗나갑니다. 한 번이라도 형식이 어긋나면 처리가 깨집니다. 앱에서 결과를 기계적으로 쓰려면 형식이 100% 보장돼야 합니다.

구조화된 출력(structured output)이 이 문제를 해결합니다. 우리가 정한 JSON 스키마에 응답이 반드시 맞도록 강제하는 기능입니다. 모델은 그 형식을 벗어날 수 없습니다.

Pydantic으로 출력 형식 정의하기 #

Python에서 가장 편한 방법은 Pydantic 모델로 원하는 구조를 정의하고 messages.parse에 넘기는 것입니다. 응답이 검증된 객체로 돌아옵니다.

structured_pydantic.py
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 스키마를 직접 넣습니다.

structured_schema.py
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 후 바로 키로 접근해도 안전합니다.

텍스트에서 구조화된 데이터 뽑기 #

구조화된 출력이 진짜 힘을 발휘하는 곳은 자유 형식의 텍스트를 정돈된 데이터로 바꿀 때입니다. 이메일, 회의록, 리뷰처럼 사람이 쓴 글에서 필요한 정보만 추출해 구조로 담을 수 있습니다. 중첩된 객체나 목록도 됩니다.

extract_tasks.py
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는 제목과 우선순위를 가진 객체이고, 우선순위는 정해 둔 세 값 중 하나입니다. 이런 추출 작업을 손으로 정규식이나 문자열 처리로 짜려면 까다롭지만, 구조화된 출력으로는 모델이 한 번에 해결합니다.

노트
스키마에는 제약이 있습니다. 숫자의 최소,최대값이나 문자열 길이 같은 세부 제약, 그리고 재귀 구조는 지원하지 않습니다. Python과 TypeScript SDK는 지원하지 않는 제약을 자동으로 걸러내고 클라이언트 쪽에서 검증하므로, 우선 Pydantic으로 자연스럽게 작성하면 됩니다. 구조화된 출력은 claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5에서 쓸 수 있습니다.

흔히 걸려 넘어지는 곳 #

  • JSON이 중간에 잘린다max_tokens가 작으면 JSON이 닫히기 전에 잘려 파싱이 실패합니다. 구조화된 출력은 결과 길이를 가늠해 넉넉하게 잡습니다.
  • 거부 시에는 형식이 보장되지 않는다 — 모델이 안전상의 이유로 답을 거부하면(stop_reasonrefusal) 스키마를 지키지 않을 수 있습니다. 사용자 입력을 다루는 앱이라면 이 경우를 따로 처리합니다.
  • 스키마를 너무 자주 바꾼다 — 새 스키마는 첫 호출에서 한 번 컴파일 비용이 들고, 같은 스키마는 24시간 동안 캐시됩니다. 매 호출마다 스키마를 조금씩 바꾸면 이 캐시를 못 씁니다. 스키마는 고정해 두고 쓰는 편이 좋습니다.

마무리 #

이번 글에서는 출력 형식을 강제하는 구조화된 출력을 다뤘습니다.

  • 프롬프트는 형식을 좁힐 뿐 보장하지는 못하지만, 구조화된 출력은 스키마에 맞도록 강제합니다.
  • Python에서는 Pydantic 모델과 messages.parse로 검증된 객체를 바로 받습니다.
  • Pydantic 없이 output_config에 JSON 스키마를 직접 넣을 수도 있습니다.
  • 자유 형식 텍스트에서 중첩 객체나 목록을 포함한 구조화된 데이터를 뽑아낼 수 있습니다.

여기까지는 Claude가 텍스트나 정돈된 데이터를 “답"으로 돌려주는 흐름이었습니다. 다음 글인 “LLM 앱 개발 실전 #6 툴 콜링으로 외부 기능 연결"에서는 방향이 바뀝니다. Claude가 우리가 정의한 함수를 직접 호출하게 해서, 검색이나 데이터베이스, 외부 API 같은 바깥 세상과 연결합니다.

X