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"이라고 합니다.
안정성을 위해서는 세 가지 보호 계층이 필요합니다:
- 명확한 스키마를 프롬프트와 함께 보내거나, 더 좋은 방법은 API를 통해 제약으로 보냅니다.
- 엄격한 파싱으로 Java 타입으로 변환합니다.
- 파싱 후 비즈니스 검증을 수행하고, 필요하면 재시도합니다.
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) 호출은 세 가지를 합니다:
OrderRequest에서 JSON Schema를 생성합니다.- 포맷 지침과 스키마를 프롬프트 끝에 추가합니다.
- 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과 오류 피드백을 포함한 재시도는 스키마가 다루지 않는 부분을 처리하는 데 도움이 됩니다.
핵심 원칙: 스키마는 데이터의 형태를 지키고, 검증은 데이터의 의미를 지킵니다. 프로덕션 애플리케이션은 두 계층이 모두 필요하며, 파싱 부분에 대한 단위 테스트와 프롬프트 품질에 대한 별도의 평가 세트가 필요합니다.