Spring AI로 AI 챗봇 만들기: ChatModel부터 ChatClient까지

한소연·2026년 3월 23일

내일배움캠프

목록 보기
13/15
post-thumbnail

Java 개발자를 위한 Spring AI 입문 가이드. LangChain과의 비교부터 실시간 스트리밍 챗봇 구현까지 핵심 개념을 코드와 함께 정리합니다.


목차

  1. Spring AI란?
  2. 개발환경 구축
  3. 핵심 API 구조 이해
  4. 실습: 어린왕자 챗봇 만들기
  5. ChatClient로 코드 간소화하기

1. Spring AI란?

AI 애플리케이션 개발의 현실

AI 모델을 직접 연동해본 개발자라면 공감할 것입니다. 프롬프트를 구성하고, 응답을 파싱하고, 대화 맥락을 유지하고, 외부 도구를 연동하는 전 과정을 날것으로 구현하면 코드 복잡도가 급격히 치솟습니다. 여기에 모델을 교체하거나 기능을 추가하는 날엔 하드코딩된 로직이 발목을 잡습니다.

Python 생태계에서는 이 문제를 LangChain이 해결해왔습니다. 프롬프트 → 응답 → 후처리를 하나의 체인으로 연결하는 LangChain의 철학은 RAG, 대화 기억, 도구 호출 등 고도화된 기능의 표준을 만들었습니다.

Java 진영의 대답이 바로 Spring AI입니다.

LangChain vs Spring AI

비교 항목LangChainSpring AI
언어 / 플랫폼Python / Node.jsJava / Spring Boot
핵심 개념프롬프트-응답 단계를 체인으로 연결스프링 빈으로 자동 주입된 API로 메서드 호출 처리
대화 기억지원지원
구조화된 출력JSON 형식 지원JSON 및 Java 객체 역직렬화 지원
벡터 저장소지원지원
도구 호출지원지원
멀티모달지원지원
RAG지원지원
MCP Server 개발미지원지원

Spring AI의 차별점은 친숙한 Spring 개발 경험을 그대로 유지한다는 점입니다. Spring Boot Starter 의존성 하나로 AI 기능을 활성화하고, OpenAI, Anthropic, Gemini, Ollama 등 주요 LLM을 마치 로컬 Bean처럼 다룰 수 있습니다.


2. 개발환경 구축

프로젝트 설정

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'  // 스트리밍용
    implementation 'org.springframework.ai:spring-ai-starter-model-openai'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
}

왜 Web과 WebFlux를 함께? 일반적인 MVC 패턴을 유지하면서도, OpenAI와의 실시간 스트리밍 응답(SSE) 구현을 위해 Reactive 라이브러리(WebClient 등)가 필요하기 때문입니다. 두 의존성이 공존하면 기본적으로 Servlet 기반 Spring MVC로 구동됩니다.

Spring Boot 버전 주의: Spring AI 1.1.2(안정화 버전)와의 호환성을 위해 Spring Boot 3.5.x를 사용합니다. Spring Boot 4.0.x 환경에서는 Spring AI가 아직 마일스톤 단계인 2.0.0-M2 버전으로 강제되므로 현재 조합을 유지하는 것이 안전합니다.

application.yml 설정

spring:
  application:
    name: aistudy

  servlet:
    multipart:
      enabled: true
      max-file-size: 10MB
      max-request-size: 15MB

  ai:
    openai:
      api-key: ${OPENAI_API_KEY}  # 환경변수로 주입

API 키는 환경변수로 관리합니다.

# ~/.zshrc 또는 ~/.bashrc에 추가
export OPENAI_API_KEY=발급받은_API_KEY

# 즉시 적용
source ~/.zshrc

3. 핵심 API 구조 이해

지원 모델 종류

Spring AI는 다양한 모델 유형을 공통 인터페이스로 추상화합니다.

모델 구분설명주요 인터페이스
Chat Model텍스트/멀티모달 입력 처리 및 응답 생성ChatModel
Image Model텍스트 프롬프트 기반 이미지 생성/편집ImageModel
Speech Model텍스트 → 음성 변환 (TTS)SpeechModel
Transcription Model음성 → 텍스트 변환 (STT)TranscriptionModel
Embedding Model텍스트 → 벡터 변환 (유사도 검색용)EmbeddingModel

ChatModel vs StreamingChatModel

동기식 (ChatModel.call)           스트리밍 (ChatModel.stream)
─────────────────────────         ─────────────────────────
[LLM 처리 중...]                  토큰1 → 토큰2 → 토큰3 → ...
         ↓                                 ↓
  응답 전체 한번에 반환              첫 토큰부터 즉시 반환
  String / ChatResponse            Flux<String> / Flux<ChatResponse>
구분call()stream()
처리 방식동기 (Blocking)리액티브 스트림 (Async)
반환 타입String, ChatResponseFlux<String>, Flux<ChatResponse>
적합한 용도데이터 분석, 요약, 백엔드 로직챗봇 UI, 실시간 텍스트 표시

