AI/Spring AI

팀 프로젝트(Zelo) - LLM 구조화 출력: 엄격 모드 도입 배경

Grogrammer 2026. 10. 24. 14:56
Zelo(제로클릭 커머스)는 채팅 한 줄로 장보기를 끝내는 서비스다. 사용자의 말을 LLM이 정해진 칸의 객체로 정리하고, 서버가 그 값으로 상품을 고르고 금액을 계산한다. 이 글은 LLM의 답을 어떤 방식으로 객체로 받을지 고른 과정과 근거를 기록한 것이다.

 


1. 무엇을 받아야 했나

사용자가 이렇게 말한다.

토요일에 초등학생 둘이랑 넷이 먹을 건데, 김치볶음밥 재료 3만 원 안에서 준비해 줘

서버가 필요한 건 문장이 아니라 칸이 정해진 값이다. 이 값을 MenuPlan이라는 record로 정의했다.

public record MenuPlan(
        MenuPlanIntent intent,                    // SHOPPING_REQUEST / GENERAL_QUESTION
        String menuName,                          // 김치볶음밥
        Integer servings,                         // 4
        Long budget,                              // 30000, 말하지 않았으면 null
        List<MenuPlanIngredient> ingredients,     // [김치 400 G, 밥 800 G, ...]
        List<Keyword> conditions,                 // [NOT_SPICY]
        List<String> unmatchedConditions          // 키워드로 못 나타내는 조건
) { }

이 값은 그대로 견적 계산의 입력이 된다.

servings, ingredients.amount → 상품을 몇 개 살지
ingredients.unit             → 상품 용량으로 나눌 수 있는 단위인지
conditions                   → 어떤 상품을 고를지 (백김치 vs 포기김치)
budget                       → 합계가 예산을 넘는지

즉 칸 하나가 틀리면 결제 금액이 틀린다. 형식이 깨진 답을 "대충 파싱해서" 쓸 수는 없었다.


2. 선택지: LLM에게서 구조화된 값을 받는 세 가지 방법

방법 어떻게 하나 보장되는 것
① 프롬프트로 부탁 메시지 끝에 "이 JSON 스키마대로 답해"라는 글을 붙인다 없음. 모델이 따르려고 노력할 뿐이다
② JSON 모드 요청 옵션 response_format: {type: json_object} 올바른 JSON이라는 것만. 어떤 칸이 있는지는 보장하지 않는다
③ 구조화 출력 엄격 모드 요청 옵션 response_format: {type: json_schema, strict: true} + 스키마 JSON + 스키마의 칸·타입·enum 값까지

비유하면 이렇다.

  • ① 시험지 아래에 "답은 이 양식으로 써 주세요"라고 적어 두는 것. 대부분 지키지만 낙서를 막을 방법이 없다.
  • ② "글씨는 또박또박 써 주세요". 읽을 수는 있지만 답안 양식은 제각각이다.
  • ③ 칸과 보기가 인쇄된 OMR 카드. 정해진 칸, 정해진 보기 말고는 마킹 자체가 안 된다.

Spring AI에서의 차이는 인자 하나

Spring AI 2.0.1에서 ①과 ③은 같은 클래스(MenuPlan.class)로 스키마를 만든다는 점까지 같다. 다른 건 그 스키마를 보내는 곳이다.

// ① 기본: 스키마를 사용자 메시지 끝에 글로 덧붙인다
.responseEntity(MenuPlan.class)

// ③ 엄격 모드: 스키마를 OpenAI 요청 옵션으로 보낸다
.responseEntity(MenuPlan.class, ChatClient.EntityParamSpec::useProviderStructuredOutput)
  • ①은 Spring AI의 변환기(BeanOutputConverter)가 만든 스키마를 "Your response should be in JSON format…"이라는 안내문과 함께 사용자 메시지에 붙인다.
  • ③은 같은 스키마를 response_format으로 보내고, 메시지에는 붙이지 않는다. 

"둘 다 JSON 스키마로 답하라는 건 똑같지 않나?"라는 질문의 답은 이렇다. 요구하는 내용은 같지만, ①은 부탁이고 ③은 강제다.


3. 왜 ③ 엄격 모드인가

