Structured output: Spring AI로 LLM이 올바른 JSON 스키마 반환하기

1. 개요

LLM을 애플리케이션에 통합할 때, 보통 긴 텍스트가 필요하지 않습니다. 데이터베이스에 저장하거나, API를 호출하거나, 의사결정을 내리기 위해 구조화된 데이터가 필요합니다. 이 글에서는 Spring AI를 사용하여 Spring Boot 3.x에서 LLM이 올바른 스키마의 JSON을 반환하도록 하는 방법을 설명합니다. 이 글을 읽은 후에는 모델 응답을 Java record로 직접 매핑하고, OpenAI의 JSON Schema native 모드를 활성화하며, 결과를 검증하고, 모델이 잘못된 응답을 줄 때 재시도하고, 파싱 부분에 대한 테스트를 작성할 수 있을 것입니다.

2. “JSON으로 반환해줘”가 충분하지 않은 이유

가장 간단한 방법은 프롬프트에 “JSON으로 답해줘”라는 문장을 추가하는 것입니다. 하지만 이 방법은 여러 가지 방식으로 실패합니다:

  • 모델이 JSON을 markdown fence로 감싸거나 앞에 “다음은 결과입니다:“라는 문장을 추가합니다.
  • 필드 이름이 요청마다 변합니다. 예를 들어 한 번은 quantity, 한 번은 qty라고 합니다.
  • 데이터 타입이 틀립니다. 예를 들어 2 대신 "quantity": "hai"라고 반환합니다.
  • enum 값이 만들어집니다. 예를 들어 "speed": "nhanh lắm"이라고 합니다.

안정성을 위해서는 세 가지 보호 계층이 필요합니다:

  1. 명확한 스키마를 프롬프트와 함께 보내거나, 더 좋은 방법은 API를 통해 제약으로 보냅니다.
  2. 엄격한 파싱으로 Java 타입으로 변환합니다.
  3. 파싱 후 비즈니스 검증을 수행하고, 필요하면 재시도합니다.

3. 설치

이 글에서는 Spring AI 1.0과 OpenAI를 사용합니다. 먼저 BOM과 starter를 추가합니다:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

그 다음 API 키와 모델을 구성합니다:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0

temperature=0은 모델의 “창의성”을 줄입니다. 데이터 추출 작업의 경우 이것이 원하는 동작입니다.

4. ChatClient.entity()로 응답을 record로 매핑하기

예제 문제: 고객 이메일에서 주문 정보를 추출합니다. 대상 데이터 타입을 정의하는 것으로 시작합니다:

public enum DeliverySpeed { STANDARD, EXPRESS }

public record OrderRequest(
        @JsonProperty(required = true)
        @JsonPropertyDescription("고객 이름")
        String customerName,

        @JsonProperty(required = true)
        @JsonPropertyDescription("제품 코드, 형식 SP-xxxx")
        String productCode,

        @JsonProperty(required = true)
        @JsonPropertyDescription("수량, 양의 정수")
        int quantity,

        @JsonProperty(required = true)
        DeliverySpeed speed
) {}

여기 Jackson annotation은 두 가지 역할을 합니다:

  • @JsonPropertyDescription은 JSON Schema에 포함됩니다. 모델이 이 설명을 읽고 각 필드의 의미를 이해합니다.
  • @JsonProperty(required = true)는 스키마에서 필드를 필수로 표시합니다.
  • enum DeliverySpeed는 "enum": ["STANDARD", "EXPRESS"]를 생성하므로 모델은 이 두 값으로만 제한됩니다.

다음은 모델을 호출하는 service입니다:

@Service
public class OrderExtractionService {

    private final ChatClient chatClient;

    public OrderExtractionService(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("당신은 고객 이메일에서 주문 정보를 추출합니다.")
                .build();
    }

    public OrderRequest extract(String email) {
        return chatClient.prompt()
                .user(u -> u.text("Email:\n{email}").param("email", email))
                .call()
                .entity(OrderRequest.class);
    }
}

.entity(OrderRequest.class) 호출은 세 가지를 합니다:

  1. OrderRequest에서 JSON Schema를 생성합니다.
  2. 포맷 지침과 스키마를 프롬프트 끝에 추가합니다.
  3. Jackson으로 응답을 파싱하여 OrderRequest로 변환합니다.

