지난 글에서 스키마 강제가 무엇을 보장하는지를 봤다면, 이번에는 그 스키마를 실제로 어떻게 적는지를 본다. 여기서 가장 자주 어긋나는 인식이 하나 있다. JSON Schema는 원래 검증용 명세다. 이미 만들어진 문서가 규칙을 지켰는지 뒤에서 판정하는 언어다. 반면 구조화 출력은 이 명세를 생성 제약으로 쓴다. 아직 만들어지지 않은 토큰을 앞에서 막는 데 쓴다. 두 용도는 겹치지만 같지 않고, 겹치지 않는 자리에서 사고가 난다.
강제되는 것과 무시되는 것
검증기는 문서를 끝까지 다 받은 뒤에 판정할 수 있다. 하지만 생성 제약은 토큰 하나를 고르는 그 시점에 "이 토큰을 허용해도 되는가"를 답해야 한다. 그래서 앞에서부터 왼쪽에서 오른쪽으로 판정할 수 있는 규칙만 제약으로 옮겨진다. 나머지는 구현에 따라 무시되거나, 프롬프트에 힌트로 섞여 들어가거나, 다 만든 뒤 검증해서 재시도하는 식으로 처리된다.
| 키워드 | 대개 강제됨 | 이유 |
|---|---|---|
type |
예 | 다음에 올 수 있는 문자 집합이 바로 정해진다 |
enum / const |
예 | 후보 문자열이 유한하다 |
required |
예 | 아직 안 쓴 필수 키를 세어 두면 된다 |
properties |
예 | 키 이름 자체가 유한 집합이 된다 |
additionalProperties: false |
예 | 목록 밖 키를 못 열게 하면 끝 |
items |
예 | 배열 원소마다 같은 제약을 다시 건다 |
minLength / maxLength |
부분 | 길이 세기는 되지만 토크나이저 단위와 안 맞는다 |
pattern |
부분 | 정규식을 오토마톤으로 바꿀 수 있으면 된다 |
minimum / maximum |
부분 | 자릿수 단위로만 좁혀지고 경계는 못 맞춘다 |
format: "date" 등 |
거의 아니오 | 명세상 애초에 주석에 가깝다 |
minItems / maxItems |
구현마다 다름 | 세는 것은 되지만 지원이 고르지 않다 |
oneOf / not |
거의 아니오 | 전체를 봐야 판정된다 |
description |
아니오 | 제약이 아니라 프롬프트로 들어간다 |
이 표에서 실무적으로 가장 중요한 줄은 minimum/maximum이다. {"type": "integer", "minimum": 1, "maximum": 5}를 적어 두면 대개 한 자리 숫자까지는 좁혀지지만 7이나 0은 그대로 나올 수 있다. 값의 범위가 정말 중요하면 정수 범위 대신 enum: [1, 2, 3, 4, 5]로 적는 편이 확실하다. 후보가 유한 집합이 되면 그 밖의 토큰이 아예 못 나온다.
description은 스키마가 아니라 프롬프트다
description은 강제되지 않지만 정확도에 가장 크게 기여하는 필드다. 구조화 출력을 쓸 때 이 값은 모델에게 그대로 전달되는 지시문이기 때문이다. 그래서 "이 필드가 무엇인가"보다 "애매할 때 어떻게 하라"를 적는 편이 효과가 크다.
{
"refund_amount": {
"type": ["number", "null"],
"description": "요청된 환불 금액(원). 문의에 금액이 명시된 경우에만 채우고, 추정하지 말 것. 금액 언급이 없으면 null."
}
}
필드 이름과 같은 말을 반복하는 "description": "환불 금액"은 토큰만 쓰고 아무것도 안 바꾼다. 반대로 위처럼 경계 조건을 적어 두면 "총 5만 원 결제 중 일부만 환불" 같은 입력에서 모델이 멋대로 계산해 넣는 일이 줄어든다. 스키마를 고쳐도 안 잡히는 오류 대부분은 사실 description에 적을 말이 안 적혀 있어서 생긴다.
단위도 여기 적는다. "duration": {"type": "integer"}만 있으면 초인지 분인지 밀리초인지 모델이 매번 다르게 고른다. "description": "소요 시간(초)" 한 줄이 그 흔들림을 없앤다.
선택 필드를 다루는 법
JSON Schema에서 필드를 선택으로 만드는 정석은 required에서 빼는 것이다. 그런데 구조화 출력에서는 이 방식이 잘 안 통하는 구현이 많다. 많은 제공자가 required에 모든 키가 들어 있고 additionalProperties: false인 닫힌 스키마만 제대로 강제한다. 그래서 실무에서는 선택 필드를 이렇게 표현한다.
# 안 통하는 경우가 있는 방식 — 키가 통째로 빠질 수 있어야 한다
LOOSE = {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": [],
}
# 어디서나 도는 방식 — 키는 항상 있고 값으로 없음을 표현한다
STRICT = {
"type": "object",
"properties": {"order_id": {"type": ["string", "null"]}},
"required": ["order_id"],
"additionalProperties": False,
}
닫힌 쪽이 파싱하는 코드에도 편하다. 키의 유무와 값의 유무 두 가지를 다 확인할 필요 없이 data["order_id"] is None만 보면 된다. 그리고 지난 글에서 짚은 대로, 모델에게 "없음"을 표현할 자리를 주는 것 자체가 환각을 줄인다.
중첩과 재귀
깊은 중첩은 두 가지를 동시에 나쁘게 만든다. 제약 컴파일 비용이 올라가고, 모델의 정확도가 떨어진다. 3단계를 넘어가면 모델이 닫는 괄호의 위치를 헷갈리기 시작하고, 그 결과 형태는 맞는데 값이 엉뚱한 층에 들어가는 출력이 나온다.
{
"order": {
"customer": {
"address": {
"detail": { "floor": 3 }
}
}
}
}
이런 구조가 필요하다면 대개 한 번의 호출에 너무 많은 일을 시키고 있는 것이다. 평평한 스키마 여럿으로 나누고 코드에서 조립하는 편이 정확도와 디버깅 양쪽에서 낫다. 중간 층이 틀렸을 때 어느 호출이 틀렸는지 바로 보인다.
재귀 스키마는 더 조심할 대상이다. $ref로 자기 자신을 가리키는 트리 구조는 명세상 유효하지만 지원 여부가 갈리고, 지원되더라도 종료 조건이 없으면 모델이 계속 자식 노드를 만들어 상한까지 간다. 깊이 제한이 필요하면 스키마 대신 description에 "최대 3단계까지만"이라고 적고, 실제 절단은 받은 뒤 코드로 한다.
$ref 자체는 같은 하위 스키마를 여러 곳에서 재사용할 때 유용하고 대부분 지원된다. 다만 재귀가 아닌 경우로 한정하는 편이 안전하다.
배열에서 자주 나는 사고
배열은 개수가 문제가 된다. minItems: 3이 강제되지 않는 구현에서는 모델이 하나만 넣고 닫아 버리는 일이 흔하다. 반대로 상한이 없으면 있지도 않은 항목을 지어내 채우기도 한다. 개수가 중요하면 description에 목적을 적는 편이 숫자를 적는 것보다 잘 통한다 — "본문에 실제로 등장한 것만. 없으면 빈 배열." 같은 문장이다.
원소가 객체인 배열에서는 필드 순서가 특히 중요하다. 원소 하나를 만들 때마다 같은 순서가 반복되므로, 근거 필드를 앞에 두면 그 효과가 원소 수만큼 곱해진다.
FINDINGS = {
"type": "array",
"items": {
"type": "object",
"properties": {
# 인용 → 판단 → 점수 순서. 뒤 값이 앞 값을 근거로 나온다
"quote": {"type": "string", "description": "원문에서 그대로 옮긴 문장"},
"issue": {"type": "string", "enum": ["누락", "모순", "근거없음"]},
"severity": {"type": "integer", "enum": [1, 2, 3]},
},
"required": ["quote", "issue", "severity"],
"additionalProperties": False,
},
}
quote를 먼저 뽑게 하면 부수 효과가 하나 더 생긴다. 원문에 없는 문장을 인용해야 하는 상황이 되므로, 근거 없는 항목을 지어내기가 어려워진다. 그리고 받은 뒤에 quote in source_text로 코드가 직접 검증할 수 있다. 스키마로 못 막는 것을 스키마 설계로 검증 가능하게 만드는 방식이다.
스키마는 프롬프트의 일부다
마지막으로 하나. 스키마는 컨텍스트에 들어가고 토큰을 쓴다. 필드 40개짜리 스키마를 매 호출에 붙이면 그 자체로 비용이고, 프롬프트 캐시를 쓰지 않으면 매번 다시 낸다. 실제로 쓰는 필드만 남기고, 한 호출에서 절반을 안 쓴다면 스키마 두 개로 나누는 것이 맞다.
그리고 스키마를 바꾸면 출력 분포가 바뀐다. 필드 하나를 추가하거나 순서를 바꾼 것만으로 다른 필드의 값이 달라질 수 있다. 스키마 변경은 프롬프트 변경과 같은 급으로 다뤄야 한다 — 버전을 남기고, 바꿀 때는 기존 평가 세트를 다시 돌린다.
다음 글에서는 이 스키마가 디코딩 단계에서 실제로 어떻게 토큰을 막는지, 그 과정에서 무슨 비용이 드는지를 본다.
지난 글: 구조화 출력: 모델의 답을 프로그램이 쓸 수 있는 형태로
다음 글: 제약 디코딩: 스키마가 토큰을 막는 방식
읽어주셔서 감사합니다. 😊

