본문으로 건너뛰기
AIDevOps
  • Learn
  • Learning Paths
  • Practice
  • Open Source
  • Books
  • Engineering

    AI DevOpsAI 서비스 개발·운영 전체 지도LLMOpsLLM 배포·평가·관측실전 프로젝트AI Agent 프로젝트 실습

    Knowledge

    Docs기술 문서 모음Blog엔지니어링 아티클Plogger개발 기록 피드

    Validate

    Certification3단계 역량 인증 · 준비 중
AI Models
LlamaMistralGemmaDeepSeekQwen
🌱 Spring Cloud
Spring 입문 & 로드맵Spring Cloud GatewaySpring BootJava|Spring AISpring SecuritySpring BatchSpring JPA
🤖 AI 실전 개발
AI 실전 입문 & 로드맵Hugging FaceLangChainLlamaIndexLLMOps|LangGraphMCPMulti-AgentAgent Evaluation
🧠 AI Core
AI 입문 & 로드맵ML FundamentalsLLM Fundamentals|Python AIC++|PyTorchTensorFlowJAX
🧠 AI Agent 개발
금융 AI AgentLLM API 서버주식 투자 AgentAIOps AI Agent교육 AI Agent코딩 AI Agent
🐳 DevOps
DevOps 입문 & 로드맵LinuxDockerCI/CD|Kubernetes 기본K8s 심화/실무PrometheusGrafana
🧱 인프라
인프라 입문 & 로드맵NginxRedis
☁️ 클라우드
클라우드 입문 & 로드맵AWSGCPAzureNCPCloudflare
🎨 Frontend
Frontend 입문 & 로드맵JavaScriptTypeScript|ReactNext.js|VueNuxt
📱 Mobile
Mobile 입문 & 로드맵KotlinAndroidFlutter
⚙️ Backend
Backend 입문 & 로드맵Python 기본FastAPIDjangoFlask|CGoGinNode.js
💾 Database
DB 입문 & 로드맵공통 SQLOracleMySQLPostgreSQL|MongoDB벡터 DB
🧪 검증
k6JMeternGrinder
AIDevOps

Engineering AI. From Code to Production.
AI와 AI Agent를 개발하고 운영하기 위한 엔지니어링 학습 플랫폼

Learn

  • 전체 가이드
  • Learning Paths
  • Practice
  • Books

Resources

  • AI DevOps
  • LLMOps
  • 실전 프로젝트
  • Docs
  • Blog
  • Plogger
  • Open Source
  • Certification (준비 중)

Start Here

  • AI Core 로드맵
  • AI 실전 개발 로드맵
  • Spring Cloud 로드맵
  • DevOps 로드맵
  • 인프라 로드맵

 

  • 클라우드 로드맵
  • Frontend 로드맵
  • Mobile 로드맵
  • Backend 로드맵
  • Database 로드맵
© 2026 AI DevOps Korea. All rights reserved.
이용약관개인정보처리방침Sitemaptestforge.kr
  1. Home
  2. Learn
  3. Spring Cloud
  4. Spring AI
Spring Boot AI 애플리케이션 가이드 (Spring AI 2.0)

AI Spring AI 완전 가이드

Visitors

Spring AI 2.0으로 ChatClient, 프롬프트 템플릿, 구조화 출력, 대화 메모리, Tool Calling, RAG(벡터 스토어), MCP 서버까지 Spring Boot 방식 그대로 LLM 기능을 서비스에 붙이는 방법을 정리합니다.

  • Intermediate · 중급
  • 약 22분 읽기
  • 11개 섹션
  • 예제 코드 22개

포함된 Learning Path

이 가이드는 아래 경로의 한 단계입니다. 앞뒤 순서와 함께 학습해보세요.

  • Spring Cloud Engineer →
LLM 챗 API구조화 출력 (POJO 매핑)Tool CallingRAG 문서 검색MCP 서버

관련 프레임워크 & 개발환경

🍃Spring Boot→🔐Spring Security→GWSpring Cloud Gateway→🔌MCP→🧠벡터 DB→LCLangChain→

목차

