AI 응답을 구조화된 JSON으로 강제하는 3가지 실전 방법
LLM에게 "JSON으로 답해줘"라고 부탁한 뒤 json.loads()를 호출했다가 JSONDecodeError를 만난 경험은 누구에게나 있습니다. 모델은 종종 "네, 다음은 추출한 데이터입니다:" 같은 군더더기를 앞에 붙이고, 마크다운 코드펜스(```json)로 감싸고, 마지막 항목 뒤에 트레일링 콤마를 남깁니다. 정규식으로 중괄호를 긁어내고, 코드펜스를 벗기고, 다시 파싱하는 방어 코드는 금세 누더기가 됩니다. 근본 해법은 따로 있습니다. 모델의 출력 형식 자체를 JSON Schema로 강제하는 것입니다. 이 글은 그 3가지 실전 방법 — (1) tool_choice로 특정 도구를 강제 호출해 인자를 구조화 데이터로 받기, (2) Claude의 output_config.format, (3) OpenAI의 response_format strict 모드 — 을 동작하는 코드와 함께 정리합니다. 이 글은 AI API 시리즈의 핵심 편입니다.
왜 자유 텍스트 파싱은 실패하는가
자유 텍스트 응답을 파싱하는 코드는 본질적으로 모델이 매번 똑같은 포맷을 지킬 것이라는 낙관에 기대고 있습니다. 그러나 같은 프롬프트라도 입력 데이터, 온도(temperature), 모델 버전에 따라 출력 형태가 미세하게 달라집니다. 실무에서 자주 마주치는 깨짐의 양상은 다음과 같습니다.
- 설명 군더더기:
"Here is the JSON:","아래 표를 참고하세요"같은 텍스트가 JSON 앞뒤에 붙음. - 코드펜스 래핑: 응답 전체가
```json ... ```로 감싸져 순수 JSON이 아님. - 구문 오류: 트레일링 콤마, 작은따옴표, 닫히지 않은 괄호.
- 타입 불일치: 숫자여야 할 필드가
"1,200"같은 문자열로 옴. - 필드 누락/환각: 스키마에 없는 키를 추가하거나, 필수 키를 빠뜨림.
해결의 핵심 개념은 하나입니다. "파싱 후 검증"이 아니라 "생성 단계에서 제약". 모델이 토큰을 만들 때부터 JSON Schema에 맞는 구조만 나오도록 강제하면, 후처리 정규식 지옥이 통째로 사라집니다. 아래에서 세 가지 강제 방법을 차례로 봅니다. 1차 예제는 Claude이며, OpenAI는 비교용으로 다룹니다.
스키마부터 정의하기 — Pydantic과 JSON Schema
어떤 방법을 쓰든 출발점은 스키마 정의입니다. 파이썬에서는 Pydantic 모델로 정의하고 model_json_schema()로 JSON Schema를 뽑아내는 흐름이 가장 깔끔합니다. 인보이스(청구서) 추출을 예로 들겠습니다.
from pydantic import BaseModel, Field
from typing import Literal
class LineItem(BaseModel):
description: str = Field(description="품목 설명")
quantity: int = Field(description="수량")
unit_price: float = Field(description="단가(통화 단위 제외, 숫자만)")
class Invoice(BaseModel):
invoice_number: str = Field(description="청구서 번호")
issue_date: str = Field(description="발행일 (YYYY-MM-DD)")
vendor_name: str = Field(description="공급자(판매자) 상호")
currency: Literal["KRW", "USD", "EUR", "JPY"]
line_items: list[LineItem]
total_amount: float = Field(description="총 청구 금액")
# Pydantic이 생성한 JSON Schema 확인
import json
print(json.dumps(Invoice.model_json_schema(), ensure_ascii=False, indent=2))
한 가지 중요한 제약을 미리 짚겠습니다. 구조화 출력 기능은 additionalProperties: false(스키마에 없는 키 금지)를 요구하지만, minimum/maximum/minLength 같은 수치·길이 제약은 지원하지 않습니다. 따라서 "수량은 1 이상"처럼 값의 범위를 검증하려면 모델에게 맡기지 말고 클라이언트 측에서 다시 검증해야 합니다(Pydantic 모델로 다시 파싱하면 자동으로 됩니다). 스키마는 구조를 보장하고, 비즈니스 제약은 코드가 보장한다 — 이 역할 분담을 기억하세요.
방법 1 — tool_choice로 특정 도구를 강제 호출하기
가장 폭넓게 호환되는 방법입니다. "구조화 출력 전용 API"가 없던 시절부터 쓰이던 패턴으로, 도구(function)의 input_schema를 곧 추출 스키마로 삼고, tool_choice로 그 도구를 반드시 호출하도록 강제합니다. 그러면 모델의 응답은 텍스트가 아니라 tool_use 블록이 되고, 그 .input은 이미 파싱된 dict로 도착합니다. 즉 "도구 호출 인자"라는 통로를 빌려 구조화 데이터를 받아내는 것입니다.
Claude에서 tool_choice는 네 가지 값을 가집니다. {"type":"auto"}(기본, 모델이 알아서) / {"type":"any"}(아무 도구나 반드시 하나) / {"type":"tool","name":"..."}(특정 도구 강제) / {"type":"none"}(도구 금지). 추출 시나리오에서는 {"type":"tool","name":"extract_invoice"}가 정답입니다.
import json
from anthropic import Anthropic
client = Anthropic() # ANTHROPIC_API_KEY 환경변수 사용
tools = [
{
"name": "extract_invoice",
"description": "청구서 이미지/텍스트에서 구조화된 인보이스 데이터를 추출한다.",
"input_schema": Invoice.model_json_schema(),
}
]
document = """
청구서 번호: INV-2026-0412
발행일: 2026-06-10
공급자: (주)뎁팀
통화: KRW
- API 연동 개발 1식 3,500,000
- RAG 파이프라인 구축 1식 4,200,000
총액: 7,700,000
"""
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=tools,
tool_choice={"type": "tool", "name": "extract_invoice"}, # 특정 도구 강제
messages=[{"role": "user", "content": f"다음 청구서를 추출하세요:\n{document}"}],
)
# 응답은 텍스트가 아니라 tool_use 블록. .input은 이미 파싱된 dict.
for block in msg.content:
if block.type == "tool_use" and block.name == "extract_invoice":
invoice = Invoice.model_validate(block.input) # 타입 검증까지 한 번에
print(invoice.vendor_name, invoice.total_amount)
핵심 포인트 세 가지입니다. 첫째, tool_choice로 도구를 강제하면 msg.stop_reason은 "tool_use"가 됩니다. 둘째, block.input은 SDK가 이미 dict로 파싱해 줍니다 — 원시 JSON 문자열을 정규식으로 매칭하지 마세요. 만약 어떤 경로로 원시 문자열을 다루게 된다면 반드시 json.loads()를 거칩니다. 셋째, model_validate()로 Pydantic에 다시 넣으면 타입 강제와 누락 필드 검출이 공짜로 따라옵니다.
방법 2 — Claude output_config.format (JSON 직접 강제)
도구 호출이라는 우회로 없이, 응답 본문 자체를 JSON으로 강제하는 현행 방식입니다. output_config={"format": {"type": "json_schema", "schema": {...}}}를 넘기면, 모델의 첫 텍스트 블록이 스키마에 부합하는 유효한 JSON 문자열로 채워집니다. (구버전 output_format 파라미터는 폐기되었으니 쓰지 마세요.) 이 기능은 claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5, claude-fable-5에서 지원됩니다.
import json
from anthropic import Anthropic
client = Anthropic()
schema = Invoice.model_json_schema()
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
output_config={"format": {"type": "json_schema", "schema": schema}},
messages=[{"role": "user", "content": f"다음 청구서를 JSON으로 추출:\n{document}"}],
)
# 첫 텍스트 블록이 유효한 JSON 문자열
raw = next(b.text for b in msg.content if b.type == "text")
invoice = Invoice.model_validate_json(raw) # 문자열 → 모델, 검증 동시 수행
print(invoice.invoice_number, invoice.currency)
더 간결한 길도 있습니다. SDK의 client.messages.parse(...)는 스키마 부합 응답을 받아 자동으로 검증까지 해 줍니다. 직접 json.loads를 호출할 필요가 없어 가장 권장되는 패턴입니다.
두 방법(도구 강제 vs output_config)은 언제 무엇을 쓸까요? 최종 산출물이 곧 데이터인 경우(추출·분류·파싱) — output_config.format 또는 parse()가 직관적입니다. 반면 에이전트 루프 안에서 여러 도구 중 하나로 구조화 인자를 받아야 하거나, 동일 호출에서 다른 도구들과 함께 써야 한다면 tool_choice 방식이 자연스럽습니다.
방법 3 — OpenAI response_format json_schema strict:true (비교용)
비교를 위해 OpenAI의 표준 패턴도 봅니다. response_format에 json_schema를 지정하고 strict: true를 켜면, 출력이 스키마를 엄격히 따르도록 보장됩니다. strict 모드에서는 모든 객체에 additionalProperties: false가 필요하고, 모든 속성이 required에 들어가야 한다는 제약이 있습니다(선택 필드는 type에 "null"을 허용하는 식으로 표현).
from openai import OpenAI
client = OpenAI() # OPENAI_API_KEY 환경변수 사용
schema = Invoice.model_json_schema()
resp = client.chat.completions.create(
model="gpt-4o", # 현재 제공 모델 ID는 공급사 공식 문서 확인 권장
messages=[
{"role": "system", "content": "청구서에서 구조화 데이터를 추출한다."},
{"role": "user", "content": document},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice",
"schema": schema,
"strict": True,
},
},
)
raw = resp.choices[0].message.content # 유효한 JSON 문자열
invoice = Invoice.model_validate_json(raw)
print(invoice.vendor_name)
참고로 OpenAI 파이썬 SDK에는 Pydantic 모델을 그대로 넘기는 헬퍼(client.beta.chat.completions.parse(..., response_format=Invoice))도 있어, 스키마 변환과 검증을 한 번에 처리할 수 있습니다. OpenAI/Gemini의 안정적 패턴 자체는 잘 알려져 있지만, 최신 모델 ID는 변동되므로 공급사 현행 문서를 확인하세요. 세 공급사의 접근을 한눈에 비교하면 다음과 같습니다.
| 방법 | 공급사 | 강제 위치 | 응답 형태 |
|---|---|---|---|
tool_choice={type:tool} | Claude / OpenAI 공통 | 도구 인자(input_schema) | tool_use 블록의 dict |
output_config.format | Claude | 응답 본문 | JSON 문자열(텍스트 블록) |
response_format strict | OpenAI | 응답 본문 | JSON 문자열(message.content) |
실전 예제 — 연락처 정보 추출 (Laravel/PHP)
독자 중 다수가 Laravel 개발자이므로, 명함/이메일 서명에서 연락처를 추출하는 예제를 PHP로 보입니다. PHP SDK는 도구 키에 camelCase(inputSchema)를 쓰고, stopReason·toolUseID도 동일하게 camelCase라는 점에 주의합니다.
<?php
use Anthropic\Client;
$client = new Client(apiKey: getenv("ANTHROPIC_API_KEY"));
$tools = [
[
"name" => "save_contact",
"description" => "텍스트에서 연락처 정보를 추출한다.",
"inputSchema" => [ // PHP SDK는 camelCase 키 사용
"type" => "object",
"properties" => [
"name" => ["type" => "string", "description" => "이름"],
"company" => ["type" => "string", "description" => "회사명"],
"email" => ["type" => "string", "description" => "이메일"],
"phone" => ["type" => "string", "description" => "전화번호"],
],
"required" => ["name", "email"],
"additionalProperties" => false,
],
],
];
$signature = "안녕하세요, (주)뎁팀 김개발입니다. [email protected] / 010-1234-5678";
$message = $client->messages->create(
model: "claude-opus-4-8",
maxTokens: 512,
tools: $tools,
toolChoice: ["type" => "tool", "name" => "save_contact"],
messages: [
["role" => "user", "content" => "다음에서 연락처 추출:\n" . $signature],
],
);
// tool_use 블록의 input은 이미 파싱된 연관 배열
foreach ($message->content as $block) {
if ($block->type === "tool_use") {
$contact = $block->input; // ["name" => "김개발", "email" => "dev@...", ...]
// 여기서 Laravel Validator로 비즈니스 제약을 다시 검증하는 것을 권장
\Log::info("추출된 연락처", $contact);
}
}
Laravel에서는 추출된 배열을 곧장 신뢰하지 말고 Validator::make($contact, [...])로 한 번 더 거르는 것이 안전합니다. 스키마는 구조(필드 존재·타입)를 보장하지만, "이메일 형식이 올바른가", "전화번호가 우리 포맷에 맞는가" 같은 도메인 규칙은 애플리케이션의 몫이기 때문입니다.
파싱·검증·실패 처리를 견고하게
구조화 출력을 강제하더라도 실패 가능성을 0으로 만들 수는 없습니다. 프로덕션 코드라면 다음 세 가지 경계를 반드시 처리해야 합니다.
- 토큰 초과로 잘림:
stop_reason == "max_tokens"이면 JSON이 중간에 끊겨 파싱이 실패합니다.max_tokens를 넉넉히 주고, 그래도 잘리면 재시도합니다. - 안전상 거부:
stop_reason == "refusal"이면 출력이 스키마를 따르지 않을 수 있습니다.content를 읽기 전에 먼저stop_reason을 확인하세요. - 검증 실패: 구조는 맞아도 비즈니스 제약(범위·형식)을 어길 수 있습니다. Pydantic
ValidationError를 잡아 재시도하거나 사람에게 에스컬레이션합니다.
import anthropic
from pydantic import ValidationError
def extract_invoice(document: str, retries: int = 2) -> Invoice | None:
for attempt in range(retries + 1):
try:
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
output_config={"format": {"type": "json_schema",
"schema": Invoice.model_json_schema()}},
messages=[{"role": "user",
"content": f"청구서를 JSON으로 추출:\n{document}"}],
)
# 1) content를 읽기 전에 stop_reason 확인
if msg.stop_reason == "refusal":
print("모델이 요청을 거부함")
return None
if msg.stop_reason == "max_tokens":
print("출력 잘림 — max_tokens 상향 후 재시도")
continue
# 2) 구조 + 타입 검증
raw = next(b.text for b in msg.content if b.type == "text")
return Invoice.model_validate_json(raw)
except ValidationError as e:
print(f"검증 실패(시도 {attempt + 1}): {e}") # 비즈니스 제약 위반 등
except anthropic.RateLimitError:
# SDK가 429/5xx는 지수 백오프로 자동 재시도하지만,
# 한도 소진 시 타입드 예외로 올라옴 — 문자열 매칭 금지
raise
return None
오류 처리에서 두 가지 원칙을 강조합니다. 첫째, 예외는 타입으로 잡으세요. Claude SDK는 anthropic.RateLimitError(429), anthropic.OverloadedError(529), anthropic.APIStatusError 등 타입드 예외를 제공합니다. 에러 메시지 문자열에 "429"가 들어있는지 검사하는 코드는 깨지기 쉽습니다. 둘째, 재시도는 SDK에 맡기세요. 429/5xx는 SDK가 지수 백오프로 자동 재시도하므로(max_retries), 직접 루프를 돌리는 것은 검증 실패나 잘림 같은 애플리케이션 레벨 실패에 한정합니다.
대량 처리와 비용 — 배치도 함께 고려하기
인보이스 수천 건을 한꺼번에 추출하는 비실시간 작업이라면, 매 건마다 동기 호출을 날리는 대신 Message Batches API(client.messages.batches.create)를 쓰는 편이 비용 효율적입니다. 구조화 출력 강제는 배치 요청 안에서도 동일하게 동작하므로, "스키마 강제 + 배치"를 결합하면 대규모 추출 파이프라인을 안정적이고 저렴하게 운영할 수 있습니다. 추출 작업은 보통 출력이 짧으므로, 비용에 민감하다면 claude-haiku-4-5 같은 빠르고 저렴한 모델로 내려보는 것도 좋은 선택입니다(스키마 강제는 동일하게 지원됩니다).
자주 묻는 질문 (FAQ)
Q. tool_choice 방식과 output_config.format 방식 중 무엇을 써야 하나요?
최종 산출물이 곧 구조화 데이터(추출·분류·파싱)라면 output_config.format이나 messages.parse()가 가장 직관적입니다. 반면 여러 도구가 도는 에이전트 루프 안에서 한 도구의 인자로 구조화 데이터를 받아야 하거나, 같은 호출에서 다른 도구들과 함께 써야 한다면 tool_choice={"type":"tool","name":...} 방식이 자연스럽습니다.
Q. JSON Schema에 minimum/maxLength 같은 제약을 넣으면 모델이 지켜주나요?
아니요. 구조화 출력 기능은 타입과 additionalProperties: false는 강제하지만, 수치·길이 제약(minimum, maximum, minLength 등)은 지원하지 않습니다. 이런 제약은 스키마에 넣어도 무시될 수 있으므로, 응답을 받은 뒤 클라이언트 측에서(예: Pydantic 모델로 다시 파싱) 검증해야 합니다.
Q. 구버전 output_format 파라미터를 그대로 써도 되나요?
안 됩니다. output_format은 폐기되었습니다. 현행 API는 output_config={"format": {"type": "json_schema", "schema": {...}}} 형태를 사용합니다. 더 간단하게는 client.messages.parse(...)가 검증까지 자동으로 처리합니다.
Q. 구조화 출력을 강제하면 파싱 실패가 완전히 사라지나요?
대부분 사라지지만 0은 아닙니다. stop_reason이 "max_tokens"면 JSON이 중간에 잘리고, "refusal"이면 스키마를 따르지 않을 수 있습니다. content를 읽기 전에 항상 stop_reason을 먼저 확인하고, max_tokens를 넉넉히 설정하세요.
Q. tool_use 블록의 input을 정규식으로 파싱해도 되나요?
하지 마세요. Claude SDK는 block.input을 이미 파싱된 dict로 제공합니다. 원시 JSON 문자열을 직접 다뤄야 하는 드문 경우에만 json.loads()를 사용하고, 절대 문자열 raw 매칭으로 값을 긁어내지 마세요. 모델 버전에 따라 유니코드·슬래시 이스케이프가 달라질 수 있어 raw 매칭은 쉽게 깨집니다.
AI 개발이 필요하신가요?
DevTeam은 OpenAI·Claude 등 AI API 연동, 챗봇, RAG, 업무 자동화를 설계부터 배포까지 구현합니다. AI 연동 개발 또는 무료 견적 문의.