Spring AI 구조화 출력
Spring AI 구조화 출력 (Structured Output): 모델 결과를 Java 타입으로
대형 언어 모델은 기본적으로 텍스트를 넣고 텍스트를 받는 시스템이에요. 그런데 실제 코드에서는 결과의 특정 필드로 분기하고, 값을 저장하고, 결과에 따라 로직을 나눠야 하는 경우가 많죠. 그럴 때마다 그 텍스트는 타입이 있는 레코드(typed record)로 바뀌어야 합니다. 구조화 출력(Structured Output) 이 그 간극을 메워줘요. 모델이 스키마를 따르는 텍스트를 만들도록 유도하고, 애플리케이션이 그 텍스트를 나머지 코드에서 일반 도메인 타입처럼 다룰 수 있는 타입 객체로 다시 파싱하는 거죠.
Spring AI는 구조화 출력을 ChatClient 플루언트 API의 .entity(...) 에 직접 노출합니다. 원하는 형태에 대한 Java 타입을 정의하면 나머지는 Spring AI가 처리해요. 타입에서 JSON 스키마가 생성되고, 모델이 그 스키마를 지키도록 지시받으며, 응답이 다시 타입으로 역직렬화됩니다.
이 페이지는 고수준 ChatClient 경로를 다룹니다. 신뢰성 스위치와 저수준 API가 궁금하다면 다음을 참고하세요:
- Schema Validation & Self-Correction —
validateSchema()로 잘못된 출력을 감지하고 자동 재시도. - Provider-Native Structured Output —
useProviderStructuredOutput()으로 프로바이더 API 레벨에서 스키마 강제. - Output Converters — 저수준
StructuredOutputConverterAPI, 내장 컨버터, 커스텀/비-JSON 컨버터.
타입 응답 (Typed Response)
원하는 형태에 대한 Java record를 정의합니다:
record ActorsFilms(String actor, List<String> movies) {}
ChatClient에게 그것을 채우라고 요청하세요. 원시 텍스트 답을 반환하는 .content() 대신 .entity(...) 로 호출을 마무리하고 대상 타입을 넘기면 됩니다:
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class);
결과는 나머지 코드에 그대로 넘길 수 있는 타입 있는 ActorsFilms예요:
films.actor(); // "Tom Hanks"
films.movies(); // ["Forrest Gump", "Cast Away", ...]
내부적으로 Spring AI는 세 가지를 수행했어요. 스키마 생성기가 ActorsFilms record를 JSON 스키마로 바꾸고, 그 스키마가 프롬프트의 시스템 컨텍스트에 추가되며, 모델의 JSON 답이 그 record로 파싱하는 타입 컨버터로 전달됐죠.
이 방식은 Spring AI가 지원하는 모든 모델에서 동작합니다. 프로바이더 특유의 것이 아니에요.
.entity(...)는.call()전용입니다. 타입 파싱은 완전한 응답이 필요하므로 스트리밍 경로에서는 사용할 수 없어요 (.stream()은 타입 객체가 아니라 텍스트 청크를 반환합니다). 이 페이지의 모든 변형(Class, ParameterizedTypeReference, 커스텀 컨버터, 신뢰성 스위치 있든 없든)에 동일하게 적용됩니다.
기본 .entity(...) 호출에는 보장이 없어요. 모델은 스키마에 맞는 JSON을 만들도록 요청받을 뿐 강제되진 않습니다. 대부분은 따르지만, 가끔 여분의 필드를 추가하거나 필수 필드를 빠뜨리거나 JSON을 산문으로 감싸서 파서가 예외를 던질 때가 있어요. 아래 두 스위치가 이 문제를 다룹니다.
제네릭 타입: 리스트, 맵, 그 너머
.entity(Class) 는 구체 클래스용이에요. List<ActorsFilms>, Map<String, ActorsFilms> 같은 제네릭 타입은 ParameterizedTypeReference를 사용합니다:
List<ActorsFilms> films = chatClient.prompt()
.user("Generate filmographies for three random actors.")
.call()
.entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});
신뢰성 스위치: EntityParamSpec
모든 .entity(...) (그리고 .responseEntity(...)) 오버로드는 Consumer<EntityParamSpec>을 선택적으로 받아 두 가지 독립적이고 결합 가능한 동작을 켭니다.
잘못된 출력에서 실패하지 않기: validateSchema()
validateSchema() 은 자가 보정 재시도 루프를 켭니다. Spring AI가 응답을 엔티티 스키마에 대해 검증하고, 실패하면 그 특정 오류를 프롬프트에 추가한 뒤 기본적으로 최대 3회까지 호출을 다시 발행합니다.
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class, spec -> spec.validateSchema());
재시도 루프가 어떻게 동작하고 어떻게 커스터마이즈하는지는 Schema Validation & Self-Correction을 참고하세요.
더 강한 상류 보장: useProviderStructuredOutput()
useProviderStructuredOutput() 은 스키마를 API 레벨 제약으로 프로바이더에 전송해서, 프롬프트 지시에 의존하는 대신 프로바이더 런타임이 준수를 강제하게 만듭니다.
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class, spec -> spec.useProviderStructuredOutput());
이 기능은 기반 모델이 네이티브 구조화 출력을 지원해야 해요. 지원 프로바이더와 한계는 Provider-Native Structured Output을 참고하세요.
둘 다 결합
두 스위치는 서로 다른 문제를 해결하며 자연스럽게 결합됩니다:
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class, spec -> spec
.useProviderStructuredOutput()
.validateSchema());
useProviderStructuredOutput() 은 API 레벨에서 모델을 제약해 잘못된 출력의 가능성을 최소화해요. validateSchema() 는 남은 케이스(프로바이더 엣지 케이스, 추론 모델의 변덕)를 잡아 자동으로 바로잡습니다. 다운스트림 코드가 형태 변화를 견딜 수 없을 때 둘 다 쓰는 걸 권장해요.
전체 응답 받기
.entity(...) 는 파싱된 객체만 반환합니다. 토큰 사용량, 관측 가능성 메타데이터처럼 엔티티 너머의 것까지 필요하다면 .responseEntity(...) 를 쓰세요:
ResponseEntity<ChatResponse, ActorsFilms> result = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.responseEntity(ActorsFilms.class);
ActorsFilms films = result.entity();
ChatResponse raw = result.response();
long totalTokens = raw.getMetadata().getUsage().getTotalTokens();
.entity(...) 와 동일한 오버로드 세트를 가집니다 — Class, ParameterizedTypeReference, 커스텀 StructuredOutputConverter, EntityParamSpec 컨슈머 모두 적용돼요.
커스텀 및 비-JSON 출력
내장 JSON 파싱으로 부족할 때가 있어요 — 모델이 JSON을 마크다운 펜스로 감싸거나, YAML·CSV 같은 비-JSON 형식이 필요할 때죠. 그럴 땐 직접 만든 StructuredOutputConverter<T> 를 .entity(...) 에 넘기면 됩니다. Output Converters를 참고하세요.
치트 시트
| 필요한 것 | 사용 |
|---|---|
| 기본 — 모든 프로바이더에서 동작 | .entity(Type.class) |
List<T>, Map<K,V> 같은 제네릭 타입 |
.entity(new ParameterizedTypeReference<...>() {}) |
| 잘못된 출력에서 실패하지 않기 | .entity(Type.class, spec -> spec.validateSchema()) |
| 프로바이더의 더 강한 상류 보장 | .entity(Type.class, spec -> spec.useProviderStructuredOutput()) |
| 둘 다 — 요청 제약 + 응답 재시도 | .entity(Type.class, spec -> spec.useProviderStructuredOutput().validateSchema()) |
| 엔티티와 함께 토큰 사용량/메타데이터 | .responseEntity(...) (동일 오버로드) |
| 모델이 JSON을 마크다운 펜스로 감싸거나 비-JSON 형식 | StructuredOutputConverter<T> 구현 후 .entity(...) 에 전달 |
| 스트리밍 응답 | 지원 안 함 — .entity(...) 는 .call() 전용; .stream() 은 텍스트 청크 반환 |
구조화 출력은 모델 출력을 구조화 타입으로 변환하려는 최선의 노력이에요. 모델이 요청한 구조를 반환한다는 보장은 없습니다. 정확성이 중요할 때는 validateSchema() 와/또는 useProviderStructuredOutput() 을 사용하세요.
StructuredOutputConverter 는 LLM 도구 호출에는 사용되지 않습니다. 도구 호출은 기본적으로 구조화 출력을 제공하니까요.