멀티 ChatModel 운영 전략

멀티 ChatModel 운영 전략

들어가며

AI 기능을 서비스에 붙이다 보면 금방 이런 상황을 마주하게 된다. 사용자 수정. 네 수정했습니다.
관리자 수정 관리자 글자 확인

기능에 따라 서로 다른 ChatModel을 사용하고 싶은데, 어떻게 관리해야 할까?

관광지 추천 AI 서비스를 개발하면서 실제로 이 문제를 겪었다.

처음에는 텍스트 기반 대화 모델만으로 충분하다고 생각했다. 하지만 관광지 이미지가 입력되면, 해당 이미지를 분석해 이미지의 특징과 설명을 추출해야 했다. 이 경우 텍스트 전용 모델만으로는 이미지를 이해할 수 없다.

결국 다음과 같이 두 가지 모델이 필요해졌다.

  • 텍스트 모델: 빠른 응답과 긴 컨텍스트 처리에 적합한 대화 특화 모델

  • 멀티모달 모델: 이미지를 입력받아 내용을 이해할 수 있는 비전 지원 모델

여기서 고민이 시작됐다.

Spring Boot + LangChain4J 환경에서 여러 ChatModel을 어떻게 관리할 것인가?

LangChain4J 자동 설정의 한계

LangChain4J는 Spring Boot 자동 설정을 지원한다. 예를 들어 application.yml에 다음과 같이 설정하면 ChatModel 빈이 자동으로 등록된다.

langchain4j:
  ollama:
    chat-model:
      base-url: https://ollama.example.com
      model-name: model-name

하지만 이 방식에는 한계가 있다.

자동 설정은 기본적으로 하나의 ChatModel 빈을 등록하는 방식에 가깝다. 따라서 서로 다른 목적의 모델을 여러 개 사용해야 한다면, 자동 설정에만 의존하기 어렵다.

결국 여러 모델을 명시적으로 관리하려면 직접 @Bean을 정의하는 방식이 필요하다.

방법 비교

방법 1. @Qualifier로 구분하기

가장 먼저 떠올릴 수 있는 방식은 @Qualifier를 사용해 각 모델 빈에 이름을 붙이는 것이다.

@Configuration
public class ModelConfig {

    @Primary
    @Bean
    @Qualifier("textChatModel")
    public ChatModel textChatModel() {
        return OllamaChatModel.builder()
                .baseUrl("https://ollama.example.com")
                .modelName("chat-model-name")
                .build();
    }

    @Bean
    @Qualifier("imageChatModel")
    public ChatModel imageChatModel() {
        return OllamaChatModel.builder()
                .baseUrl("https://ollama.example.com")
                .modelName("multi-modal-model-name")
                .build();
    }
}

사용하는 쪽에서는 다음과 같이 주입받는다.

@Service
public class SomeService {

    private final ChatModel textModel;
    private final ChatModel imageModel;

    public SomeService(
            @Qualifier("textChatModel") ChatModel textModel,
            @Qualifier("imageChatModel") ChatModel imageModel
    ) {
        this.textModel = textModel;
        this.imageModel = imageModel;
    }
}

이 방식은 단순하고 직관적이다. 하지만 모델이 늘어날수록 @Qualifier 문자열이 여러 클래스에 퍼지게 된다.

문자열 기반이기 때문에 오타가 발생해도 컴파일 시점에 잡기 어렵고, 모델 교체나 이름 변경 시 영향 범위도 커진다.

방법 2. @Qualifier + Proxy 클래스 사용하기

두 번째 방식은 @Qualifier를 직접 비즈니스 로직에 노출하지 않고, 모델별 Proxy 클래스로 감싸는 방법이다.

현재 프로젝트에서는 이 방식을 사용하고 있다.

@Service
public class ChatModelProxy {

    private final ChatModel chatModel;
    private final ObjectMapper objectMapper;

    public ChatModelProxy(
            @Qualifier("textChatModel") ChatModel chatModel,
            ObjectMapper objectMapper
    ) {
        this.chatModel = chatModel;
        this.objectMapper = objectMapper;
    }

    public String chat(Prompt prompt) {
        // 텍스트 모델 호출
    }

    public  T chat(Prompt prompt, Class clazz) {
        // 응답 JSON 파싱
    }

    public  List chatAnswerInList(Prompt prompt, Class clazz) {
        // 리스트 형태 응답 파싱
    }
}

이미지 분석 전용 모델은 별도의 Proxy로 분리한다.

@Service
public class ImageChatModelProxy {

    private final ChatModel chatModel;

    public ImageChatModelProxy(
            @Qualifier("imageChatModel") ChatModel chatModel
    ) {
        this.chatModel = chatModel;
    }

    public String chatWithImage(String imageUrl, String textPrompt) {
        String base64 = fetchAsResizedBase64(imageUrl);
        String mimeType = detectMimeType(imageUrl);

        UserMessage message = UserMessage.from(
                ImageContent.from(base64, mimeType, ImageContent.DetailLevel.AUTO),
                TextContent.from(textPrompt)
        );

        return chatModel.chat(message).aiMessage().text();
    }
}

