////
Search

Spring AI 공부하기 3편

생성일
2025/11/24 01:30
태그

개요

Spring AI의 기본 콘셉은 Structured Output이다.
즉, AI의 Assistance Message를 POJO로 만들어 준다는 것인데 일반적인 사용법은 아래와 같다.
ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt() .user(u -> u.text("Generate the filmography of 5 movies for {actor}.") .param("actor", "Tom Hanks")) .call() .entity(ActorsFilms.class);
Java
복사

원리는 뭘까

위 프롬프트를 보면 알 수 있는 것 처럼 LLM의 요청에 객체의 힌트는 전혀없다.
하다못해 JSON응답으로 달라고 지정도 안했는데 어떻게 POJO로 만들어 준다는 것일까?
여기에 대한 답은 ChatClient의 구현체인 DefaultChatClientBeanOutputConverter 클래스를 그리고 최종 완성하는 Prompt 객체를 살펴보면 알 수 있다.

ChatClient

Spring AI를 사용하다 보면 가장 많이 볼 수 있는 객체다.
Interface인데 상당히 많은 기능을 가지고 있지만, 아무래도 주요 기능은 말 그대로 클라이언트 일것인데
create 시에 인터페이스 내부에 구현된 DefaultChatClientBuilder 구현체에 의해서 DefaultChatClient가 생성된다.
기본적인 채팅에 관한 기능들을 담고 있기 때문에 통신 시 가장 많이 이용된다.
그런데 앞서 본 코드에서는 빌더 메시지를 통해 entity를 전달하면 자연스럽게 POJO로 연동해주는데 어떤 비밀이 숨어있는 걸까?

BeanOutputConverter

먼저 알아야 하는게 당연하게도 위 프롬프트에 단 한줄도 ActorsFilms 클래스 구조를 LLM에게 전달한 적이 없다.
하지만 Spring AI 프레임워크를 통해서 POJO로 응답받는데 이는 BeanOutputConverter 을 통해서 LLM에게 Class의 구조를 전달하여 JSON으로 전달받기 때문이다.
애초에 이런 구조가 까보지 않으면 추상화가 높게 되어있어 모르고 이용할 수 있는데 이 정도 이용으로 토큰 이용이 기하 급수적으로 증가하지는 않을 수 있지만
어디까지나 관리 차원에서 알아둬야 할것 같기는 하다.
JSON으로 클래스 구조를 전달하는 방식은 상당히 간단한다.
public abstract class ParameterizedTypeReference<T> { private final Type type; protected ParameterizedTypeReference() { Class<?> parameterizedTypeReferenceSubclass = findParameterizedTypeReferenceSubclass(this.getClass()); Type type = parameterizedTypeReferenceSubclass.getGenericSuperclass(); Assert.isInstanceOf(ParameterizedType.class, type, "Type must be a parameterized type"); ParameterizedType parameterizedType = (ParameterizedType)type; Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); Assert.isTrue(actualTypeArguments.length == 1, "Number of type arguments must be 1"); this.type = actualTypeArguments[0]; } private ParameterizedTypeReference(Type type) { this.type = type; } public Type getType() { return this.type; } public boolean equals(@Nullable Object other) { boolean var10000; if (this != other) { label26: { if (other instanceof ParameterizedTypeReference) { ParameterizedTypeReference<?> that = (ParameterizedTypeReference)other; if (this.type.equals(that.type)) { break label26; } } var10000 = false; return var10000; } } var10000 = true; return var10000; } public int hashCode() { return this.type.hashCode(); } public String toString() { return "ParameterizedTypeReference<" + String.valueOf(this.type) + ">"; } public static <T> ParameterizedTypeReference<T> forType(Type type) { return new ParameterizedTypeReference<T>(type) { }; } private static Class<?> findParameterizedTypeReferenceSubclass(Class<?> child) { Class<?> parent = child.getSuperclass(); if (Object.class == parent) { throw new IllegalStateException("Expected ParameterizedTypeReference superclass"); } else { return ParameterizedTypeReference.class == parent ? child : findParameterizedTypeReferenceSubclass(parent); } } }
Java
복사
org.springframework.core
각주를 보면 알겠지만 이는 Spring AI의 자체 기능이라기 보다는 Spring Core의 ParameterizedTypeReference를 사용하는 방식을 취하고 있다.
복잡하게 생각 할 필요없이 모든 필드를 가져온다고 보면 된다.
@Override public String getFormat() { String template = """ Your response should be in JSON format. Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. Do not include markdown code blocks in your response. Remove the ```json markdown from the output. Here is the JSON Schema instance your output must adhere to: ```%s``` """; return String.format(template, this.jsonSchema); }
Java
복사
그리고 이를 사용하는 BeanOutputConverter에서 이를 이용하는 getFormat 메소드를 통해서 Prompt에 추가된다고 보면 된다.

마치며

결과적으로 위와 같은 메커니즘을 모른다면 왜 POJO로 변환하는 것인지 그리고 이를 통제하는 방법이나 오류에 대처할 수 없게된다.
뭐 빠르게 발전하는 만큼이나 당연히 버그가 생기면 빠르게 고칠 것이고 토큰 또한 문제될 정도가 아니니 편의를 위해 굳이 알 필요가 없을지도 모르지만
결국 개발자가 통제할 수 있는 레벨에서는 알아두는게 좋다고 생각한다.
추가적으로 이런 거대한 프레임워크를 초창기부터 분석해볼 수 있다는게 참 재밌는거 같다.
안그래도 복잡도가 높은 프레임워크에서 더 복잡해 진다면 사실 엄두도 안날텐데 친숙한 기능 위주로 분석을 시작하니 재미가 쏠쏠하다.