Spring AI 1.0 이후부터 ChatModelStreamingChatModel을 직접 확장하는 구조로 설계되어, 하나의 Bean 주입으로 두 방식을 모두 사용할 수 있습니다.

Prompt와 Message 구조

Prompt
├── List<Message>
│   ├── SystemMessage   - AI 역할, 말투, 제약사항 등 기본 지침
│   ├── UserMessage     - 사용자 입력 (멀티모달 지원)
│   ├── AssistantMessage - AI 응답, 대화 기억 유지에도 활용
│   └── ToolResponseMessage - 외부 도구 실행 결과
└── ChatOptions
    ├── model           - 사용할 모델명 (gpt-4o, claude-3-opus 등)
    ├── temperature     - 창의성 조절 (0.0 ~ 2.0)
    ├── maxTokens       - 응답 최대 토큰 수
    ├── topP            - 누적 확률 컷오프
    └── stopSequences   - 응답 중단 트리거 문자열

ChatResponse 응답 구조

ChatResponse
└── List<Generation>
    └── Generation
        ├── AssistantMessagegetText()  // 실제 텍스트
        └── ChatGenerationMetadatagetFinishReason()  // STOP, LENGTH 등

// 활용 예시
ChatResponse response = chatModel.call(prompt);
String content = response.getResult().getOutput().getText();
String finishReason = response.getResult().getMetadata().getFinishReason();

4. 실습: 어린왕자 챗봇 만들기

생텍쥐페리의 소설 <어린왕자>의 주인공처럼 응답하는 챗봇을 강의 내용을 따라 만들어 보았습니다.

4-1. 동기식 응답 구현

인터페이스 정의

// chatbot1/application/LittlePrinceChatBotService.java
public interface LittlePrinceChatBotService {
    String generate(String question);
    Flux<String> generateStream(String question);
}

서비스 구현체 (동기식)

// chatbot1/infrastructure/LittlePrinceChatbotServiceImpl.java
@Service
public class LittlePrinceChatbotServiceImpl implements LittlePrinceChatBotService {

    private final ChatModel chatModel;

    public LittlePrinceChatbotServiceImpl(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @Override
    public String generate(String question) {

        // 1. 시스템 메시지 - AI 페르소나 정의
        String persona = """
            당신은 생텍쥐페리의 '어린 왕자'입니다. 다음 특성을 따라주세요:
            1. 순수한 관점으로 세상을 바라봅니다.
            2. "어째서?"라는 질문을 자주 하며 호기심이 많습니다.
            3. 철학적 통찰을 단순하게 표현합니다.
            4. "어른들은 참 이상해요"라는 표현을 씁니다.
            5. B-612 소행성에서 왔으며 장미와의 관계를 언급합니다.
            6. "중요한 것은 눈에 보이지 않아"라는 문장을 사용합니다.
            항상 간결하게 답변하세요. 길어야 2-3문장으로 응답하세요.""";

        SystemMessage systemMessage = SystemMessage.builder().text(persona).build();

        // 2. 사용자 메시지
        UserMessage userMessage = UserMessage.builder().text(question).build();

        // 3. 옵션 설정
        ChatOptions chatOptions = ChatOptions.builder()
                .model("gpt-4o-mini")
                .temperature(0.3)
                .maxTokens(1000)
                .build();

        // 4. 프롬프트 조합 → LLM 호출 → 응답 추출
        Prompt prompt = Prompt.builder()
                .messages(systemMessage, userMessage)
                .chatOptions(chatOptions)
                .build();

        ChatResponse chatResponse = chatModel.call(prompt);
        return chatResponse.getResult().getOutput().getText();
    }
}

컨트롤러

// chatbot1/presentation/LittlePrinceController.java
@Controller
@RequestMapping("/littleprince")
public class LittlePrinceController {

    private final LittlePrinceChatBotService service;

    public LittlePrinceController(LittlePrinceChatBotService service) {
        this.service = service;
    }

    @ResponseBody
    @PostMapping(
        consumes = MediaType.APPLICATION_JSON_VALUE,
        produces = MediaType.TEXT_PLAIN_VALUE
    )
    public String message(@RequestBody String question) {
        return service.generate(question);
    }

    @GetMapping
    public String index() {
        return "chatbot1/index";
    }
}

4-2. 스트리밍 응답 구현

동기식과의 차이점은 딱 두 가지입니다. chatModel.stream() 호출과 Flux<String> 반환입니다.

서비스 구현체 (스트리밍)

@Override
public Flux<String> generateStream(String question) {
    // ... 프롬프트 구성은 동일 ...

    // stream() 으로 호출하면 Flux<ChatResponse> 반환
    Flux<ChatResponse> response = chatModel.stream(prompt);

    // 각 청크에서 텍스트 조각 추출
    return response.map(res -> {
        AssistantMessage msg = res.getResult().getOutput();
        return Objects.requireNonNullElse(msg.getText(), "");
    });
}

스트리밍 컨트롤러 엔드포인트

@ResponseBody
@PostMapping(
    path = "/stream",
    consumes = MediaType.APPLICATION_JSON_VALUE,
    produces = MediaType.APPLICATION_NDJSON_VALUE  // NDJSON으로 라인 단위 전송
)
public Flux<String> streamMessage(@RequestBody String question) {
    return service.generateStream(question);
}

프론트엔드: 스트림 읽기 (JavaScript)

async function sendStreamMessage() {
    const message = document.getElementById('user-input').value.trim();
    if (!message) return;

    // 빈 말풍선 미리 생성
    const botBubble = createEmptyBubble();

    const response = await fetch('/littleprince/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: message
    });

