LLM을 사람이 읽는 답변 생성기로 쓰는 동안에는 출력 형식이 문제가 되지 않는다. 문장이 조금 길거나 서두에 인사가 붙어도 사람은 알아서 읽는다. 문제는 그 출력을 다음 코드가 받아 쓰는 순간부터다. json.loads()에 넣는 순간 "네, 요청하신 정보를 정리해 드릴게요!"라는 한 줄이 예외가 되고, 어제까지 user_name이던 키가 오늘 userName으로 나오면 조용히 None이 흘러 들어간다. 구조화 출력은 이 지점을 다루는 기술이다.
세 가지 방식은 서로 다른 것을 보장한다
실무에서 쓰이는 방식은 크게 셋이고, 이들을 "정확도가 조금씩 높아지는 같은 종류의 방법"으로 이해하면 나중에 반드시 헛다리를 짚는다. 셋은 보장하는 대상 자체가 다르다.
첫 번째는 프롬프트에 "JSON으로만 답하라"고 적는 방식이다. 이것은 요청이지 제약이 아니다. 모델이 대체로 따르기 때문에 데모에서는 잘 돌아가고, 그래서 이 상태로 프로덕션까지 가는 코드가 많다. 실패는 트래픽이 늘고 입력이 다양해질 때 나타난다. 입력이 애매하면 모델은 설명하고 싶어지고, 설명하고 싶어지면 JSON 앞뒤에 문장이 붙는다.
두 번째는 JSON 모드다. API가 출력이 문법적으로 올바른 JSON이 되도록 보장한다. 파싱 오류는 사라진다. 하지만 어떤 JSON인지는 보장하지 않는다. 스키마를 프롬프트에 적어 두었더라도 그건 여전히 부탁이라, 필드 이름이 바뀌거나 필수 필드가 빠지거나 숫자여야 할 자리에 문자열이 온다.
세 번째가 스키마 강제다. 스키마를 API에 넘기면 디코딩 단계에서 스키마를 어기는 토큰이 후보에서 제외된다. 잘못된 형태를 만들 수 없으므로 형태에 관한 한 100%다. 이 동작이 실제로 어떻게 이뤄지는지는 제약 디코딩에서 따로 다룬다.
무엇이 남는가
세 번째까지 올려 두고도 남는 실패가 있다. 그리고 이 구분이 구조화 출력에서 가장 자주 어긋나는 지점이다.
| 실패 유형 | 프롬프트 요청 | JSON 모드 | 스키마 강제 |
|---|---|---|---|
| 앞뒤에 설명 문장이 붙는다 | 발생 | 없음 | 없음 |
| 코드 펜스로 감싸서 내보낸다 | 발생 | 없음 | 없음 |
| 따옴표·쉼표가 깨진다 | 발생 | 없음 | 없음 |
| 키 이름이 호출마다 다르다 | 발생 | 발생 | 없음 |
| 필수 필드가 빠진다 | 발생 | 발생 | 없음 |
| 숫자 자리에 문자열이 온다 | 발생 | 발생 | 없음 |
| enum에 없는 값이 온다 | 발생 | 발생 | 없음 |
| 값 자체가 사실과 다르다 | 발생 | 발생 | 발생 |
| 날짜가 형식은 맞고 존재하지 않는다 | 발생 | 발생 | 발생 |
| 필드 간 관계가 모순이다 | 발생 | 발생 | 발생 |
아래 세 줄이 핵심이다. 스키마 강제는 문법과 타입의 문제를 없애지 「의미의 문제」는 건드리지 않는다. "2026-02-31"은 format: "date"를 통과하는 구현이 흔하고, {"start": "2026-05-01", "end": "2026-04-01"}은 어떤 JSON Schema로도 막기 어렵다. 그래서 스키마를 붙였다고 검증 코드를 지우면, 사라지는 것은 파싱 오류일 뿐 잘못된 값은 그대로 통과한다. 이 뒷단 검증은 출력 검증에서 다룬다.
세 방식의 코드 형태
같은 작업을 세 방식으로 적으면 차이가 분명해진다. 고객 문의에서 티켓 정보를 뽑는 작업을 예로 든다.
import json
import anthropic
client = anthropic.Anthropic()
MESSAGE = "결제가 두 번 됐어요. 어제 오후 주문번호 A-8821입니다. 환불 부탁드립니다."
# 1. 부탁하기 — 파싱이 언제 깨질지 모른다
def by_prompt(text: str) -> dict:
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
messages=[{
"role": "user",
"content": f"다음 문의에서 정보를 뽑아 JSON으로만 답하라.\n\n{text}",
}],
)
raw = resp.content[0].text
return json.loads(raw) # 여기가 언젠가 터진다
이 코드가 실제로 배포된 뒤 흔히 겪는 순서는 이렇다. 먼저 json.loads에 try/except가 붙는다. 다음으로 앞뒤 문장을 벗겨 내려고 raw[raw.find("{"):raw.rfind("}") + 1] 같은 줄이 생긴다. 그다음 코드 펜스를 지우는 정규식이 붙는다. 이 세 줄이 생겼다는 건 방식을 바꿀 때가 지났다는 뜻이다. 문자열 후처리로는 "키 이름이 어제와 다르다" 같은 문제를 영원히 못 잡는다.
도구 정의(tool)를 이용하면 스키마를 그대로 넘길 수 있다. 요즘 API에서 구조화 출력의 표준 형태는 대개 이 모양이다.
TICKET_SCHEMA = {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["billing", "shipping", "technical", "other"],
},
"order_id": {"type": ["string", "null"]},
"urgency": {"type": "integer", "minimum": 1, "maximum": 5},
"summary": {"type": "string", "maxLength": 200},
},
"required": ["category", "order_id", "urgency", "summary"],
"additionalProperties": False,
}
def by_schema(text: str) -> dict:
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=512,
tools=[{
"name": "record_ticket",
"description": "문의 하나를 티켓으로 기록한다",
"input_schema": TICKET_SCHEMA,
}],
tool_choice={"type": "tool", "name": "record_ticket"},
messages=[{"role": "user", "content": text}],
)
for block in resp.content:
if block.type == "tool_use":
return block.input
raise NoStructuredOutput(resp.stop_reason)
tool_choice로 그 도구를 강제하는 부분이 중요하다. 이걸 빼면 모델이 도구를 안 부르고 그냥 문장으로 답할 수 있고, 그러면 다시 1번 상태로 돌아간다. 그리고 도구가 하나뿐이어도 반환 블록을 순회해야 한다 — 사고 블록이나 텍스트 블록이 앞에 붙는 경우가 있어서 resp.content[0]을 가정하면 인덱스가 어긋난다.
필드를 설계할 때 자주 어긋나는 것
스키마가 강제되기 시작하면 문제의 성격이 바뀐다. 이제 모델은 스키마를 반드시 채워야 하므로, 스키마가 잘못 설계돼 있으면 모델은 틀린 값이라도 만들어 넣는다.
가장 흔한 사고가 널 자리를 안 만드는 것이다. 위 예시에서 order_id를 {"type": "string"}으로만 두고 required에 넣으면, 주문번호가 없는 문의가 들어왔을 때 모델은 빈 문자열을 넣거나 문장에서 아무 숫자나 주워 담는다. 구조가 강제되므로 "모르겠다"고 답할 자리가 없기 때문이다. ["string", "null"]을 허용하거나 required에서 빼는 것만으로 이 부류의 환각이 크게 준다.
두 번째는 자유 문자열을 남겨 두는 것이다. "priority": {"type": "string"}은 "high", "High", "높음", "P1"을 모두 통과시킨다. 값의 집합이 정해져 있으면 enum으로 적는다. enum은 스키마 강제에서 가장 효과가 큰 도구다 — 후보 토큰이 그 문자열들로 좁혀지므로 오탈자조차 나올 수 없다.
세 번째는 순서다. JSON 객체의 필드는 모델이 적는 순서대로 생성되고, 먼저 적은 값이 뒤 값에 영향을 준다. 그래서 근거가 되는 필드를 결론보다 앞에 두는 편이 정확도에 유리하다. {"decision": ..., "reason": ...}보다 {"reason": ..., "decision": ...}이 낫다. 뒤 순서는 모델이 결론을 먼저 뱉고 그 결론에 맞는 이유를 지어내게 만든다.
# 결론이 먼저면 근거는 사후 정당화가 된다
BAD = {"properties": {"is_spam": {...}, "evidence": {...}}}
# 근거를 먼저 쓰게 하면 그 내용이 판정에 반영된다
GOOD = {"properties": {"evidence": {...}, "is_spam": {...}}}
이 효과는 스키마 강제를 쓸 때 특히 크다. 강제가 없을 때는 모델이 알아서 설명을 앞에 붙이기도 하지만, 강제하면 스키마에 적힌 순서 그대로만 나오기 때문이다.
언제 구조화 출력을 쓰지 않는가
모든 호출에 스키마를 붙이는 것도 답은 아니다. 세 가지 경우에는 오히려 손해다.
출력이 사람에게 그대로 보이는 경우가 첫째다. 챗봇 응답을 {"answer": "..."}로 감싸면 얻는 게 없다. 문자열 하나를 꺼내려고 JSON 문법 토큰을 더 쓰고, 긴 답변 안의 줄바꿈과 따옴표가 이스케이프되면서 토큰이 더 늘어난다.
긴 자유 서술이 본체인 경우가 둘째다. 스키마 안의 "content" 필드에 수천 토큰짜리 글을 넣게 하면 이스케이프 비용이 붙고, 생성 도중 상한에 걸렸을 때 JSON이 미완성 상태로 잘려 통째로 못 쓰게 된다. 형태가 필요한 메타데이터만 구조화하고 본문은 별도 호출로 받는 편이 안전하다.
셋째는 스키마가 아직 안 정해진 탐색 단계다. 어떤 필드가 필요한지 모르는 상태에서 스키마를 먼저 고정하면, 모델이 볼 수 있었던 정보를 스키마가 미리 잘라 낸다. 몇 십 건을 자유 형식으로 뽑아 보고 실제로 반복되는 것이 무엇인지 확인한 뒤에 스키마를 짜는 순서가 낫다.
정리
구조화 출력은 "모델이 시키는 대로 하게 만드는 기술"이 아니라 "모델이 어길 수 없게 만드는 기술"이다. 그래서 판단 기준도 하나다. 이 방식은 무엇을 구조적으로 불가능하게 만드는가. 프롬프트 요청은 아무것도 불가능하게 만들지 않고, JSON 모드는 문법 오류를, 스키마 강제는 타입과 필드 위반을 불가능하게 만든다. 그리고 어떤 방식도 값이 틀리는 것은 막지 못한다.
다음 글에서는 그 스키마를 실제로 어떻게 적는지 — JSON Schema의 어느 부분이 지원되고 어느 부분이 조용히 무시되는지를 본다.
지난 글: 추론 모델을 언제 쓸 것인가
다음 글: JSON Schema로 출력 형태를 못 박기
읽어주셔서 감사합니다. 😊

