구조화 출력은 얼마나 믿을 만한가: 스키마를 줬다고 스키마대로 오지는 않는다
JSON 스키마를 넘기면 끝날 줄 알았는데 아니었습니다. 프롬프트 지시부터 제약 디코딩까지 강제 수준을 네 단계로 나누고, 각 단계에서 무엇이 보장되고 무엇이 안 되는지 정리했습니다.

TL;DR
- 구조화 출력에는 강제 수준이 네 단계 있고, 단계마다 보장하는 게 다릅니다.
- 가장 흔한 오해: "스키마를 줬으니 타입이 맞을 것" → 형식은 맞아도 값이 틀립니다.
- 중첩이 깊어질수록 정확도가 떨어집니다. 평평하게 만드는 게 가장 효과가 컸습니다.
- 무엇을 쓰든 파싱 실패 복구는 남습니다. 그 경로를 안 만들면 언젠가 500이 납니다.
회귀 테스트를 붙이면서 판정을 자동화하다 보니, 출력이 정해진 형식으로 나오면 검사가 훨씬 쉬워진다는 걸 알았습니다. 문자열을 뒤지는 대신 필드를 읽으면 되니까요.
그래서 응답을 전부 JSON으로 바꿨습니다. 스키마를 정의하고, Spring AI의 구조화 출력 바인딩에 넘기고, DTO로 받았습니다.
대부분 잘 됐습니다. 그리고 가끔 안 됐습니다. 그 "가끔"이 문제였습니다.
강제 수준은 네 단계다
같은 "구조화 출력"이라도 구현 방식에 따라 보장 수준이 전혀 다릅니다.
| 단계 | 방식 | 보장하는 것 | 보장 못 하는 것 |
|---|---|---|---|
| 1. 프롬프트 지시 | "JSON으로 답해줘" | 없음 | 전부 |
| 2. 함수 호출 | 도구 인자 스키마 | 인자 구조 | 값의 타당성 |
| 3. 스키마 강제 | response_format | 타입 · 필수 필드 | 값의 타당성 |
| 4. 제약 디코딩 | 문법 기반 토큰 제한 | 문법적으로 위반 불가 | 값의 타당성 |
네 단계 모두 "값이 맞다"는 보장은 안 합니다. 이게 제가 가장 늦게 이해한 부분입니다.
{"orderNo": "20260924-0032", "status": "SHIPPED"} 는 스키마를 완벽히 지킵니다. 그런데 그 주문이 실제로는 취소 상태라면 형식만 맞고 내용은 틀린 것입니다. 스키마는 이걸 못 잡습니다.
1단계가 실패하는 방식
프롬프트로만 지시하면 이런 게 나옵니다.
설명 문장이 앞에 붙음 알겠습니다. 아래와 같습니다: {"orderNo": ...}
코드펜스로 감쌈 ```json\n{...}\n```
후행 쉼표 {"a": 1, "b": 2,}
주석 {"a": 1 /* 추정값 */}
따옴표 없는 키 {orderNo: "..."}
숫자를 문자열로 {"amount": "12,000"}각각에 대응하는 전처리를 붙이다 보면 파서를 다시 만들고 있게 됩니다. 저도 한동안 그랬습니다. 코드펜스 벗기고, 첫 {부터 마지막 }까지 자르고, 후행 쉼표 지우고…
2단계 이상을 쓸 수 있으면 그 코드는 다 버려도 됩니다. 저는 이걸 늦게 깨달았습니다.
중첩이 깊어질수록 나빠진다
스키마 강제를 켜도 정확도가 균일하지 않았습니다. 어떤 스키마는 잘 나오고 어떤 건 자주 틀렸습니다.
차이는 구조의 깊이였습니다.
❌ 깊은 구조
{
"order": {
"customer": { "address": { "zipCode": "...", "detail": "..." } },
"items": [ { "options": [ { "name": "...", "value": "..." } ] } ]
}
}
✅ 평평한 구조
{
"orderNo": "...",
"customerZipCode": "...",
"customerAddressDetail": "...",
"itemNames": ["..."],
"itemOptionSummary": "..."
}배열 안의 객체 안의 배열까지 가면, 형식은 맞는데 엉뚱한 자리에 값이 들어가는 일이 생겼습니다.
그래서 규칙을 세웠습니다.
| 규칙 | 이유 |
|---|---|
| 깊이 2단계 이하 | 3단계부터 오배치가 늘어남 |
| 배열 안 객체는 필드 3개 이하 | 많으면 정렬이 흐트러짐 |
| 열거형은 값을 스키마에 명시 | 자유 문자열이면 오타·변형이 생김 |
| 선택 필드를 줄임 | "넣을까 말까"를 판단시키지 않음 |
| 필드명은 서술적으로 | val1 ❌ → customerZipCode ✅ |
마지막이 의외로 효과가 컸습니다. 필드명 자체가 지시문 역할을 합니다. date보다 orderPlacedDateIso8601이 훨씬 정확하게 채워졌습니다.
값의 타당성은 따로 검증해야 한다
형식이 맞아도 값은 틀릴 수 있으므로, 받은 뒤에 한 겹 더 봅니다.
public OrderIntent parse(String raw) {
OrderIntent intent = objectMapper.readValue(raw, OrderIntent.class); // ① 형식
validator.validate(intent); // ② 제약
return reconcile(intent); // ③ 사실 대조
}
private OrderIntent reconcile(OrderIntent intent) {
// 모델이 "지어낸" 주문번호인지 실제 DB로 확인한다.
// 형식이 맞는 주문번호를 만들어내는 건 아주 쉽다.
if (!orderRepository.existsByOrderNo(intent.orderNo())) {
throw new UnknownEntityException(intent.orderNo());
}
return intent;
}③이 핵심입니다. 주문번호 형식(yyyyMMdd-nnnn)을 지키면서 존재하지 않는 번호를 만들어내는 건 모델에게 아주 쉬운 일입니다. 스키마는 이걸 절대 못 잡습니다.
식별자를 다루는 필드는 전부 실제 존재 여부를 대조하도록 바꿨습니다. ProblemDetail로 에러 응답을 표준화해둔 덕에 이 실패를 클라이언트에 전달하는 형식은 이미 있었습니다.
실패 복구 경로는 반드시 남는다
어떤 강제 수준을 쓰든 실패는 납니다. 모델이 이상해서가 아니라 네트워크가 끊기거나 응답이 잘리거나 타임아웃이 나서입니다.
복구 순서를 이렇게 뒀습니다.
| 순서 | 대응 | 비용 |
|---|---|---|
| 1 | 응답이 잘렸는지 확인 (finish_reason) | 0 |
| 2 | 잘렸으면 최대 토큰을 늘려 1회 재시도 | 낮음 |
| 3 | 파싱 실패면 오류 내용을 붙여 1회 재시도 | 낮음 |
| 4 | 그래도 실패면 평문 응답으로 폴백 | 없음 |
| 5 | 전 과정 로그 기록 | 0 |
4번을 만들어 두는 게 중요합니다. 구조화에 실패했다고 사용자에게 500을 주는 것보다, 형식 없는 답변이라도 주는 편이 낫습니다. 내부적으로는 실패로 기록하고요.
3번의 "오류 내용을 붙여"가 효과적이었습니다. Unexpected token at position 412 같은 파서 메시지를 그대로 돌려주면 두 번째 시도에서 대부분 고쳐졌습니다. EP.02에서 정리한 실패 유형별 대응과 같은 원리입니다 — 무엇이 틀렸는지 알려주고 한 번만 재시도.
정리
| 질문 | 답 |
|---|---|
| 스키마를 주면 타입이 맞나 | 3단계 이상이면 맞습니다 |
| 스키마를 주면 값이 맞나 | 아니요. 별도 대조가 필요합니다 |
| 중첩은 몇 단계까지 | 2단계. 3단계부터 오배치 |
| 필드명은 어떻게 | 서술적으로. 필드명이 지시문 역할 |
| 실패하면 | 잘림 확인 → 재시도 1회 → 평문 폴백 |
가장 하고 싶은 말은 이겁니다. 구조화 출력은 파싱 문제를 해결하지, 신뢰 문제를 해결하지 않습니다. 깔끔한 JSON이 오면 맞는 답처럼 보이는데, 형식과 사실은 다른 층위입니다.
다음 편
출력을 정리하고 나니 청구서가 눈에 들어왔습니다. 같은 기능인데 호출 비용이 예상보다 훨씬 컸고, 줄여보니 모델이 아니라 순서 문제였습니다.
다음 편에서는 프롬프트 캐싱과 비용 설계를 다룹니다.