    // ReadableStream으로 청크 단위 수신
    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        // 받아온 청크를 말풍선에 즉시 추가
        const chunk = decoder.decode(value, { stream: true });
        botBubble.innerText += chunk;
    }
}

스트리밍의 UX 장점: 동기식은 전체 응답이 완성될 때까지 화면이 멈춰 있지만, 스트리밍은 첫 토큰부터 바로 텍스트가 흘러나와 사용자가 AI가 "생각하고 있음"을 실시간으로 느낄 수 있습니다.


5. ChatClient로 코드 간소화하기

ChatModel vs ChatClient

구분ChatModel (저수준)ChatClient (고수준)
특징모델 직접 제어, 세밀한 응답 관리복잡한 데이터 흐름 및 대화 컨텍스트 관리
주요 용도단순 단일 질문-답변챗봇, RAG 시스템, 에이전트
핵심 기능기본 LLM 호출Advisor 체이닝, 대화 기억, 도구 호출 관리
권장 여부특수한 경우일반적인 경우 ChatClient 권장

ChatClient의 핵심은 Advisor 체인입니다. 여러 어드바이저를 순차 실행하여 프롬프트를 전/후처리합니다. 대화 기억 유지, RAG 구현, 도구 호출 흐름 관리 등 고수준 기능이 이 위에서 동작합니다.

ChatClient 객체 생성

// 방법 1: ChatClient.Builder 빈 사용 (권장)
@Service
class ChatService {
    private final ChatClient chatClient;

    public ChatService(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }
}

// 방법 2: ChatModel로 직접 생성
@Service
class ChatService {
    private final ChatClient chatClient;

    public ChatService(ChatModel chatModel) {
        this.chatClient = ChatClient.builder(chatModel).build();
    }
}

Fluent API로 리팩토링

ChatClient를 사용하면 기존의 장황한 객체 생성 코드가 간결한 메서드 체이닝으로 바뀝니다.

Before (ChatModel)

SystemMessage systemMessage = SystemMessage.builder().text(persona).build();
UserMessage userMessage = UserMessage.builder().text(question).build();
ChatOptions chatOptions = ChatOptions.builder().model("gpt-4o-mini").temperature(0.3).build();
Prompt prompt = Prompt.builder().messages(systemMessage, userMessage).chatOptions(chatOptions).build();
ChatResponse chatResponse = chatModel.call(prompt);
AssistantMessage assistantMessage = chatResponse.getResult().getOutput();
return assistantMessage.getText();

After (ChatClient)

return chatClient.prompt()
        .system(persona)          // SystemMessage 생성 불필요
        .user(question)           // UserMessage 생성 불필요
        .options(ChatOptions.builder()
                .temperature(0.3)
                .maxTokens(1000)
                .build())
        .call()                   // 동기 실행
        .content();               // String 바로 추출

스트리밍도 동일한 구조에서 call() 대신 stream()만 바꾸면 됩니다.

return chatClient.prompt()
        .system(persona)
        .user(question)
        .options(ChatOptions.builder().temperature(0.3).build())
        .stream()                 // 스트리밍 실행
        .content();               // Flux<String> 반환

ChatClient Fluent API 핵심 정리

메서드역할
.system()SystemMessage 객체 생성 없이 시스템 프롬프트 주입
.user()UserMessage 형태로 사용자 입력 전달
.options()Temperature, MaxTokens 등 모델 파라미터 설정
.call()동기 실행 → .content()String 추출
.stream()스트리밍 실행 → .content()Flux<String> 추출

마무리

이번 글에서는 Spring AI의 핵심 구조를 살펴봤습니다.

ChatModel의 저수준 API로 Prompt, Message, ChatOptions, ChatResponse가 어떻게 맞물려 동작하는지 이해하고, ChatClient의 Fluent API로 동일한 기능을 얼마나 간결하게 표현할 수 있는지 비교해봤습니다.

동기식과 스트리밍 방식의 차이도 중요합니다. 단순 API 응답이라면 call()로 충분하지만, 사용자와 실시간으로 대화하는 챗봇이라면 stream()을 통한 토큰 단위 전송이 훨씬 나은 UX를 만들어냅니다.

ChatClient의 진가는 Advisor 체인에서 드러납니다. 대화 기억 유지, RAG, 도구 호출 등 고도화된 기능은 이후 글에서 계속 다룰 예정입니다.


참고 출처

본 글은 아래 강의 자료를 바탕으로 작성되었습니다.

  • Spring AI 소개 및 Chat Model API, TeamSparta Spring AI 강의자료

Copyright ⓒ TeamSparta All rights reserved.

profile
안 되면 될 때까지

0개의 댓글