다음 이메일로 시도해봅시다:

Chào shop, mình là Trần Thị Lan. Cho mình đặt 3 cái SP-1024, giao hỏa tốc giúp mình nhé.

받은 결과:

OrderRequest[customerName=Trần Thị Lan, productCode=SP-1024, quantity=3, speed=EXPRESS]

모델이 “hỏa tốc”을 EXPRESS로 이해합니다. enum이 두 값만 허용하기 때문입니다.

4.1. 내부: BeanOutputConverter

.entity()는 내부적으로 BeanOutputConverter를 사용합니다. 이 클래스를 직접 사용하여 정확히 무엇이 전송되는지 볼 수 있습니다:

var converter = new BeanOutputConverter<>(OrderRequest.class);
System.out.println(converter.getJsonSchema());

출력 (줄임):

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "customerName" : { "type" : "string", "description" : "고객 이름" },
    "productCode" : { "type" : "string", "description" : "제품 코드, 형식 SP-xxxx" },
    "quantity" : { "type" : "integer", "description" : "수량, 양의 정수" },
    "speed" : { "type" : "string", "enum" : [ "STANDARD", "EXPRESS" ] }
  },
  "required" : [ "customerName", "productCode", "quantity", "speed" ],
  "additionalProperties" : false
}

getFormat() 메서드는 프롬프트에 추가될 지침 문자열을 반환합니다. 이 섹션은 모델이 RFC 8259에 따라 JSON만 반환하도록 요청하며, 설명을 포함하지 않고, markdown으로 감싸지 않으며, 위의 스키마를 첨부합니다. 파싱할 때 convert()는 모델이 여전히 markdown fence를 추가하려고 하면 자동으로 제거합니다.

4.2. Generic 타입: 주문 목록

하나의 이메일에 여러 주문이 포함된 경우 ParameterizedTypeReference를 사용합니다:

public List<OrderRequest> extractAll(String email) {
    return chatClient.prompt()
            .user(u -> u.text("이메일의 모든 주문을 나열합니다:\n{email}").param("email", email))
            .call()
            .entity(new ParameterizedTypeReference<List<OrderRequest>>() {});
}

List.class를 전달하면 작동하지 않습니다. generic 정보는 runtime에 삭제되므로 Spring AI가 내부 요소의 타입을 알 수 없습니다.

5. OpenAI의 Structured output native

4장의 방법은 단순한 요청일 뿐입니다. 모델은 여전히 무시할 수 있습니다. 새로운 OpenAI 모델은 strict 모드에서 json_schema 타입의 response_format을 지원합니다. 이 모드에서는 토큰 생성 프로세스가 스키마에 의해 제약되므로 반환된 JSON은 항상 구문적으로 유효하고 구조적으로 올바릅니다.

public OrderRequest extractStrict(String email) {
    var converter = new BeanOutputConverter<>(OrderRequest.class);

    var options = OpenAiChatOptions.builder()
            .responseFormat(new ResponseFormat(
                    ResponseFormat.Type.JSON_SCHEMA, converter.getJsonSchema()))
            .build();

    String json = chatClient.prompt()
            .user(u -> u.text("Email:\n{email}").param("email", email))
            .options(options)
            .call()
            .content();

    return converter.convert(json);
}

이 코드는 다음과 같이 작동합니다:

  • 여전히 BeanOutputConverter를 사용하지만 스키마를 얻거나(getJsonSchema()) 결과를 파싱(convert())하기 위해서만 사용합니다.
  • 스키마는 프롬프트에 삽입하는 대신 API 매개변수를 통해 전송됩니다.
  • .content()는 원본 JSON 문자열을 반환하고, 그 다음 직접 convert합니다.

참고: OpenAI의 strict 모드에는 자체 제한이 있습니다. 모든 필드는 required 내에 있어야 하고, 객체는 additionalProperties: false를 가져야 합니다. pattern 또는 minimum 같은 일부 키워드는 모델에 따라 지원되지 않을 수 있습니다. 필드가 선택사항인 경우 required에서 제외하는 대신 null을 허용하는 타입을 선언합니다.

