포스트

LLM Structured Output 2026년 7월 판: JSON mode만으론 부족한 “Schema 강제”의 현실적 제약과 함수 호출 설계법

LLM Structured Output 2026년 7월 판: JSON mode만으론 부족한 “Schema 강제”의 현실적 제약과 함수 호출 설계법

들어가며

프로덕션에서 LLM을 “파서 앞에 세우는” 순간, 실패의 대부분은 모델 지능이 아니라 인터페이스 계약(contract) 에서 터집니다. 예를 들어 “주문서 추출 → DB upsert → 결제/메일 발송” 같은 파이프라인에서 JSON이 한 글자만 깨져도 장애고, 더 흔하게는 JSON은 유효한데 schema를 안 지켜서(필수 필드 누락, 타입 틀림, enum 위반) 다운스트림이 터집니다.

2026년 7월 기준, 주요 벤더는 크게 3단계를 제공합니다.

  • JSON mode: “유효한 JSON”까지만 보장(스키마 일치는 보장 X). OpenAI도 JSON mode는 schema 준수 보장이 없고, “json” 지시가 없으면 공백 스트리밍 같은 엣지케이스가 있어 방어가 필요하다고 명시합니다. (help.openai.com)
  • Structured Outputs / strict schema: JSON Schema에 맞게 토큰 생성 자체를 제한(= constrained/grammar decoding) 해서 형태를 강제. OpenAI는 strict: true로 함수 호출 인자(schema) 강제를 제공한다고 설명합니다. (openai.com)
  • Tool input strict(Anthropic): tool 입력을 schema-valid로 강제하며, 내부적으로 schema를 grammar로 컴파일해 샘플링을 제한한다고 밝힙니다. (platform.claude.com)

언제 쓰면 좋나

  • LLM 출력이 바로 코드 실행/DB write/외부 API 호출로 이어지는 경우(“파싱 실패 = 장애”인 곳)
  • 에이전트가 여러 툴을 오가며 중간 상태를 구조화 데이터로 전달해야 하는 워크플로
  • 다국어/자유서술 입력에서 추출(extraction) + 검증(validation) 이 중요한 경우

언제 쓰면 안 되나

  • 최종 산출물이 사람에게 읽히는 자유형 텍스트가 본질인 기능(보고서/요약/브레인스토밍). 억지 schema는 사고를 “칸 채우기”로 바꿔 품질을 깎을 수 있음.
  • schema가 잦게 바뀌고 실험이 빠른 단계에서, 매번 strict schema를 붙이면 프롬프트/스키마 버전 관리 비용이 먼저 폭발함.

🔧 핵심 개념

1) JSON mode vs Schema 강제(Structured Outputs)의 차이

  • JSON mode는 “파싱 가능한 JSON”만 목표. 즉 { "a": "1" } 같은 JSON은 나오지만, a가 number여야 한다거나 required가 뭔지까지는 보장하지 않습니다. OpenAI Help Center도 이 구분을 명확히 합니다. (help.openai.com)
  • Structured Outputs(Strict)모델의 토큰 선택 공간을 schema에 맞는 토큰으로 좁혀 “형태적으로” schema에 맞게 만듭니다. OpenAI는 이를 “developer-supplied JSON Schemas에 정확히 맞춘다”고 설명하며, 함수 호출에서는 strict: true로 tool arguments가 schema에 “exactly match” 되게 한다고 말합니다. (openai.com)
  • Anthropic도 동일하게 grammar-constrained sampling이라는 표현으로 “샘플링을 schema-valid 출력으로 제한”한다고 밝힙니다. (platform.claude.com)

핵심은 “프롬프트로 부탁”이 아니라, 디코딩 레벨에서 출력 언어를 제한한다는 점입니다.

2) 내부 작동 흐름(실무 관점)

실제로는 대개 이런 파이프라인입니다.

  1. JSON Schema(일부 subset) 를 받음
  2. 벤더가 schema를 grammar(생성 규칙) 로 컴파일
  3. 생성 중 매 토큰마다 “지금 이 토큰을 내면 grammar 위반인가?”를 체크해 위반 토큰을 확률 0으로 만듦
  4. 결과적으로 문법적으로 schema-valid 한 JSON만 생성

이 방식의 장점은 “재시도/후처리 정규식”을 크게 줄이지만, 대신 (a) schema subset 제한, (b) schema가 클수록 지연/비용, (c) semantic correctness(의미적 정합성)는 별개라는 트레이드오프가 생깁니다. “JSON Schema로 파싱 실패는 줄어도 안전하고 올바른 트랜잭션인지까지 결정해주진 않는다”는 연구도 같은 맥락을 지적합니다. (arxiv.org)

