AISpring BootAPI Design

구조화 출력은 얼마나 믿을 만한가: 스키마를 줬다고 스키마대로 오지는 않는다

JSON 스키마를 넘기면 끝날 줄 알았는데 아니었습니다. 프롬프트 지시부터 제약 디코딩까지 강제 수준을 네 단계로 나누고, 각 단계에서 무엇이 보장되고 무엇이 안 되는지 정리했습니다.

Srue2026년 9월 24일
구조화 출력은 얼마나 믿을 만한가: 스키마를 줬다고 스키마대로 오지는 않는다

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이 오면 맞는 답처럼 보이는데, 형식과 사실은 다른 층위입니다.


다음 편

출력을 정리하고 나니 청구서가 눈에 들어왔습니다. 같은 기능인데 호출 비용이 예상보다 훨씬 컸고, 줄여보니 모델이 아니라 순서 문제였습니다.

다음 편에서는 프롬프트 캐싱과 비용 설계를 다룹니다.

참고