3-1. ①로 생길 수 있는 실패가 우리 서비스에서는 치명적이다

①에서 모델이 양식을 어길 수 있는 경우와, 그게 우리 서비스에서 어떤 결과로 이어지는지 정리했다.

①에서 생길 수 있는 일 우리 서비스에서의 결과
JSON 앞뒤에 설명 문장이나 ` `json ` 마크다운을 붙임 파싱 실패 → 사용자는 "다시 말씀해 주세요"
칸을 빠뜨림 (conditions 누락) 조건이 무시된 견적. 아이용인데 매운 김치
키워드를 지어냄 ("MILD", "KID_FRIENDLY") 우리 enum에 없는 값 → 파싱 실패, 또는 매칭 실패
단위를 지어냄 ("KG", "개") 상품 용량으로 나눌 수 없어 수량 계산 불가
숫자를 문자열로 ("4명") 파싱 실패

특히 키워드를 지어내는 문제가 컸다. 조건 키워드는 상품 도메인과 미리 맞춘 10개(NOT_SPICY, SPICY, SOFT, …)뿐이다. 실제로 초기 기획 단계에서는 "초등학생 → MILD, KID_FRIENDLY"처럼 지금 목록에 없는 값을 쓰려 했다. 사람도 헷갈리는 값을 모델이 지어낼 가능성은 충분하다. ③에서는 키워드 칸이 스키마의 enum으로 묶여 있어서, 목록 밖의 값은 OpenAI가 만들 수 없다.

3-2. ②는 반쪽짜리다

JSON 모드는 "JSON 문법이 맞다"는 것만 보장한다. {"메뉴": "김치볶음밥"}처럼 칸 이름이 다르거나 칸이 빠져도 올바른 JSON이다. 위 표의 문제 대부분이 그대로 남는다.

3-3. "실패하면 재시도하면 되지 않나"에 대한 답

①을 쓰고 실패하면 다시 물어보는 방법도 있다. 하지만 이렇게 판단했다.

  • 재시도 1번 = LLM 호출 1번 더 = 사용자 대기 수 초 더. 우리 서비스는 이미 응답 속도가 과제다.
  • 재시도해도 같은 실수를 반복할 수 있다.
  • 처음부터 실패가 생기지 않게 막는 것이 실패 후 복구보다 싸다.

재시도는 버리지 않았다. 엄격 모드로도 막을 수 없는 내용 오류(5장)를 위해 남겨 두었다.

3-4. 선택 기준 요약

기준① 부탁②  JSON 모드 엄격 모드
형식 보장 ✗ △ (문법만) ○
키워드·단위가 목록 안의 값 ✗ ✗ ○ (enum)
파싱 실패로 인한 재시도·대기 잦을 수 있음 잦을 수 있음 사실상 없음
스키마 설계 자유도 ○ ○ △ (제약 있음, 4장)
모델 호환성 모든 모델 대부분 지원 모델만

금액으로 이어지는 값을 받는 곳이라 "형식 보장"과 "목록 안의 값"을 가장 무겁게 봤고, ③을 골랐다.


4. 엄격 모드의 대가와 우리가 처리한 방법

엄격 모드는 공짜가 아니다. 코드가 조금 특이해 보이는 곳은 대부분 이 대가 때문이다.

4-1. 모든 칸이 필수, null 불가 → "예산 없음"을 0으로 받는다

엄격 모드 스키마는 모든 칸이 필수다. "말하지 않은 예산"을 null로 받을 수 없다. 그래서 프롬프트에서 "말하지 않았으면 0"으로 받게 하고, 서버에서 null로 바꾼다.

public MenuPlan {
    // OpenAI 엄격 모드(Structured Outputs)는 모든 칸에 값을 넣게 해서 "예산 없음"을 null로 받을 수 없다.
    // 그래서 프롬프트로 0을 받게 하고 여기서 null로 바꾼다
    if (budget != null && budget == NO_BUDGET) {
        budget = null;
    }
}

이렇게 하면 서버의 나머지 코드는 엄격 모드의 제약을 몰라도 된다. 견적 쪽은 "budget이 null이면 예산 없음"이라는 자연스러운 규칙만 알면 된다.

같은 이유로 장보기가 아닌 말("안녕하세요")에도 모든 칸을 채워야 한다. 프롬프트에 "이때 menuName은 빈 문자열, servings와 budget은 0, 목록은 빈 배열로 둬"라는 규칙을 넣었다.

4-2. 첫 요청이 느릴 수 있다

OpenAI는 새 스키마를 처음 받으면 처리하는 시간이 든다. 같은 스키마를 계속 쓰면 문제가 되지 않는다. 다만 모델 응답 시간을 잴 때는 이 영향을 빼기 위해 조합마다 첫 호출(준비 호출)을 통계에서 제외했다.

4-3. 지원하는 모델과 스키마 기능이 제한된다

모든 모델이 엄격 모드를 지원하지는 않고, 일부 JSON 스키마 기능은 쓸 수 없다. 그래서 MenuPlan은 단순한 타입(문자열, 정수, enum, 목록, 중첩 record)만 쓰게 설계했다. 모델을 바꿀 때 엄격 모드를 지원하는지는 실측 테스트의 준비 호출에서 확인한다(지원하지 않으면 400 → "지원 안 함"으로 표시).

4-4. 정리

대가 처리
null 불가 0 / 빈 문자열 / 빈 배열로 받고, record 생성자에서 null로 변환
첫 요청 지연 측정 시 준비 호출 제외
모델·스키마 제약 단순한 타입만 사용, 모델 교체 시 실측으로 지원 여부 확인

5. 엄격 모드가 막지 못하는 것 → 서버 검증과 2중 방어

엄격 모드가 보장하는 건 모양이다. 내용은 보장하지 않는다.

엄격 모드가 막는 것 (모양)        서버 검증이 막는 것 (내용)
─────────────────────────       ─────────────────────────
칸 누락                          servings = 0, 50
타입 오류 ("4명")                 ingredients = [] (장보기인데 재료 없음)
목록 밖 키워드 ("MILD")           재료 21개 이상
목록 밖 단위 ("KG")               김치를 ML로 (단위는 목록 안이지만 그 재료와 안 맞음)
설명 문장·마크다운                 budget < 0

인원 50명, 김치 500ML는 모양상 문제가 없어서 엄격 모드를 통과한다. 그래서 서버가 한 번 더 본다.

// LLM이 준 값은 믿지 않는다. 장보기 요청이 아니면 재료가 없어도 정상이다
public void validate(Map<String, BaseUnit> ingredientUnits) {
    if (intent == null) throw new BusinessException(ErrorCode.AI_INVALID_RESPONSE);
    if (intent == MenuPlanIntent.GENERAL_QUESTION) return;
    if (!StringUtils.hasText(menuName)) throw ...;
    if (servings == null || servings < 1 || servings > 20) throw ...;
    if (budget != null && budget < 0) throw ...;
    if (ingredients == null || ingredients.isEmpty() || ingredients.size() > 20) throw ...;
    ingredients.forEach(ingredient -> ingredient.validate(ingredientUnits));  // 재료별 단위 일치
}

실패했을 때의 흐름

OpenAI 응답
  ├─ 엄격 모드로도 생긴 파싱 실패(거의 없음) → JacksonException ─┐
  └─ 서버 검증 실패                     → BusinessException ─┤
                                                             ▼
                                        AI_009 "요청을 이해하지 못했어요. 다시 말씀해 주세요."
                                          - 한 번 더 물어본다 (재시도 대상)
                                          - 서킷 브레이커 실패로는 세지 않는다

서킷 브레이커에서 뺀 이유는 코드 리뷰(CodeRabbit)에서 지적받아 반영한 것이다. 검증 실패는 대부분 "30명이 먹을 거야"처럼 사용자의 말 때문에 범위를 벗어난 경우다. 이걸 장애로 세면, 그런 요청이 몇 번 반복될 때 OpenAI는 멀쩡한데도 서킷이 열려 다른 사용자의 채팅까지 막힌다. 엄격 모드 덕분에 형식 오류는 사실상 생기지 않으므로, 이 코드를 통째로 서킷 집계에서 빼도 진짜 장애를 놓칠 위험은 작다고 판단했다. 진짜 OpenAI 장애는 5xx나 시간 초과로 드러나고, 그건 서킷이 그대로 잡는다.


6. 정말 엄격 모드로 가고 있는지 테스트로 고정했다

설정 한 줄(useProviderStructuredOutput)이 빠지면 조용히 ① 부탁 방식으로 돌아간다. 겉으로는 여전히 잘 동작하는 것처럼 보여서 알아채기 어렵다. 그래서 요청이 실제로 엄격 모드로 나가는지를 테스트로 확인한다.

① 가짜 OpenAI 서버로 실제 HTTP 요청 본문 검사

@Test
void 메뉴_계획_요청은_JSON_스키마를_엄격_모드로_보내고_프롬프트_파일을_system으로_보낸다() {
    // when
    llmGateway.planMenu(menuPlanPrompt.systemPrompt(), "초등학생 둘이랑 넷이 김치볶음밥");

    // then
    assertThat(requestBodies).singleElement().satisfies(body -> {
        assertThat(body).contains("\"response_format\"").contains("\"json_schema\"").contains("\"strict\":true");
        // 조건 키워드는 스키마의 enum으로 묶여, 목록 밖의 값은 OpenAI가 만들지 않는다
        for (Keyword keyword : Keyword.values()) {
            assertThat(body).contains("\"" + keyword + "\"");
        }
    });
}

② 스키마가 사용자 메시지에 붙지 않고 요청 옵션으로 가는지

assertThat(prompt.getUserMessage().getText()).isEqualTo("김치볶음밥 재료");   // 안내문이 붙지 않았다
assertThat(prompt.getOptions()).isInstanceOfSatisfying(OpenAiChatOptions.class, options ->
        assertThat(options.getOutputSchema()).contains("\"unmatchedConditions\"").contains("\"NOT_SPICY\""));

회귀 확인: useProviderStructuredOutput을 잠시 빼고 돌리면 이 테스트 2개가 실패하는 것을 확인했다. 누군가 실수로 지워도 CI에서 바로 드러난다.

또 키워드 enum에 값이 추가되면 스키마와 프롬프트의 키워드 목록이 자동으로 함께 바뀐다(둘 다 Keyword.values()에서 만든다). 상품 쪽과 AI 쪽이 키워드 목록을 따로 관리하다 어긋나는 일을 막는다.


7. 검토했지만 택하지 않은 방법들

방법 장점  택하지 않은 이유
① 프롬프트로 부탁 모든 모델에서 동작, 스키마 제약 없음 형식·enum 보장이 없다. 실패하면 재시도로 대기 시간이 는다
② JSON 모드 문법은 보장 칸·값은 보장하지 않는다
함수 호출(Tool)의 인자로 받기 Tool 인자도 strict 스키마를 쓸 수 있다 "함수를 부를지 말지"를 모델이 정하는 구조라, 메뉴 계획처럼 항상 받아야 하는 값에는 어색하다. Tool은 견적 수정처럼 모델의 판단이 필요한 곳에 쓰기로 했다
자유 문장으로 받고 정규식으로 추출 모델 제약 없음 "넷이", "4명", "네 명" 같은 표현을 정규식으로 다 다룰 수 없다. 이걸 대신하려고 LLM을 쓰는 것이다
엄격 모드만 믿고 서버 검증 생략 코드가 줄어든다 모양만 보장된다. 인원 50명, 단위 불일치가 그대로 견적으로 간다

8. 정리

  • LLM의 답이 결제 금액으로 이어지는 값이라, "부탁"이 아니라 "강제" 하는 방식이 필요했다.
  • 같은 MenuPlan.class에서 스키마를 만들지만, 사용자 메시지에 글로 붙이는 방식(부탁) 대신 요청 옵션으로 보내는 엄격 모드(강제) 를 골랐다. 차이는 코드 인자 하나지만 보장 수준이 다르다.
  • 엄격 모드의 대가(null 불가, 첫 요청 지연, 모델 제약)는 record 생성자 변환, 측정 시 준비 호출 제외, 단순한 스키마 설계로 처리했다.
  • 엄격 모드는 모양만 지킨다. 내용은 서버 검증이 지키는 2중 방어로 갔고, 검증 실패는 다시 물어보되 서킷 장애로는 세지 않는다.
  • 이 설정이 조용히 사라지지 않도록 요청 본문을 검사하는 테스트로 고정했다.