Native structured output은 구조를 보장하지만 올바른 내용은 보장하지 않습니다. 모델은 여전히 quantity: 0을 반환하거나 productCode를 만들어낼 수 있습니다. 따라서 다음 섹션이 여전히 필요합니다.

6. 검증과 재시도

record에 Bean Validation 제약을 추가합니다:

public record OrderRequest(
        @JsonProperty(required = true) @NotBlank
        String customerName,

        @JsonProperty(required = true) @Pattern(regexp = "SP-\\d{4}")
        String productCode,

        @JsonProperty(required = true) @Positive
        int quantity,

        @JsonProperty(required = true) @NotNull
        DeliverySpeed speed
) {}

그 다음 결과를 검증합니다. 데이터가 잘못된 경우 오류를 모델에 다시 보내고 수정하도록 요청합니다:

@Service
public class ValidatedOrderExtractor {

    private static final int MAX_ATTEMPTS = 3;

    private final ChatClient chatClient;
    private final Validator validator;

    public ValidatedOrderExtractor(ChatClient.Builder builder, Validator validator) {
        this.chatClient = builder.build();
        this.validator = validator;
    }

    public OrderRequest extract(String email) {
        String feedback = "";
        for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
            String prompt = "이메일에서 주문을 추출합니다:\n" + email + feedback;
            try {
                OrderRequest order = chatClient.prompt().user(prompt).call().entity(OrderRequest.class);
                Set<ConstraintViolation<OrderRequest>> violations = validator.validate(order);
                if (violations.isEmpty()) {
                    return order;
                }
                feedback = "\n\n이전에 잘못된 데이터를 반환했습니다: " + describe(violations) + ". 수정해주세요.";
            } catch (RuntimeException e) {
                feedback = "\n\n이전 응답을 파싱할 수 없었습니다: " + e.getMessage();
            }
        }
        throw new IllegalStateException(MAX_ATTEMPTS + "번 시도 후에도 주문을 추출할 수 없습니다");
    }

    private String describe(Set<ConstraintViolation<OrderRequest>> violations) {
        return violations.stream()
                .map(v -> v.getPropertyPath() + " " + v.getMessage())
                .collect(Collectors.joining("; "));
    }
}

주목할 점:

  • validator.validate(order)는 스키마가 완전히 표현하지 못하는 비즈니스 제약을 확인합니다. 예를 들어 상품 코드의 regex입니다.
  • quantity must be greater than 0과 같은 오류 메시지는 다음 프롬프트에 추가됩니다. 모델이 어디가 잘못되었는지 알 때 오류를 더 잘 수정합니다.
  • catch 블록은 손상된 JSON의 경우를 처리합니다. 이 오류는 strict 모드에서는 드물지만 다른 모델에서 발생할 수 있습니다.
  • 비용을 낭비하는 무한 루프를 피하기 위해 시도 횟수를 제한합니다.

참고: 이메일에 실제로 수량이 포함되어 있지 않으면 재시도는 모델이 검증을 통과하도록 숫자를 만들어내도록 할 뿐입니다. 이 경우 필드가 null을 허용하도록 하고 비즈니스 계층에서 처리해야 합니다. 예를 들어 고객에게 다시 묻는 것입니다.

7. 테스트 작성

단위 테스트에서 실제 LLM을 호출하면 안 됩니다. 느리고 비용이 들며 결과가 불안정하기 때문입니다. 테스트해야 할 부분은 확정적인 로직입니다: 스키마가 올바르게 생성되고, 파싱이 올바르고, 검증이 올바른지 확인합니다.

class OrderRequestConversionTest {

    private final BeanOutputConverter<OrderRequest> converter =
            new BeanOutputConverter<>(OrderRequest.class);

    private final Validator validator =
            Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void schemaContainsAllFieldsAndEnumValues() {
        String schema = converter.getJsonSchema();

        assertThat(schema)
                .contains("\"customerName\"", "\"productCode\"", "\"quantity\"")
                .contains("\"STANDARD\"", "\"EXPRESS\"");
    }