이렇게 하면 비즈니스 로직에서는 더 이상 @Qualifier를 알 필요가 없다.

@Service
public class ChatTask {

    private final ChatModelProxy chatModelProxy;
    private final ImageChatModelProxy imageChatModelProxy;

    public ChatTask(
            ChatModelProxy chatModelProxy,
            ImageChatModelProxy imageChatModelProxy
    ) {
        this.chatModelProxy = chatModelProxy;
        this.imageChatModelProxy = imageChatModelProxy;
    }
}

이 방식의 장점은 명확하다.

@Qualifier 문자열은 Proxy 내부에만 존재하고, 서비스 계층은 각 모델의 구체적인 빈 이름을 몰라도 된다. 또한 이미지 다운로드, Base64 인코딩, JSON 파싱 같은 모델별 전처리·후처리 로직도 Proxy 내부에 캡슐화할 수 있다.

방법 3. 설정 기반 모델 레지스트리 사용하기

모델이 세 개 이상으로 늘어나거나, 설정만으로 모델을 추가·교체해야 한다면 레지스트리 패턴도 고려할 수 있다.

@Configuration
public class ModelRegistryConfig {

    @Bean
    public Map chatModelRegistry(ModelProperties props) {
        Map registry = new HashMap<>();

        props.getModels().forEach((name, config) -> {
            ChatModel model = OllamaChatModel.builder()
                    .baseUrl(config.getBaseUrl())
                    .modelName(config.getModelName())
                    .build();

            registry.put(name, model);
        });

        return registry;
    }
}

설정은 다음과 같이 관리할 수 있다.

my-service:
  models:
    text:
      base-url: https://ollama.example.com
      model-name: text-model-name
      timeout: 10m
    image:
      base-url: https://ollama.example.com
      model-name: multi-modal-model-name
      timeout: 30m

이 방식은 모델 개수가 많거나 설정 기반으로 모델을 교체해야 할 때 유용하다.

다만 단점도 있다. 모델별로 필요한 전처리 로직이 다를 경우, 단순히 Map로 관리하는 것만으로는 부족하다.

예를 들어 이미지 모델은 이미지 다운로드, 리사이징, MIME 타입 판별, Base64 인코딩이 필요하다. 이런 로직은 결국 레지스트리 바깥의 별도 서비스나 Proxy 계층에 다시 위치하게 된다.

따라서 모델 호출 방식이 단순하고 동일할 때는 레지스트리가 적합하지만, 모델마다 사용 방식이 다르다면 Proxy 방식이 더 자연스럽다.

현재 프로젝트에서 선택한 방식

현재 프로젝트에서는 @Qualifier + Proxy 클래스 방식을 선택했다.

항목

평가

@Qualifier 문자열 노출 범위

Proxy 클래스 내부로 격리

모델별 전처리 로직 분리

이미지 리사이징, 인코딩 로직을 ImageChatModelProxy에 캡슐화

텍스트 모델 부가 기능

JSON 파싱, 리스트 응답 파싱 등을 ChatModelProxy에 집약

비즈니스 코드 결합도

서비스는 Proxy 타입만 의존

두 모델의 역할은 명확히 다르다.

텍스트 모델은 일반 대화, JSON 응답 파싱 등의 작업에 사용된다. 반면 이미지 모델은 이미지 입력을 처리하기 위한 별도의 전처리 과정이 필요하다.

따라서 두 모델을 억지로 하나의 인터페이스로 묶기보다는, 각각의 책임을 가진 Proxy로 분리하는 편이 더 자연스럽다.

물론 모델이 3개 이상으로 늘어나거나, 런타임에 모델을 선택해야 하는 요구사항이 생긴다면 레지스트리 패턴을 함께 검토할 수 있다.

마무리하며

  1. LangChain4J 자동 설정만으로는 여러 ChatModel을 세밀하게 관리하기 어렵다.

  2. 여러 모델을 사용해야 한다면 직접 @Bean을 정의하고 @Qualifier로 구분할 수 있다.

  3. Proxy를 사용하면 모델별 전처리·후처리 로직을 캡슐화할 수 있으며, 비즈니스 로직은 모델 빈 이름을 몰라도 된다.

  4. 모델 수가 많아지거나 설정 기반 교체가 중요해지면 레지스트리 패턴을 고려할 수 있다.

개인적으로 이 상황에서는 Proxy 방식이 가장 적절한 선택이라고 본다.

텍스트 모델과 이미지 모델은 단순히 “ChatModel이 두 개”인 것이 아니라, 사용하는 방식과 책임 자체가 다르다. 따라서 공통 인터페이스나 레지스트리로 먼저 추상화하기보다는, 모델별 책임을 명확히 드러내는 Proxy를 두는 편이 유지보수 측면에서 더 낫다.

Ted

Site footer