0 / 13
  1. 가이드 사용법
  2. 구조 다이어그램
  3. Spring AI란?
  4. 프로젝트 설정
  5. ChatClient 기본
  6. 프롬프트 템플릿
  7. 구조화 출력
  8. 대화 메모리
  9. Tool Calling
  10. RAG & 벡터 스토어
  11. MCP 서버
  12. 관측성 & 운영
  13. 테스트 전략
목차 13개 섹션
  1. 가이드 사용법
  2. 구조 다이어그램
  3. Spring AI란?
  4. 프로젝트 설정
  5. ChatClient 기본
  6. 프롬프트 템플릿
  7. 구조화 출력
  8. 대화 메모리
  9. Tool Calling
  10. RAG & 벡터 스토어
  11. MCP 서버
  12. 관측성 & 운영
  13. 테스트 전략

가이드 사용법

읽는 방향

Spring AI를 실무 흐름으로 이해하기

Spring AI 2.0으로 ChatClient, 프롬프트 템플릿, 구조화 출력, 대화 메모리, Tool Calling, RAG(벡터 스토어), MCP 서버까지 Spring Boot 방식 그대로 LLM 기능을 서비스에 붙이는 방법을 정리합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.

핵심 관점

백엔드 / 시스템 개발

문법보다 요청이 들어와 검증, 처리, 저장, 응답으로 이어지는 경계를 먼저 잡습니다.

LLM 챗 API구조화 출력 (POJO 매핑)Tool CallingRAG 문서 검색MCP 서버

구조 다이어그램

글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Spring AI를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.

학습 흐름

다이어그램 렌더링 중…

아키텍처 관점

다이어그램 렌더링 중…

Spring AI란?

Spring AI를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.

Spring AI는 OpenAI, Anthropic, Google, Amazon Bedrock, Ollama 같은 AI 모델과 PGVector, Redis, Chroma, Qdrant 같은 벡터 DB를 Spring Boot 자동 설정과 이식 가능한 API로 감싸는 프로젝트입니다. 모델 제공자를 바꿔도 비즈니스 코드는 ChatClient·VectorStore 같은 추상화에만 의존하므로, JDBC가 DB 벤더를 감춘 것처럼 LLM 벤더를 감춥니다. 이 가이드는 공식 GA인 Spring AI 2.0.x(Spring Boot 4.0/4.1 기반)를 기준으로 합니다.
구성 요소역할대표 API
ChatClient프롬프트 조립·호출·응답 변환을 담당하는 fluent APIprompt().user().call().content()
Advisor요청/응답 사이에 끼어드는 체인 (메모리, RAG, 로깅)MessageChatMemoryAdvisor, QuestionAnswerAdvisor
Structured OutputLLM 응답을 Java record/POJO로 매핑.entity(MyRecord.class)
Tool Calling모델이 애플리케이션 메서드 실행을 요청@Tool, .tools(...)
VectorStore임베딩 저장·유사도 검색 (RAG의 검색 단계)add(), similaritySearch()
MCPModel Context Protocol 서버/클라이언트 부트 스타터spring-ai-starter-mcp-server-webmvc

Tip

Spring AI 1.x 예제를 그대로 옮기면 컴파일 오류가 나는 부분이 많습니다. 2.0에서는 Spring Boot 4 / Spring Framework 7이 기반이고, 대화 메모리의 conversation id가 필수가 되었으며, 모델 옵션 프로퍼티에서 `.options` 단계가 빠졌습니다. 블로그 예제를 참고할 때는 버전부터 확인하세요.

프로젝트 설정

여기서는 프로젝트 설정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

start.spring.io에서 Spring Web과 원하는 모델 스타터(예: OpenAI)를 선택하면 됩니다. Spring AI 모듈 버전은 반드시 spring-ai-bom으로 한 번에 관리하세요 — 스타터마다 버전을 따로 적으면 core와 모델 모듈 버전이 어긋나 런타임에 NoSuchMethodError가 납니다. 스타터 이름은 spring-ai-starter-model-{제공자}, spring-ai-starter-vector-store-{저장소} 규칙을 따릅니다.
build.gradle.ktsKOTLIN
plugins {
    id("java")
    id("org.springframework.boot") version "4.1.0"
    id("io.spring.dependency-management") version "1.1.7"
}