    @Test
    void convertsValidJsonToRecord() {
        String json = """
                {"customerName":"Trần Thị Lan","productCode":"SP-1024","quantity":3,"speed":"EXPRESS"}
                """;

        OrderRequest order = converter.convert(json);

        assertThat(order.quantity()).isEqualTo(3);
        assertThat(order.speed()).isEqualTo(DeliverySpeed.EXPRESS);
        assertThat(validator.validate(order)).isEmpty();
    }

    @Test
    void flagsInvalidBusinessValues() {
        String json = """
                {"customerName":"Lan","productCode":"ABC","quantity":0,"speed":"STANDARD"}
                """;

        OrderRequest order = converter.convert(json);

        assertThat(validator.validate(order))
                .extracting(v -> v.getPropertyPath().toString())
                .containsExactlyInAnyOrder("productCode", "quantity");
    }
}

세 번째 테스트는 책임의 경계를 명확하게 보여줍니다. 이 JSON은 구조적으로 올바르므로 파싱은 성공하지만 비즈니스 측면에서는 여전히 잘못되었습니다. Bean Validation은 두 필드 오류를 모두 포착합니다.

실제 모델로 프롬프트 품질을 확인하려면 별도의 integration test 세트로 분리합니다. 이 테스트 세트는 일정에 따라 또는 수동으로 실행되며, 샘플 이메일 세트를 사용하고 올바른 추출의 비율을 측정합니다.

8. 일반적인 오류 및 사용하지 말아야 할 때

일반적인 오류 몇 가지:

  • 지침을 잘 따르지 못하는 모델에서 .entity()를 사용합니다. 로컬에서 실행되는 작은 모델은 종종 필드가 누락된 JSON을 반환합니다. 이 경우 JSON mode를 사용하거나 런타임의 grammar 기능을 사용합니다(사용 가능한 경우).
  • 스키마가 너무 깊거나 너무 넓습니다. 수십 개의 중첩 필드는 오류율을 증가시키고 토큰을 소비합니다. 여러 개의 작은 호출로 나누는 것이 좋습니다.
  • @JsonPropertyDescription이 누락되었습니다. code 또는 type과 같은 필드 이름은 모델에 매우 모호합니다. 한 줄의 설명이 정확도를 크게 개선합니다.
  • strict 모드를 완전히 신뢰합니다. Strict는 JSON의 형태만 보장합니다. 만들어진 데이터를 방지하지 않습니다.
  • temperature을 높게 유지합니다. 추출 결과는 실행 간에 변동합니다.
  • 사용자 데이터를 명확하게 분리하지 않고 프롬프트에 넣습니다. 이메일은 “이전 지침 무시”와 같은 문장을 포함할 수 있습니다. system prompt를 분리하고 항상 출력을 검증합니다.

structured output을 사용하지 말아야 할 경우:

  • 출력이 본질적으로 자유형 텍스트인 경우(예: 요약 또는 채팅 응답). 텍스트를 JSON으로 강제하면 품질이 떨어집니다.
  • 데이터를 regex 또는 일반 파서로 추출할 수 있는 경우. 그 방법이 더 저렴하고, 빠르고, 확정적입니다.
  • 인간의 확인 단계 없이 절대 정확도가 필요한 경우(예: 거래 금액).

9. 결론

이 글은 Spring Boot에서 LLM이 올바른 스키마의 JSON을 반환하도록 하는 방법을 설명했습니다:

  • ChatClient.entity()와 record, enum은 응답을 Java 타입으로 매핑하는 데 단 한 줄의 코드만 필요합니다.
  • BeanOutputConverter는 생성되는 스키마를 보고 제어할 수 있게 합니다.
  • OpenAI의 ResponseFormat.Type.JSON_SCHEMA는 모델이 토큰을 생성할 때부터 구조를 보장합니다.
  • Bean Validation과 오류 피드백을 포함한 재시도는 스키마가 다루지 않는 부분을 처리하는 데 도움이 됩니다.

핵심 원칙: 스키마는 데이터의 형태를 지키고, 검증은 데이터의 의미를 지킵니다. 프로덕션 애플리케이션은 두 계층이 모두 필요하며, 파싱 부분에 대한 단위 테스트와 프롬프트 품질에 대한 별도의 평가 세트가 필요합니다.

llmspring-aijson-schemaspring-bootjava