분석: 언어 모델에서 «기계가 읽을 수 있는» 출력 받기 — «JSON 으로 줘»는 사양이 아니다
모델을 파이프라인 안에 넣는 제품은 결국 안정적인 구조화 출력을 필요로 한다. 실패 유형은 잘 알려져 있고 해법은 화려하지 않다. 실무 안내.
한 줄 답
언어 모델에서 신뢰할 수 있는 구조화 데이터를 어떻게 받나?
플랫폼이 제공하는 제약 디코딩(스키마 강제 모드)이 있으면 그것부터 켠다 — 형식 오류를 «드문 일»이 아니라 «불가능한 일»로 만들기 때문이다. 그다음 스키마를 «모델이 읽을 것»으로 설계한다: 평평하게, 필수 필드는 적게, 값 공간이 닫혀 있으면 enum 으로, «모름»을 말할 수 있는 자리를 명시적으로 두고, 계산이 필요한 필드는 넣지 않는다. 모든 응답을 스키마로 검증하고, «모양»이 아니라 «값»이 맞는가는 검증이 풀어 주지 않는 별개 문제로 다룬다.
핵심 요약
- 스키마 제약 디코딩은 «문법»을 풀지 «의미»를 풀지 않는다. 완벽하게 유효한 JSON 이면서 완전히 틀린 응답이 가능하다.
- 스키마 설계가 곧 프롬프트 설계다. 필드 이름·enum 값·설명은 전부 모델이 읽는 지시문이다.
- «없음»·«모름»을 말할 정당한 통로를 준다. 통로가 없으면 필수 필드는 «값을 지어내라»는 초대장이 된다.
- 합계·비율·파생 날짜를 모델에게 계산시키지 않는다. 입력만 추출하고 계산은 코드에서 한다.
- 검증하고, 실패하면 «그 검증 오류를 붙여» 한 번만 다시 묻는다. 겨냥한 수리 1턴이 재시도 루프보다 싸게 대부분을 고친다.
언어 모델이 «사람에게» 쓰기를 멈추고 «프로그램에게» 쓰기 시작하는 순간, 다른 요구사항이 도착한다. 산문은 «대충 맞아도» 된다. total_amount 라는 필드는 그럴 수 없다.
대부분의 팀이 같은 순서로 이것을 알게 된다. 프롬프트에 JSON 으로 달라고 쓴다. 된다. 출시한다. 일주일 뒤 파서가 터진다 — 모델이 객체를 코드 펜스로 감쌌거나, 앞에 친절한 문장을 한 줄 붙였거나, 뒤에 쉼표를 하나 더 찍었기 때문이다. 정리 함수를 붙인다. 된다. 한 달 뒤, null 이어야 했던 필드에 그럴듯하게 지어낸 값이 들어 있고 아무것도 터지지 않는다. 이쪽이 훨씬 나쁘다.
이 두 실패는 종류가 다르고, 둘을 뭉뚱그리는 것이 이 문제가 오래 걸리는 이유다.
이름 하나를 쓰는 문제 «둘»
문법적 실패는 파싱이 안 되거나 스키마에 안 맞는 출력이다. 시끄럽고, 즉시 드러나고, 완전히 풀 수 있다.
의미적 실패는 완벽하게 파싱되면서 틀린 말을 하는 출력이다. 문서에 없던 자신 있는 날짜. enum 에는 맞지만 내용에는 안 맞는 분류. 항목 합과 어긋나는 총액.
제약 디코딩 — 이제 주요 플랫폼이 모두 제공하는 스키마 강제 모드 — 은 첫째 범주를 거의 완전히 없앤다. 각 생성 단계에서 허용되는 토큰을 제한해 문법 밖 출력이 «나올 수 없게» 만드는 방식이다. 모델이 대체로 따라 주는 «권유»에 불과했던 프롬프트 요청과는 진짜 단계 차이이고, 그 차이는 꼬리에서 보인다 — 천 번에 한 번 실패하던 그 요청이 누군가를 호출하는 그 요청이다.
둘째 범주에는 아무 일도 하지 않는다. 그리고 이것이 심리적으로 착지하는 방식에 진짜 위험이 있다. 스키마 강제를 도입한 팀은 «플랫폼이 스키마를 보장하니까» 검증을 그만두는 일이 잦다. 보장은 모양에 대한 것이다. 남은 질문은 전부 여전히 우리 것이다 — 이게 맞는 값인가, 이 필드들이 서로 일치하는가, 이 날짜가 그럴듯한 범위 안인가.
스키마는 «프롬프트의 일부»다
가장 쓸모 있는 재구성이고, 흔히 놓친다. 모델은 스키마를 읽는다. 필드 이름, enum 값, 설명, 순서가 전부 지시문이고, 시스템 프롬프트와 영향력을 두고 경쟁한다.
즉 스키마 설계 선택에는 품질 결과가 따른다.
사람에게 설명하듯 필드 이름을 짓는다. flag2 보다 is_urgent 가 낫다. 필드에 붙인 설명 — "고객이 48시간 이내 기한을 «명시했을 때만» true" — 은 같은 문장을 시스템 프롬프트에 두는 것보다 효과적일 때가 많다. 판단이 일어나는 자리 바로 옆에 있기 때문이다.
값 공간이 닫혀 있으면 자유 문자열 대신 enum 을 쓴다. 자유 텍스트 category 필드는 뒷단 코드가 영원히 정규화해야 하는 유의어 꼬리를 만든다. enum 은 그 판단을 생성 시점에 끝낸다.
평평하게 유지한다. 깊게 중첩된 객체는 구조적 오류 확률과, 한 가지가 잘못됐을 때의 파장을 함께 키운다. 도메인이 정말 계층적이라면 깊은 응답 하나보다 «두 번 호출»이 더 안정적인 경우가 많고, 어느 단계가 실패했는지 보이므로 평가도 쉽다.
결론보다 근거가 «먼저» 오도록 필드 순서를 잡는다. reasoning 이나 evidence_quote 필드가 classification 보다 앞에 오는 스키마는 반대 순서보다 측정 가능하게 나은 분류를 낸다 — 모델은 왼쪽에서 오른쪽으로 생성하고, 앞 필드가 뒤 필드를 조건 짓기 때문이다. 구조화 응답 «안»에서 생성된 산문이 제 값을 하는 유일한 자리다.
모델에게 «빠져나갈 문»을 준다
값을 지어내는 가장 흔한 원인은, 지어내는 것 말고 다른 정당한 선택지를 주지 않은 스키마다.
contract_end_date 가 null 을 허용하지 않는 필수 문자열인데 계약서에 종료일이 없다면, 모델은 무언가를 내놓아야 한다. 그럴듯한 것을 내놓을 것이다. 파이프라인의 어느 것도 이의를 제기하지 않는다.
해법은 «없음»을 표현할 수 있게 만드는 것이다. nullable 필드, 명시적인 not_stated enum 항목, 혹은 값을 게이트하는 별도 불리언. 여기에 «없다고 답하는 것은 받아들여지고 기대되는 답»이라는 지시를 붙이면, 지어낸 필드 값의 상당 부분이 사라진다. 스키마 한 줄 값이다.
같은 논리가 확신도로 이어진다. high·medium·low 세 값짜리 confidence enum 이 숫자 점수보다 유용하다 — 모델은 숫자 자기평가에 잘 보정돼 있지 않지만 거친 구간에는 꽤 일관적이다. 낮은 구간을 사람에게 보내면 «정확도 문제»가 «처리량 문제»로 바뀐다. 훨씬 나은 문제다.
모델에게 산수를 시키지 않는다
subtotal·tax·total 이 함께 들어 있는 스키마는, 하나가 나머지에서 유도되는 값 셋을 요구하고 있다. 모델은 셋 다 채울 것이고, 가끔 서로 안 맞을 것이다.
입력만 추출하고, 파생 값은 코드에서 계산한다. 날짜 계산·비율·개수·단위 변환도 마찬가지다. 모델의 능력에 대한 진술이 아니다 — 결정적 계산은 오류율이 0 이고 생성된 계산은 아니며, 앞엣것이 있는데 뒤엣것을 받아들일 이유가 없다는 얘기다.
검증, 그리고 «수리 1턴»
스키마를 강제하는 엔드포인트에서 온 것을 포함해 모든 응답을 검증한다. 검증이 실패하면 가장 효과적인 대응은 맹목적 재시도가 아니라 구체적 오류를 담은 수리 1회다. "priority 필드는 low·medium·high 중 하나여야 하는데 'urgent' 를 반환했습니다" 를 되돌려 주면 잔여 실패의 대다수가 한 턴에 해결된다.
1회로 묶는다. 적대적이거나 진짜로 모호한 입력에 대한 무한 재시도는 보이는 실패를 보이지 않는 비용으로 바꾸고, 정작 필요한 신호 — «이 입력은 우리가 설계한 스키마에 안 맞는다» — 를 억누른다.
재시도 너머에는 결정적 폴백을 둔다. 검토 표시를 단 기본 레코드, 규칙 기반 경로, 혹은 사람에게 가는 큐. 실패 모드가 «다시 해 보기» 하나뿐인 파이프라인은 실패 모드가 «없는» 것이고, 그건 기능이 아니라 설계 결함이다.
구조화 출력의 평가
구조화 출력은 평가하기가 유난히 쾌적하고, 팀은 그것을 활용해야 한다.
기계가 읽는 출력이므로 대부분의 검사가 «정확»하다 — 스키마 유효성, enum 소속, 필수 필드 존재, 필드 간 정합성, 라벨셋 대비 필드 단위 정확도. 추적할 지표는 «필드 단위» 정확도이지 레코드 단위가 아니다. 레코드를 한 덩어리로 채점하면 어느 필드가 나빠지는지 감춰지는데, 대개 «한 필드»이지 추출 전체가 아니다.
null 비율도 추적한다. 프롬프트나 모델을 바꾼 뒤 null 이 급감했다면, 예전에는 물러서던 자리에서 값을 지어내기 시작했다는 뜻인 경우가 많다. 유효한 레코드만 보는 검사에는 전혀 안 보인다.
화려하지 않은 요약
여기 영리한 기법은 없다. 제약 디코딩을 켠다. 모델이 읽을 것을 전제로 스키마를 설계한다 — 실제로 읽으니까. «모른다»고 말할 수 있게 한다. 산수는 코드에 둔다. 그래도 검증한다. 한 번 수리하고, 그다음엔 폴백한다.
전부 값싼 것들이고, 합치면 모델이 «우리 시스템의 부품»인 상태와 «그 안의 간헐적 놀람 발생원»인 상태의 차이가 된다.
자주 묻는 질문
- 제약 디코딩과 «프롬프트에 JSON 으로 달라고 쓰기»는 같은 것인가?
- 아니다. 프롬프트 요청은 모델이 대체로 따르는 «권유»다. 제약 디코딩은 각 생성 단계에서 나올 수 있는 토큰을 제한해 문법 밖의 출력을 «불가능»하게 만든다. 차이는 꼬리에서 드러나고, 프로덕션 장애는 그 꼬리에 산다.
- 필수로 표시한 필드에 모델이 왜 값을 지어내나?
- 빈 값이 허용되지 않는 필수 필드는 «무언가를 내놓으라»는 지시이기 때문이다. 필드를 nullable 로 만들거나 `not_found` 같은 enum 항목을 추가하면 지어낸 값은 대개 멈춘다.
- 깊게 중첩된 스키마를 써도 되나?
- 평평한 쪽을 권한다. 깊은 중첩은 구조적 실수 확률과 부분 실패의 파장을 함께 키운다. 도메인이 정말 계층적이라면, 깊게 중첩된 응답 하나보다 «두 번 호출»이 낫다.
- 재시도는 어떻게 해야 하나?
- 구체적 검증 오류를 담은 «수리 1회», 그다음은 결정적 경로나 사람으로 폴백한다. 무한 재시도는 나쁜 응답을 나쁜 청구서로 바꾸고 근본 문제를 감춘다.
- API 가 스키마를 보장하면 검증은 필요 없나?
- 필요하다. 보장은 «모양»에 대한 것이지 «값»에 대한 것이 아니다. enum 소속·필드 간 정합성·범위·참조 무결성은 여전히 우리 몫이다.
출처
- JSON Schema specification — JSON Schema
- Model Context Protocol — specification — Model Context Protocol
- OpenAPI Specification — OpenAPI Initiative