java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }

extra["springAiVersion"] = "2.0.1"

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-actuator")

    // 모델 제공자 스타터 (하나만 골라도 됨)
    implementation("org.springframework.ai:spring-ai-starter-model-openai")
    // implementation("org.springframework.ai:spring-ai-starter-model-anthropic")
    // implementation("org.springframework.ai:spring-ai-starter-model-ollama")

    // RAG를 쓸 때
    implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
    implementation("org.springframework.ai:spring-ai-vector-store-advisor")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}")
    }
}
application.ymlYAML
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        model: gpt-4o-mini
        temperature: 0.2
      embedding:
        model: text-embedding-3-small
    # Anthropic을 쓸 때
    # anthropic:
    #   api-key: ${ANTHROPIC_API_KEY}
    #   chat:
    #     model: claude-sonnet-4-20250514
    #     max-tokens: 1024

Tip

  • API 키는 application.yml에 직접 쓰지 말고 환경 변수나 Secret Manager로 주입하세요. 키가 Git에 한 번이라도 올라가면 즉시 폐기(rotate)해야 합니다.
  • 로컬 개발은 Ollama 스타터로 비용 없이 시작하고, 운영 프로필에서만 상용 모델 스타터 설정을 켜는 구성이 흔합니다. 코드가 ChatClient에만 의존하면 프로필 전환만으로 모델이 바뀝니다.

ChatClient 기본

여기서는 ChatClient 기본을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

ChatClient는 스타터가 자동 등록하는 ChatClient.Builder로 만듭니다. 시스템 프롬프트나 공통 Advisor처럼 모든 요청에 반복되는 설정은 defaultSystem·defaultAdvisors로 빌더에 한 번만 넣고, 요청별로 달라지는 사용자 입력만 prompt()에서 채웁니다. 동기 응답은 call(), 토큰 단위 스트리밍은 stream()을 사용합니다.
ChatConfig.javaJAVA
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class ChatConfig {
    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder
            .defaultSystem("""
                너는 사내 개발 문서를 안내하는 어시스턴트다.
                모르는 내용은 추측하지 말고 모른다고 답한다.
                답변은 한국어로 간결하게 작성한다.
                """)
            .build();
    }
}
ChatController.javaJAVA
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

@RestController
@RequestMapping("/api/chat")
class ChatController {
    private final ChatClient chatClient;

    ChatController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    // 동기 호출 — 전체 응답을 한 번에 반환
    @PostMapping
    String chat(@RequestBody String message) {
        return chatClient.prompt()
            .user(message)
            .call()
            .content();
    }

    // 스트리밍 — 토큰이 생성되는 대로 SSE로 전송
    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    Flux<String> stream(@RequestBody String message) {
        return chatClient.prompt()
            .user(message)
            .stream()
            .content();
    }
}

Tip

  • 사용자 체감 속도는 첫 토큰까지의 시간(TTFT)이 좌우합니다. 긴 답변을 생성하는 화면은 stream()으로 바꾸는 것만으로도 "느리다"는 피드백이 크게 줄어듭니다.
  • stream()은 Reactor Flux를 반환하므로 Spring MVC 앱에서도 동작하지만, 동시 스트리밍 연결이 많다면 WebFlux 기반으로 구성하는 편이 스레드 자원 면에서 유리합니다.

프롬프트 템플릿

여기서는 프롬프트 템플릿을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

사용자 입력을 문자열 더하기로 프롬프트에 끼워 넣으면 템플릿과 데이터의 경계가 흐려지고 프롬프트 인젝션에도 취약해집니다. user(u -> u.text(...).param(...))로 템플릿과 변수를 분리하고, 긴 프롬프트는 classpath 리소스 파일로 빼서 코드 리뷰와 버전 관리 대상으로 만드세요.
src/main/resources/prompts/review.stTEXT
다음 {language} 코드를 리뷰해줘.
- 버그 가능성이 있는 부분을 먼저 지적한다.
- 각 항목에 심각도(HIGH/MEDIUM/LOW)를 붙인다.