3) 함수 호출(Function calling)과 구조화 출력의 역할 분담

구조화 출력이 두 갈래로 갈립니다.

  • Tool input schema 강제: 모델이 툴을 호출할 때 arguments가 schema-valid여야 함
    • OpenAI: 함수 정의에 strict: true를 켜면 tool arguments가 schema에 정확히 맞게 생성 (openai.com)
    • Anthropic: tool 정의에 strict: true로 tool input을 schema-valid로 강제 (platform.claude.com)
  • Final answer를 JSON으로 강제: “최종 응답” 자체를 JSON schema로 받기(벤더별 API 형태는 다름)

실무에서 안정적인 패턴은 보통:

  • 중간 단계는 tool call로만 이동(모델이 임의 JSON을 말하지 못하게)
  • 최종 단계도 가능하면 schema 강제된 JSON으로 받고, UI/보고서는 서버가 렌더링

💻 실전 코드

현실적인 시나리오: “인보이스(자유 텍스트) → ERP 입력용 JSON → (검증) → 벤더 API 호출”
아래 예제는 OpenAI 함수 호출 + strict schema를 통해 “ERP에 넣을 페이로드”를 강제합니다. (중요: schema 강제는 ‘형태’ 보장이지 ‘정답’ 보장이 아니라서, 서버 검증은 계속 합니다.)

0) 의존성/환경

1
2
pip install openai pydantic
export OPENAI_API_KEY="..."

1) Pydantic로 스키마 정의(= 단일 진실 소스)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pydantic import BaseModel, Field, conlist, condecimal
from typing import Literal

class LineItem(BaseModel):
    sku: str = Field(min_length=1)
    qty: int = Field(ge=1, le=1000)
    unit_price: condecimal(gt=0, max_digits=10, decimal_places=2)
    currency: Literal["USD", "EUR", "KRW"]

class ErpInvoiceUpsert(BaseModel):
    vendor_name: str = Field(min_length=1)
    invoice_number: str = Field(min_length=1)
    invoice_date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")  # YYYY-MM-DD
    payment_terms_days: int = Field(ge=0, le=180)
    items: conlist(LineItem, min_length=1, max_length=50)

2) 함수(tool)로 “업서트 요청”만 허용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from openai import OpenAI
import json

client = OpenAI()

TOOLS = [{
    "type": "function",
    "function": {
        "name": "erp_invoice_upsert",
        "description": "Upsert an invoice into ERP. Must be schema-valid.",
        "strict": True,  # 핵심: tool arguments schema 강제
        "parameters": ErpInvoiceUpsert.model_json_schema()
    }
}]

def call_llm_extract(invoice_text: str):
    resp = client.chat.completions.create(
        model="gpt-4o",   # 실제 사용 모델은 조직/프로젝트 표준에 맞게
        messages=[
            {"role": "system", "content": "Extract ERP invoice payload. Output ONLY via tool call."},
            {"role": "user", "content": invoice_text},
        ],
        tools=TOOLS,
        tool_choice={"type": "function", "function": {"name": "erp_invoice_upsert"}},  # 강제 호출
    )

    msg = resp.choices[0].message
    tool_call = msg.tool_calls[0]
    args = json.loads(tool_call.function.arguments)
    return args

raw = """
Invoice #A-19381
Vendor: ACME Supplies Inc.
Date: 2026/07/12
Terms: Net 30
Items:
- SKU: CAB-USB-C-2M Qty 12 Unit $7.50 USD
- SKU: DOCK-TB4 Qty 2 Unit $189.99 USD
"""

payload = call_llm_extract(raw)
print(json.dumps(payload, indent=2))

3) 서버측 “의미 검증” + idempotency

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pydantic import ValidationError

def upsert_to_erp(payload: dict):
    try:
        model = ErpInvoiceUpsert.model_validate(payload)  # 최종 방어선
    except ValidationError as e:
        # 여기서 재시도/휴먼리뷰/격리 큐
        raise RuntimeError(f"Schema-valid output expected, but validation failed: {e}")

    # 의미 검증(예: invoice_number 중복, vendor whitelist, SKU 존재 여부)
    # idempotency_key = hash(vendor_name + invoice_number)
    # ERP API call...

upsert_to_erp(payload)

