Spring AI를 실무 흐름으로 이해하기
Spring AI 2.0으로 ChatClient, 프롬프트 템플릿, 구조화 출력, 대화 메모리, Tool Calling, RAG(벡터 스토어), MCP 서버까지 Spring Boot 방식 그대로 LLM 기능을 서비스에 붙이는 방법을 정리합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
Spring AI 2.0으로 ChatClient, 프롬프트 템플릿, 구조화 출력, 대화 메모리, Tool Calling, RAG(벡터 스토어), MCP 서버까지 Spring Boot 방식 그대로 LLM 기능을 서비스에 붙이는 방법을 정리합니다.
Spring AI 2.0으로 ChatClient, 프롬프트 템플릿, 구조화 출력, 대화 메모리, Tool Calling, RAG(벡터 스토어), MCP 서버까지 Spring Boot 방식 그대로 LLM 기능을 서비스에 붙이는 방법을 정리합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
문법보다 요청이 들어와 검증, 처리, 저장, 응답으로 이어지는 경계를 먼저 잡습니다.
글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Spring AI를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.
Spring AI를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.
| 구성 요소 | 역할 | 대표 API |
|---|---|---|
| ChatClient | 프롬프트 조립·호출·응답 변환을 담당하는 fluent API | prompt().user().call().content() |
| Advisor | 요청/응답 사이에 끼어드는 체인 (메모리, RAG, 로깅) | MessageChatMemoryAdvisor, QuestionAnswerAdvisor |
| Structured Output | LLM 응답을 Java record/POJO로 매핑 | .entity(MyRecord.class) |
| Tool Calling | 모델이 애플리케이션 메서드 실행을 요청 | @Tool, .tools(...) |
| VectorStore | 임베딩 저장·유사도 검색 (RAG의 검색 단계) | add(), similaritySearch() |
| MCP | Model Context Protocol 서버/클라이언트 부트 스타터 | spring-ai-starter-mcp-server-webmvc |
여기서는 프로젝트 설정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
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")}")
}
}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여기서는 ChatClient 기본을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
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();
}
}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();
}
}여기서는 프롬프트 템플릿을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
다음 {language} 코드를 리뷰해줘.
- 버그 가능성이 있는 부분을 먼저 지적한다.
- 각 항목에 심각도(HIGH/MEDIUM/LOW)를 붙인다.
코드:
{code}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();
}
}여기서는 구조화 출력 (POJO 매핑)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
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>>() {});
}
}여기서는 대화 메모리을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
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();
}
}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();
}# implementation("org.springframework.ai:spring-ai-starter-model-chat-memory-repository-jdbc")
spring:
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always # 운영에서는 never + Flyway 마이그레이션 권장여기서는 Tool Calling을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
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));
}
}String answer(String question) {
return chatClient.prompt()
.user(question) // "ORD-2026-0001 언제 도착해요?"
.tools(orderTools) // 이 요청에서만 사용할 도구를 명시적으로 연결
.call()
.content();
}spring:
ai:
tools:
limits:
max-calls-per-tool-default: 5 # 기본 40 — 서비스 성격에 맞게 낮춘다
max-total-tool-calls: 15 # 기본 150
on-limit-exceeded: THROW여기서는 RAG & 벡터 스토어을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
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 # 임베딩 모델 차원과 반드시 일치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); // 임베딩 계산 + 저장
}
}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건 → 모델이 근거 없이 답변 |
여기서는 MCP 서버 만들기을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
dependencies {
implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc")
}spring:
ai:
mcp:
server:
name: order-mcp-server
version: 1.0.0
type: SYNC
protocol: STREAMABLE@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();
}
}여기서는 관측성 & 운영을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
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 초과 |
여기서는 테스트 전략을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
@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");
}
}@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();
}
}