코드:
{code}
CodeReviewService.javaJAVA
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

@Service
class CodeReviewService {
    private final ChatClient chatClient;
    private final Resource reviewPrompt;

    CodeReviewService(ChatClient chatClient,
                      @Value("classpath:prompts/review.st") Resource reviewPrompt) {
        this.chatClient = chatClient;
        this.reviewPrompt = reviewPrompt;
    }

    String review(String language, String code) {
        return chatClient.prompt()
            .user(u -> u.text(reviewPrompt)
                .param("language", language)
                .param("code", code))
            .call()
            .content();
    }
}

Tip

템플릿 변수 구분자는 기본적으로 {중괄호}입니다. 코드나 JSON처럼 중괄호가 많은 텍스트를 템플릿 본문에 직접 쓰면 변수로 오인되므로, 그런 내용은 반드시 param으로 넘기세요.

구조화 출력 (POJO 매핑)

여기서는 구조화 출력 (POJO 매핑)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

LLM 응답을 문자열로 받아 직접 파싱하는 대신 .entity()에 Java record 타입을 넘기면, Spring AI가 JSON 스키마 지시문을 프롬프트에 덧붙이고 응답을 객체로 변환해 줍니다. 제네릭 리스트처럼 타입 소거가 일어나는 경우에는 ParameterizedTypeReference를 사용합니다.
TicketClassifier.javaJAVA
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;

record TicketTriage(String category, String priority, String summary, List<String> tags) {}

@Service
class TicketClassifier {
    private final ChatClient chatClient;

    TicketClassifier(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    TicketTriage classify(String ticketBody) {
        return chatClient.prompt()
            .system("고객 문의를 분류한다. priority는 P1, P2, P3 중 하나만 사용한다.")
            .user(ticketBody)
            .call()
            .entity(TicketTriage.class);
    }

    List<TicketTriage> classifyAll(String bulkText) {
        return chatClient.prompt()
            .user(bulkText)
            .call()
            .entity(new ParameterizedTypeReference<List<TicketTriage>>() {});
    }
}

Tip

  • 모델이 스키마를 100% 지킨다는 보장은 없습니다. 변환 결과는 Bean Validation 등으로 한 번 더 검증하고, 실패 시 재시도하거나 사람 검토 큐로 보내는 경로를 준비하세요.
  • priority처럼 값의 범위가 정해진 필드는 enum으로 선언하면 허용되지 않은 값이 들어왔을 때 변환 단계에서 바로 실패해 오류를 조기에 잡을 수 있습니다.

대화 메모리

여기서는 대화 메모리을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

LLM은 상태가 없으므로 이전 대화를 기억하게 하려면 매 요청에 이전 메시지를 함께 보내야 합니다. MessageChatMemoryAdvisor가 이 일을 대신하며, 저장소는 ChatMemoryRepository로 교체할 수 있습니다(기본은 인메모리). Spring AI 2.0부터는 기본 conversation id가 없어졌으므로, 요청마다 ChatMemory.CONVERSATION_ID 파라미터를 넘기지 않으면 IllegalArgumentException이 발생합니다.
MemoryChatConfig.javaJAVA
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class MemoryChatConfig {
    @Bean
    ChatMemory chatMemory(ChatMemoryRepository repository) {
        // 최근 20개 메시지만 유지 — 토큰 비용과 컨텍스트 길이를 제한
        return MessageWindowChatMemory.builder()
            .chatMemoryRepository(repository)
            .maxMessages(20)
            .build();
    }

    @Bean
    ChatClient memoryChatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
        return builder
            .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
            .build();
    }
}
ConversationService.javaJAVA
String reply(String userId, String sessionId, String message) {
    // 사용자·세션 조합으로 id를 만들어 다른 사용자의 대화가 섞이지 않게 한다
    String conversationId = userId + ":" + sessionId;

    return memoryChatClient.prompt()
        .user(message)
        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
        .call()
        .content();
}
application.yml (JDBC 저장소)YAML
# implementation("org.springframework.ai:spring-ai-starter-model-chat-memory-repository-jdbc")
spring:
  ai:
    chat:
      memory:
        repository:
          jdbc:
            initialize-schema: always   # 운영에서는 never + Flyway 마이그레이션 권장