예상 출력(형태 예시)

  • invoice_date2026-07-12로 정규화
  • items는 1~50개, qty/unit_price/currency가 타입/제약에 맞게 생성

이 패턴의 포인트는 “LLM이 텍스트로 JSON을 말하게” 두지 않고, tool call arguments로만 구조화 데이터를 내게 해서 실패면을 줄이는 것입니다.


⚡ 실전 팁 & 함정

Best Practice (2~3개)

1) Schema를 ‘업무 API 계약’처럼 버전 관리

  • ErpInvoiceUpsertV1, V2로 분리하고, 다운스트림도 버전 별로 라우팅하세요. strict를 켜면 작은 변경도 곧바로 런타임 실패로 이어지기 쉽습니다.

2) 의미 검증은 반드시 별도로

  • strict/structured outputs는 “문법/구조”를 보장합니다. 하지만 “SKU가 실제 존재하는지”, “금액 합계가 맞는지”, “벤더가 허용 목록인지”는 별개입니다. 구조가 맞는 환각된 주문은 여전히 가능합니다(semantic reliability 문제). (arxiv.org)

3) Tool choice를 강제해 ‘자유 텍스트 탈출’을 막기

  • tool_choice=...로 특정 tool을 강제하면, 모델이 설명을 덧붙이거나 반쯤 JSON을 말하는 상황을 줄입니다. OpenAI/Anthropic 모두 “툴 입력을 schema로 강제”하는 방향을 명확히 제공 중입니다. (openai.com)

흔한 함정/안티패턴

  • “JSON mode면 schema도 대충 맞겠지”: JSON mode는 유효 JSON만 보장하고 schema 준수는 보장하지 않습니다. (help.openai.com)
  • 과도하게 거대한 schema: nested/enum/anyOf가 폭증하면 (벤더별로) subset 제한/성능 저하/컨텍스트 예산 문제로 실전에서 깨집니다. 툴 스키마가 커질수록 컨텍스트 예산을 잡아먹는 문제는 연구에서도 “tool-context trade-off”로 다룹니다. (arxiv.org)
  • schema에 비밀/PHI를 넣기: Anthropic은 tool schema가 캐시될 수 있고, PHI를 schema 자체에 넣지 말라고 구체적으로 경고합니다(필드명/enum/pattern 포함). (platform.claude.com)

비용/성능/안정성 트레이드오프

  • strict schema는 재시도를 줄여 전체 비용을 낮출 수 있지만, schema 컴파일/제약 디코딩 때문에 단일 호출 latency가 늘 수 있습니다.
  • 또한 “출력 자유도”를 줄이기 때문에, 복잡한 추론을 해야 하는 작업은 품질이 떨어질 수 있습니다(칸을 채우는 최적화로 수렴).

🚀 마무리

2026년 7월 시점의 결론은 단순합니다.

  • JSON mode는 ‘파싱 가능한 JSON’일 뿐, schema 계약을 만족시키는 안전장치가 아닙니다. (help.openai.com)
  • 프로덕션 에이전트/자동화 파이프라인이라면, 가능한 구간에서 Structured Outputs(Strict) + 함수 호출(tool use) 로 “형태”를 강제하고, 서버에서 의미 검증 + idempotency + 정책 체크로 “내용”을 방어하는 2중 구조가 가장 비용 대비 효과가 좋습니다. (openai.com)
  • 다만 벤더별로 지원하는 JSON Schema subset이 다르고(예: Gemini도 subset 지원을 명시), portability는 생각보다 낮습니다. 멀티벤더 전략이면 “공통 subset + CI lint + 다운그레이드 경로”를 설계해야 합니다. (ai.google.dev)

도입 판단 기준(체크리스트)

  • 출력이 코드/DB/외부 호출로 직결되는가? → Yes면 strict 우선
  • schema가 빈번히 변하는가? → 변하면 버전 분리/점진적 롤아웃 필요
  • semantic 오류가 치명적인가(결제/권한/데이터 삭제)? → 의미 검증/승인 플로우 필수

다음 학습 추천

  • 벤더별 structured output 문서에서 “지원 keyword/subset” 표를 먼저 정리해, 내 스키마가 어디까지 이식 가능한지를 초기에 확정하세요. (Gemini도 subset 지원을 명시) (ai.google.dev)
  • “구조적 정합성(형태)”과 “의미적 정합성(정답/안전)”을 분리하는 테스트(골든셋 + adversarial prompts)를 CI에 넣는 걸 추천합니다. (arxiv.org)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.