Tip

  • conversation id를 클라이언트가 보낸 값 그대로 쓰면 다른 사람의 대화 기록을 읽어올 수 있습니다. 반드시 인증된 사용자 id와 결합하거나 서버에서 발급한 값만 사용하세요.
  • 1.x의 JDBC 메모리 테이블을 쓰던 프로젝트는 2.0에서 메시지 순서용 sequence_id 컬럼이 추가되었으므로 스키마 마이그레이션이 필요합니다.

Tool Calling

여기서는 Tool Calling을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

Tool Calling은 모델이 "이 함수를 이런 인자로 실행해 달라"고 요청하면 애플리케이션이 실제 메서드를 실행해 결과를 다시 모델에 돌려주는 구조입니다. 모델이 직접 코드를 실행하는 것이 아니라, 어떤 도구를 쓸지 결정만 하고 실행 권한은 여전히 우리 서버에 있습니다. @Tool의 description은 모델이 도구를 고르는 유일한 근거이므로 사람에게 설명하듯 구체적으로 적어야 합니다.
OrderTools.javaJAVA
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

@Component
class OrderTools {
    private final OrderRepository orders;

    OrderTools(OrderRepository orders) {
        this.orders = orders;
    }

    @Tool(description = "주문 번호로 주문 상태와 배송 예정일을 조회한다")
    OrderStatus getOrderStatus(@ToolParam(description = "주문 번호, 예: ORD-2026-0001") String orderId) {
        return orders.findStatus(orderId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 주문: " + orderId));
    }
}
SupportService.javaJAVA
String answer(String question) {
    return chatClient.prompt()
        .user(question)            // "ORD-2026-0001 언제 도착해요?"
        .tools(orderTools)         // 이 요청에서만 사용할 도구를 명시적으로 연결
        .call()
        .content();
}
application.ymlYAML
spring:
  ai:
    tools:
      limits:
        max-calls-per-tool-default: 5    # 기본 40 — 서비스 성격에 맞게 낮춘다
        max-total-tool-calls: 15         # 기본 150
        on-limit-exceeded: THROW

Tip

  • 도구 안에서 권한 검사를 반드시 하세요. 모델은 사용자 입력에 따라 어떤 인자든 만들어낼 수 있으므로, "다른 사람의 주문 번호"가 들어오는 경우를 서버 코드가 막아야 합니다.
  • 결제·삭제처럼 되돌릴 수 없는 작업은 도구가 바로 실행하지 말고 "확인 대기" 상태만 만든 뒤 사용자가 UI에서 승인하게 하는 2단계 구조가 안전합니다.
  • 2.0부터 도구는 요청에 명시적으로 연결해야 하며(빈 이름 기반 자동 해석은 기본 비활성), 도구 호출 횟수 제한이 기본 적용됩니다. 무한 도구 호출 루프로 인한 비용 폭증을 막는 안전장치이니 끄지 마세요.

RAG & 벡터 스토어

여기서는 RAG & 벡터 스토어을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

RAG(Retrieval-Augmented Generation)는 질문과 의미가 가까운 문서 조각을 벡터 스토어에서 찾아 프롬프트에 함께 넣어, 모델이 학습하지 않은 사내 문서를 근거로 답하게 하는 패턴입니다. 수집 단계(문서 읽기 → 청크 분할 → 임베딩 → 저장)와 질의 단계(검색 → 프롬프트 보강 → 생성)를 분리해서 설계합니다. 간단한 경우는 QuestionAnswerAdvisor, 쿼리 재작성·다중 검색이 필요하면 spring-ai-rag의 RetrievalAugmentationAdvisor를 씁니다.
application.ymlYAML
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/ragdb
    username: rag
    password: ${DB_PASSWORD}
  ai:
    vectorstore:
      pgvector:
        initialize-schema: true
        index-type: HNSW
        distance-type: COSINE_DISTANCE
        dimensions: 1536   # 임베딩 모델 차원과 반드시 일치
DocumentIngestor.javaJAVA
import java.util.List;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

@Service
class DocumentIngestor {
    private final VectorStore vectorStore;

    DocumentIngestor(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    void ingest(Resource file, String team) {
        List<Document> raw = new TextReader(file).get();
        List<Document> chunks = new TokenTextSplitter().apply(raw);
        // 메타데이터를 붙여 두면 검색 시 팀/권한별로 필터링할 수 있다
        chunks.forEach(doc -> doc.getMetadata().put("team", team));
        vectorStore.add(chunks);   // 임베딩 계산 + 저장
    }
}
DocsQaService.javaJAVA
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

@Service
class DocsQaService {
    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    DocsQaService(ChatClient chatClient, VectorStore vectorStore) {
        this.chatClient = chatClient;
        this.vectorStore = vectorStore;
    }

    String ask(String question, String team) {
        var qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
            .searchRequest(SearchRequest.builder()
                .topK(5)
                .similarityThreshold(0.7)
                .filterExpression("team == '" + team + "'")
                .build())
            .build();

        return chatClient.prompt()
            .advisors(qaAdvisor)
            .user(question)
            .call()
            .content();
    }
}
튜닝 포인트너무 작으면너무 크면
청크 크기문맥이 끊겨 답의 근거가 부족관련 없는 내용이 섞여 정확도 저하·토큰 비용 증가
topK정답 문서를 놓침 (recall 저하)노이즈 증가, 프롬프트 길이 폭증
similarityThreshold무관한 문서까지 포함검색 결과 0건 → 모델이 근거 없이 답변

Tip

  • filterExpression에 들어가는 값(team 등)은 사용자 입력이 아니라 인증 정보에서 가져오세요. 문서 권한 필터는 RAG 보안의 핵심입니다.
  • 임베딩 모델을 바꾸면 기존 벡터와 차원·공간이 달라져 검색이 무의미해집니다. 모델 변경 시에는 전체 재색인을 계획하세요.
  • Anthropic처럼 임베딩 API가 없는 제공자를 채팅 모델로 쓸 때는 임베딩만 OpenAI·Ollama 등 별도 스타터로 구성하면 됩니다.

MCP 서버 만들기

여기서는 MCP 서버 만들기을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

MCP(Model Context Protocol)는 Claude Desktop, IDE 에이전트 같은 AI 클라이언트가 외부 도구를 표준 방식으로 호출하게 하는 프로토콜입니다. Spring AI의 MCP 서버 스타터를 쓰면 기존 Spring 서비스 메서드에 @McpTool만 붙여 사내 시스템을 AI 에이전트에 안전하게 노출할 수 있습니다. 원격 서버는 WebMVC/WebFlux 스타터(Streamable HTTP), 로컬 프로세스는 STDIO 스타터를 사용합니다.
build.gradle.ktsKOTLIN
dependencies {
    implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc")
}
application.ymlYAML
spring:
  ai:
    mcp:
      server:
        name: order-mcp-server
        version: 1.0.0
        type: SYNC
        protocol: STREAMABLE
OrderMcpTools.javaJAVA
@Component
class OrderMcpTools {
    private final OrderRepository orders;

    OrderMcpTools(OrderRepository orders) {
        this.orders = orders;
    }

    @McpTool(name = "get_order_status", description = "주문 번호로 주문 상태를 조회한다")
    OrderStatus getOrderStatus(
            @McpToolParam(description = "주문 번호", required = true) String orderId) {
        return orders.findStatus(orderId).orElseThrow();
    }
}

Tip

  • MCP 서버를 네트워크에 공개할 때는 Spring Security로 인증을 붙이세요. MCP 엔드포인트는 곧 "AI가 호출할 수 있는 내부 API"이므로 일반 REST API와 같은 수준의 인증·감사 로그가 필요합니다.
  • MCP 개념과 클라이언트 쪽 구성은 MCP 가이드에서 더 자세히 다룹니다.

관측성 & 운영

여기서는 관측성 & 운영을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

Spring AI는 Micrometer Observation을 내장하고 있어 Actuator만 켜면 ChatClient 호출, 모델 호출, 벡터 검색, 도구 실행이 메트릭과 트레이스로 기록됩니다. LLM 기능은 응답 시간과 비용의 변동 폭이 크기 때문에, 일반 API보다 토큰 사용량과 지연 시간을 더 촘촘히 봐야 합니다.
application.ymlYAML
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
  tracing:
    sampling:
      probability: 0.1

spring:
  ai:
    chat:
      observations:
        log-prompt: false       # 프롬프트에 개인정보가 섞일 수 있어 운영은 false
        log-completion: false
지표보는 이유알림 예시
gen_ai_client_token_usage_total입력/출력 토큰 = 비용일일 토큰이 평소 대비 2배 초과
gen_ai_chat_client_operation_seconds모델 응답 지연p95가 10초 초과 5분 지속
모델 호출 에러율429(rate limit)·5xx 증가 감지에러율 5% 초과
db_vector_client_operation_seconds벡터 검색 지연p95가 500ms 초과

Tip

  • 모델 제공자의 429/5xx에 대비해 spring.ai.retry.* 로 재시도 횟수와 백오프를 설정하고, Gateway 쪽에는 서킷 브레이커를 두어 장애가 전체 서비스로 번지지 않게 하세요.
  • 사용자별 토큰 사용량을 기록해 두면 요금제 한도, 남용 탐지, 기능별 원가 계산에 그대로 쓸 수 있습니다.

테스트 전략

여기서는 테스트 전략을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.

LLM 응답은 매번 달라지므로 "문자열이 정확히 일치하는가"로 테스트하면 금방 깨집니다. 비즈니스 로직은 ChatModel을 목(mock)으로 바꿔 결정적으로 테스트하고, 실제 모델 품질은 소수의 평가 테스트에서 "관련성이 있는가", "근거에 기반했는가"를 판정하는 방식으로 분리합니다. Spring AI는 이런 판정을 위해 RelevancyEvaluator, FactCheckingEvaluator를 제공합니다.
TicketClassifierTest.javaJAVA
@SpringBootTest
class TicketClassifierTest {
    @MockitoBean
    ChatModel chatModel;

    @Autowired
    TicketClassifier classifier;

    @Test
    void 모델_응답을_record로_변환한다() {
        var json = """
            {"category":"BILLING","priority":"P2","summary":"환불 문의","tags":["refund"]}
            """;
        given(chatModel.call(any(Prompt.class)))
            .willReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(json)))));

        TicketTriage result = classifier.classify("결제가 두 번 됐어요");

        assertThat(result.priority()).isEqualTo("P2");
        assertThat(result.tags()).contains("refund");
    }
}
DocsQaEvaluationIT.javaJAVA
@SpringBootTest
@Tag("llm-eval")   // CI 기본 실행에서 제외하고 야간/수동으로만 실행
class DocsQaEvaluationIT {
    @Autowired ChatClient.Builder builder;
    @Autowired DocsQaService qa;

    @Test
    void 답변이_질문과_관련있다() {
        String question = "배포 승인 절차는?";
        String answer = qa.ask(question, "platform");

        var evaluator = new RelevancyEvaluator(builder);
        var response = evaluator.evaluate(new EvaluationRequest(question, List.of(), answer));

        assertThat(response.isPass()).isTrue();
    }
}

Tip

  • 실제 모델을 호출하는 테스트는 비용과 시간이 들고 결과가 흔들립니다. 태그로 분리해 PR마다가 아니라 야간 배치나 프롬프트 변경 시에만 돌리세요.
  • 프롬프트 파일(.st)을 수정할 때는 평가 테스트 결과를 PR에 첨부하는 규칙을 두면, "프롬프트 한 줄 바꿨더니 다른 답변이 망가지는" 회귀를 리뷰 단계에서 잡을 수 있습니다.
← 이전 가이드Java다음 가이드 →Spring Security