Spring Boot 4.1.1 ์ต์ ํ๊ฒฝ ๋ฐ Spring AI 2.0.1 GA ๊ธฐ์ค: ChatClient 2.0 Fluent API, @Tool ๊ธฐ๋ฐ ToolCallingAdvisor, Virtual Threads I/O ์ต์ ํ, Streaming SSE, Structured Output, RAG ํ์ดํ๋ผ์ธ ๋ฐ VectorStore ์ค์ ๊ฐ์ด๋
## 1. Spring AI 2.0 ์ํคํ
์ฒ & ์ต์ ์์กด์ฑ (Spring Boot 4.1.1 + Spring AI 2.0.1)
์ต์ **Spring Boot 4.1.1** ๋ฐ **Spring AI 2.0.1 GA** ํ๊ฒฝ์ Java 17~26, Jackson 3, HttpClient 5.6, ๊ฐ์ ์ค๋ ๋(Virtual Threads)๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํ์ฌ ๋๊ท๋ชจ LLM I/O ํธ์ถ์ ๊ณ ์ฑ๋ฅยท์ ๋น์ฉ์ผ๋ก ์ฒ๋ฆฌํ ์ ์๋ ์ํฐํ๋ผ์ด์ฆ AI ํ์ค ์กฐํฉ์
๋๋ค.
```text
[Spring Boot 4.1.1 + Spring AI 2.0 ์ํคํ
์ฒ]
Application : Spring Boot 4.1.1 (Virtual Threads ํ์ฑํ, Java 17/21/25/26 ์ง์)
Client Layer : ChatClient (Fluent 2.0 API, Tools & Advisors ํตํฉ)
Advisor Layer : ToolCallingAdvisor(๋๊ตฌ ์คํ ๋ฃจํ ์ค์ํ), ChatMemoryAdvisor, RAG Advisor
Model Layer : ChatModel, EmbeddingModel, ImageModel, AudioModel
Data/RAG Layer : VectorStore (PGvector, Chroma, Qdrant, Milvus), Document, TextSplitter
Provider Layer : OpenAI (GPT-4o), Anthropic (Claude 3.5), Ollama (๋ก์ปฌ LLM), DeepSeek, Gemini
```
### ๐ ๋ฒ์ ํธํ์ฑ ๋งคํธ๋ฆญ์ค (Compatibility Matrix)
| Spring Boot ๋ฒ์ | ๊ถ์ฅ Spring AI ๋ฒ์ | Java ์๊ตฌ ์ฌํญ | ๋น๊ณ |
| :--- | :--- | :--- | :--- |
| **Spring Boot 4.1.1 (ํ์ฌ ์ต์ )** | **Spring AI 2.0.1 GA** | **Java 17, 21, 25, 26** | **๊ณต์ ๊ถ์ฅ ์ต์ ์กฐํฉ**, Maven Central ์ ์ ๋ฐฐํฌ, JSpecify ๋ ์์ ์ฑ |
| Spring Boot 4.0.x | Spring AI 2.0.0 GA | Java 17, 21 | ToolCallingAdvisor ์ค์ํ ๋์
|
| Spring Boot 3.3.x / 3.4.x | Spring AI 1.0.0-M6 | Java 17+ | ๊ตฌ๋ฒ์ ๋ง์ผ์คํค ๋ ํฌ์งํ ๋ฆฌ(`repo.spring.io/milestone`) ํ์ |
### ๐ Gradle (`build.gradle`) ์ค์
Spring Boot 4.1.1๊ณผ Spring AI 2.0.1์ **Maven Central์ ์ ์ ๋ฐฐํฌ**๋๋ฏ๋ก ๋ณ๋์ ๋ง์ผ์คํค ์ ์ฅ์ ์ถ๊ฐ ์์ด ์ฆ์ ๋น๋๋ฉ๋๋ค.
```groovy
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.1' // ๐ ์ต์ Spring Boot 4.1.1
id 'io.spring.dependency-management' version '1.1.7' // ๐ ์ต์ ์์กด์ฑ ๊ด๋ฆฌ ํ๋ฌ๊ทธ์ธ
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25) // Java 25
}
}
ext {
// ๐ Spring AI 2.0.1 GA (Spring Boot 4.x ๊ณต์ ๋ฒ ์ด์ค๋ผ์ธ)
set('springAiVersion', "2.0.1")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
// 1. OpenAI ์คํํฐ (Chat, Embedding, Image, Whisper, Vision ํตํฉ)
implementation "org.springframework.ai:spring-ai-openai-spring-boot-starter"
// 2. ๋๋ ์์ ๋ฌด๋ฃ ์จํ๋ ๋ฏธ์ค ๋ก์ปฌ LLM (Ollama)
// implementation "org.springframework.ai:spring-ai-ollama-spring-boot-starter"
// 3. ๋๋ Anthropic Claude ์คํํฐ
// implementation "org.springframework.ai:spring-ai-anthropic-spring-boot-starter"
// 4. PostgreSQL PGvector ๋ฒกํฐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๋
// implementation "org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter"
}
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
}
}
```
### ๐ชถ Maven (`pom.xml`) ์ค์
```xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<properties>
<java.version>25</java.version>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Maven Central์์ ์ง์ ๋ค์ด๋ก๋๋ฉ๋๋ค -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
</dependencies>
```
## 2. application.yml ํ๋ก๋ฐ์ด๋๋ณ ์ค์ (OpenAI ยท Claude ยท Ollama ยท DeepSeek)
Spring Boot 4.1.1์ `application.yml`์ API ํค์ ๋ชจ๋ธ ์ต์
์ ์ง์ ํ๋ฉด ์ต์ ํ๋ `ChatModel`๊ณผ `EmbeddingModel`์ด ์คํ๋ง ๋น์ผ๋ก ์๋ ๋ฑ๋ก๋ฉ๋๋ค. ํนํ ๊ธด ๋คํธ์ํฌ I/O๊ฐ ๋ฐ์ํ๋ AI ์๋น์ค ํน์ฑ์ **๊ฐ์ ์ค๋ ๋(Virtual Threads)** ํ์ฑํ๋ฅผ ์ ๊ทน ๊ถ์ฅํฉ๋๋ค.
### ๐ OpenAI ๋ฐ ํธํ API (vLLM, Groq, DeepSeek)
```yaml
spring:
# ๐ Spring Boot 4.1.1 ๊ถ์ฅ: AI I/O ๋๊ธฐ ์ ํฐ์บฃ ์ค๋ ๋ ๊ณ ๊ฐ ๋ฐฉ์ง (์ด๊ณ ์ฑ๋ฅ ์ฒ๋ฆฌ)
threads:
virtual:
enabled: true
ai:
openai:
api-key: ${OPENAI_API_KEY}
base-url: https://api.openai.com # vLLM์ด๋ Groq ๋ฑ OpenAI ํธํ ์๋ํฌ์ธํธ ์ง์
chat:
options:
model: gpt-4o-mini # gpt-4o, o1-mini, gpt-4-turbo ๋ฑ
temperature: 0.7 # 0.0(์ ๋ฐ/๋
ผ๋ฆฌ) ~ 1.0(์ฐฝ์์ )
max-tokens: 4096
embedding:
options:
model: text-embedding-3-small
# 2.0.1 ๋๊ตฌ ๋ณด์ ์ ์ฑ
(๋ช
์์ ๋ฑ๋ก ๋๊ตฌ๋ง ๊ฒฉ๋ฆฌ ์คํ)
tools:
resolution:
fallback:
enabled: false # ๋ณด์ ๊ถ์ฅ: ๋ฏธ๋ฑ๋ก ๋๊ตฌ์ ๊ธ๋ก๋ฒ ๋์คํจ์น ๋ฐฉ์ง (CVE-2026-59318 ๋ฐฉ์ด)
```
### ๐ฆ Ollama (๋ก์ปฌ ์คํ์์ค LLM: Llama 3.1, DeepSeek-Coder, Qwen)
GPU/CPU ๋ก์ปฌ ์๋ฒ์์ API ๋น์ฉ ์์ด ์์ ๋ฌด๋ฃ๋ก ์คํ:
```yaml
spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
options:
model: llama3.1:8b # deepseek-r1:8b, qwen2.5:7b ๋ฑ
temperature: 0.3
embedding:
options:
model: nomic-embed-text
```
### ๐ฃ Anthropic Claude & Google Gemini
```yaml
spring:
ai:
anthropic:
api-key: ${ANTHROPIC_API_KEY}
chat:
options:
model: claude-3-5-sonnet-20241022
max-tokens: 8192
vertex:
ai:
gemini:
project-id: my-gcp-project
location: us-central1
chat:
options:
model: gemini-1.5-pro
```
## 3. ChatClient 2.0 Fluent API (ํ๋์ ๋ํํ ๋ชจ๋ธ ์ธํฐํ์ด์ค)
Spring AI 2.0์ `ChatClient`๋ ๋ชจ๋ธ ๋
๋ฆฝ์ (Model-agnostic)์ธ ๊ณ ์์ค Fluent API์
๋๋ค. ๋น๋๋ฅผ ํตํด ์ ์ญ ์์คํ
ํ๋กฌํํธ, ๋๊ตฌ, ์ด๋๋ฐ์ด์ ์ฒด์ธ์ ๋ชจ๋์์ผ๋ก ์ฅ์ฐฉํฉ๋๋ค.
### ๐ ๏ธ ChatClient ๋น ๋ฑ๋ก (`@Configuration`)
```java
package com.lucky.ai.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AiConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder
// ๋ชจ๋ ๋ํ์ ๊ธฐ๋ณธ ์ ์ฉ๋ ์์คํ
ํ๋กฌํํธ
.defaultSystem("๋น์ ์ ์น์ ํ๊ณ ์ ๋ฌธ์ ์ธ IT ๊ธฐ์ ์ปจ์คํดํธ์
๋๋ค. ํญ์ ๋ช
ํํ ํ๊ตญ์ด๋ก ๋ต๋ณํ์ธ์.")
// ๋ชจ๋ธ ๊ธฐ๋ณธ ํ๋ผ๋ฏธํฐ (์จ๋, ํ ํฐ ๋ฑ)
.defaultOptions(options -> options.temperature(0.3))
.build();
}
}
```
### ๐ป Controller์์ ChatClient ํธ์ถ ์ค์
```java
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatClient chatClient;
// 1. ๋จ์ผ ํ๋กฌํํธ ๋ฌธ์์ด ์๋ต
@GetMapping("/simple")
public String simpleChat(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.call()
.content(); // String ํ
์คํธ ๋ฐํ
}
// 2. ๋์ ํ๋ผ๋ฏธํฐ ์นํ & ๋ฉํ๋ฐ์ดํฐ(ํ ํฐ, ๋น์ฉ) ํ๋
@GetMapping("/expert")
public ChatResponse expertChat(@RequestParam String topic, @RequestParam String lang) {
return chatClient.prompt()
.system(s -> s.text("์ ๋ฌธ ๋ถ์ผ: {field}").param("field", "์ํํธ์จ์ด ์ํคํ
์ฒ"))
.user(u -> u.text("{topic}์ ๋ํด {lang} ์ธ์ด ๊ด์ ์์ ํต์ฌ ์ฃผ์์ฌํญ์ 3์ค ์์ฝํด์ค.")
.param("topic", topic)
.param("lang", lang))
.call()
.chatResponse(); // ChatResponse: ํ ํฐ ์ฌ์ฉ๋, FinishReason ๋ฑ ์ ์ฒด ๋ฉํ๋ฐ์ดํฐ
}
}
```
## 4. ์คํธ๋ฆฌ๋ฐ(Streaming) & Server-Sent Events (SSE) ์ค์๊ฐ ์๋ต
๋ชจ๋ธ์ ๊ธด ์์ฑ ์๊ฐ์ ๊ธฐ๋ค๋ฆฌ์ง ์๊ณ ์ค์๊ฐ์ผ๋ก ์์ฑ๋๋ ํ ํฐ์ ํ๋ฉด์ ์ฆ์ ์คํธ๋ฆฌ๋ฐํฉ๋๋ค. Spring AI 2.0์ `ChatClient.stream()`์ WebFlux `Flux<String>`๊ณผ ์๋ฒฝํ๊ฒ ํธํ๋ฉ๋๋ค.
### โก Reactive ์คํธ๋ฆฌ๋ฐ ์ปจํธ๋กค๋ฌ (`Flux<String>`)
```java
package com.lucky.ai.controller;
import lombok.RequiredArgsConstructor;
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/ai")
@RequiredArgsConstructor
public class StreamController {
private final ChatClient chatClient;
// text/event-stream ์ผ๋ก ์ค์๊ฐ ๊ธ์ ๋จ์ ๋ฐฉ์ถ
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String prompt) {
return chatClient.prompt()
.user(prompt)
.stream() // call() ๋์ stream() ํธ์ถ!
.content(); // Flux<String> ์ค์๊ฐ ์คํธ๋ฆผ
}
}
```
### ๐ ํ๋ก ํธ์๋ ์ค์๊ฐ SSE ์์ ์ฝ๋
```javascript
// ๋ธ๋ผ์ฐ์ ์์ Server-Sent Events ์์
const eventSource = new EventSource('/api/ai/stream?prompt=' + encodeURIComponent('Spring AI 2.0 ์ฅ์ '));
eventSource.onmessage = (event) => {
// ์ค์๊ฐ ํ ํฐ ๋์ ๋ ๋๋ง
document.getElementById('chat-box').textContent += event.data;
};
eventSource.onerror = (err) => {
console.log('์๋ต ์คํธ๋ฆผ ์ข
๋ฃ ๋๋ ๋คํธ์ํฌ ํด์ ');
eventSource.close();
};
```
## 5. ๊ตฌ์กฐํ๋ ์ถ๋ ฅ (Structured Output & Record ๋งคํ)
LLM์ ์์ ๋ก์ด ์์ฐ์ด ์๋ต์ Java 17+ `Record`๋ DTO ๊ฐ์ฒด๋ก ์๊ฒฉํ๊ณ ์์ ํ๊ฒ(Type-Safe) ์๋ ํ์ฑํฉ๋๋ค.
### ๐ ์๋ต ๋ฐ์ Java Record ์ ์ธ
```java
public record TechArticle(
String title,
String author,
List<String> tags,
int difficultyLevel,
String summary
) {}
public record CurationResponse(
String category,
List<TechArticle> articles
) {}
```
### ๐ฏ `entity(...)`๋ฅผ ํ์ฉํ ๊ฐ์ฒด ์๋ ๋งคํ
```java
@Service
@RequiredArgsConstructor
public class CurationService {
private final ChatClient chatClient;
// ๋จ์ผ Record ์๋ ์ญ์ง๋ ฌํ
public TechArticle getRecommendedArticle(String keyword) {
return chatClient.prompt()
.user(u -> u.text("{keyword} ๋ถ์ผ์ ๋ฒ ์คํธ ์ํฐํด ์ ๋ณด๋ฅผ ์ถ์ฒํด์ค.")
.param("keyword", keyword))
.call()
.entity(TechArticle.class); // ๋ด๋ถ JSON ์คํค๋ง ์ฃผ์
๋ฐ ์๋ ๋งคํ!
}
// List ์ปฌ๋ ์
์ง์ ๋งคํ (ParameterizedTypeReference)
public List<TechArticle> getArticleList(String keyword) {
return chatClient.prompt()
.user(keyword + " ๊ด๋ จ ์ต์ ๊ธ 3ํธ์ ์ถ์ฒํด์ค.")
.call()
.entity(new ParameterizedTypeReference<List<TechArticle>>() {});
}
}
```
## 6. ํ๋กฌํํธ ํ
ํ๋ฆฟ & ๋ฉ์์ง ์ญํ ๊ณ์ธต (PromptTemplate & Roles)
๋ฐ๋ณต๋๋ ํ๋กฌํํธ ํจํด์ ํ์ผ(`.st` StringTemplate)๋ก ๋ถ๋ฆฌ ๊ด๋ฆฌํ๊ณ , ์ญํ (System, User, Assistant)๋ณ ์ปจํ
์คํธ๋ฅผ ๊ตฌ์กฐํํฉ๋๋ค.
### ๐ PromptTemplate๊ณผ ๋ณ์ ์ฃผ์
```java
import org.springframework.ai.chat.prompt.PromptTemplate;
@GetMapping("/review-diff")
public String reviewCodeDiff(@RequestParam String diffText) {
String templateString = """
๋น์ ์ ์ฝ๋ ํ์ง ๊ฐ์ฌ ์ ๋ฌธ๊ฐ์
๋๋ค. ์๋ Git Diff๋ฅผ ๊ฒํ ํ๊ณ :
1. ์ ์ฌ์ ๋ฒ๊ทธ
2. ์ฑ๋ฅ ๋ณ๋ชฉ ์ง์
3. ๋ฆฌํฉํ ๋ง ๊ถ์ฅ ์ฌํญ์ ํ๊ตญ์ด๋ก ์์ฑํ์ธ์.
[Git Diff]:
{diff}
""";
PromptTemplate template = new PromptTemplate(templateString);
Prompt prompt = template.create(Map.of("diff", diffText));
return chatClient.prompt(prompt).call().content();
}
```
### ๐ ์ธ๋ถ ๋ฆฌ์์ค ํ์ผ์์ ํ๋กฌํํธ ๋ก๋ฉ
`src/main/resources/prompts/sql-generator.st` ํ์ผ ๊ด๋ฆฌ:
```java
@RestController
public class SqlPromptController {
@Value("classpath:prompts/sql-generator.st")
private Resource sqlPromptResource;
private final ChatClient chatClient;
@PostMapping("/generate-sql")
public String generateSql(@RequestBody String userQuery) {
return chatClient.prompt()
.user(u -> u.text(sqlPromptResource)
.param("query", userQuery)
.param("dialect", "PostgreSQL 16"))
.call()
.content();
}
}
```
## 7. Spring AI 2.0 ๋๊ตฌ ํธ์ถ (@Tool & ToolCallingAdvisor)
Spring AI 2.0์ ์ต๋ ๋ณ๊ฒฝ ์ฌํญ์
๋๋ค! ์ด์ ์ ๋ณต์กํ ํจ์ ๋ฑ๋ก ๋์ , **`@Tool` ์ด๋
ธํ
์ด์
**์ ๋ฉ์๋์ ๋ถ์ฌํ๊ณ `ChatClient.prompt().tools(...)`๋ก ๋๊ธฐ๋ฉด `ToolCallingAdvisor`๊ฐ ๋ชจ๋ธ๊ณผ์ ๋๊ตฌ ํธ์ถ ๋ฃจํ๋ฅผ ์๋ฒฝํ๊ฒ ์๋ ์ฒ๋ฆฌํฉ๋๋ค.
```text
[Spring AI 2.0 ๋๊ตฌ ์คํ ๋ฃจํ]
1. ์ฌ์ฉ์ ์ง๋ฌธ : "์์ธ ๋ ์จ์ ํ์จ ์๋ ค์ค"
2. ChatClient : ๋ชจ๋ธ์ ๋ฑ๋ก๋ ๋๊ตฌ ์คํค๋ง(@Tool) ์ ์ก
3. LLM ๊ฒฐ์ : getWeatherTool(), getExchangeRateTool() ํธ์ถ ์์ฒญ
4. ToolCallingAdvisor : ์คํ๋ง ๋ด Java ๋ฉ์๋ ์ง์ ์คํ ๋ฐ ๊ฒฐ๊ณผ ์ทจํฉ
5. LLM ์ต์ข
์๋ต: ์ทจํฉ๋ ๋๊ตฌ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก ์์ฐ์ค๋ฌ์ด ํ๊ตญ์ด ๋ต๋ณ ์์ฑ
```
### ๐ ๏ธ 1. `@Tool` ์ปดํฌ๋ํธ ์ ์ธ
```java
package com.lucky.ai.tools;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
@Component
public class ExternalApiTools {
public record WeatherInfo(String city, double temperature, String condition) {}
// @Tool: ๋ชจ๋ธ์ด ์ฝ์ ์ ์๋ ๋๊ตฌ ์ค๋ช
๋ช
์
@Tool(description = "์ฃผ์ด์ง ๋์์ ์ค์๊ฐ ๋ ์จ์ ์จ๋๋ฅผ ์กฐํํฉ๋๋ค.")
public WeatherInfo getWeather(
@ToolParam(description = "์กฐํํ ๋์ ์ด๋ฆ (์: Seoul, Tokyo)") String city) {
System.out.println("๋๊ตฌ ์คํ๋จ: city = " + city);
// ์ค์ ๊ธฐ์์ฒญ API ์ฐ๋ ๋๋ DB ์ฟผ๋ฆฌ
return new WeatherInfo(city, 23.5, "๋ง์");
}
@Tool(description = "ํตํ ์ฝ๋(USD, JPY ๋ฑ)์ ๋ํ ํ์ฌ ์ํ(KRW) ํ์จ์ ์กฐํํฉ๋๋ค.")
public double getExchangeRate(@ToolParam(description = "ํตํ ์ฝ๋") String currency) {
if ("USD".equalsIgnoreCase(currency)) return 1380.50;
if ("JPY".equalsIgnoreCase(currency)) return 920.30;
return 1000.0;
}
}
```
### ๐ 2. ChatClient์์ ๋๊ตฌ ์ฅ์ฐฉ ๋ฐ ์คํ
```java
@RestController
@RequiredArgsConstructor
public class AssistantController {
private final ChatClient chatClient;
private final ExternalApiTools apiTools; // @Tool ์ปดํฌ๋ํธ ์ฃผ์
@GetMapping("/ask-assistant")
public String askAssistant(@RequestParam String query) {
return chatClient.prompt()
.user(query) // ์: "๋์ฟ ๋ ์จ ์๋ ค์ฃผ๊ณ 100๋ฌ๋ฌ๋ ์ํ๋ก ์ผ๋ง์ผ?"
// 2.0 ํ์ค: .tools(...)์ ๊ฐ์ฒด๋ฅผ ์ง์ ์ ๋ฌํ๋ฉด @Tool ๋ฉ์๋๊ฐ ์๋ ๋ฐ์ธ๋ฉ!
.tools(apiTools)
.call()
.content();
}
}
```
## 8. RAG ์๋ฒฝ ๊ฐ์ด๋ (Ingestion ยท Embedding ยท VectorStore ยท Advisor)
์ฌ๋ด ๋น๊ณต๊ฐ ๋ฌธ์(์ฌ๊ท, ๋งค๋ด์ผ, ์ ์ฑ
PDF)๋ฅผ ๋ฒกํฐ DB์ ์ ์ฌํ ๋ค, ์ฌ์ฉ์์ ์ง๋ฌธ๊ณผ ๊ฐ์ฅ ์ ์ฌํ ๋ฌธ์๋ฅผ ์ฐพ์ LLM์ ์ง์์ผ๋ก ๋ณด๊ฐํ๋ RAG(๊ฒ์ ์ฆ๊ฐ ์์ฑ) ํ์ดํ๋ผ์ธ์
๋๋ค.
```text
[RAG ๋ฐ์ดํฐ ํ์ดํ๋ผ์ธ]
1. Document Ingestion : PDF/Text ํ์ผ ์ฝ๊ธฐ (PagePdfDocumentReader, TextReader)
2. Text Splitting : ์ฒญํน (TokenTextSplitter: 500ํ ํฐ ๋จ์, 100ํ ํฐ ์ค๋ฒ๋ฉ)
3. Embedding : ๋ฒกํฐ ์์นํ (EmbeddingModel: 1536์ฐจ์ ๋ฒกํฐ ๋ณํ)
4. VectorStore Save : pgvector, Chroma, Qdrant ๋ฑ์ ์ ์ฅ
5. Similarity Search : ์ฌ์ฉ์ ์ง๋ฌธ ๋ฒกํฐ์ ์ฝ์ฌ์ธ ์ ์ฌ๋ ์์ K๊ฑด ์ถ์ถ
6. QA Advisor : ์ง๋ฌธ๊ณผ ๊ฒ์๋ ์ง์์ ๊ฒฐํฉํ์ฌ ํ๊ฐ(Hallucination) ์๋ ๋ต๋ณ ์์ฑ
```
### ๐๏ธ 1. VectorStore ์ค์ (`application.yml` - PGvector)
```yaml
spring:
ai:
vectorstore:
pgvector:
index-type: HNSW # HNSW (์ด๊ณ ์ ๊ทผ์ฌ ์ต๊ทผ์ ํ์)
distance-type: COSINE_DISTANCE # ์ฝ์ฌ์ธ ๊ฑฐ๋ฆฌ ์ธก์
dimensions: 1536 # OpenAI text-embedding-3-small ๊ธฐ์ค ์ฐจ์
```
### ๐ฅ 2. ๋ฌธ์ ์์ง & ์ฒญํน & ๋ฒกํฐ DB ์ ์ฌ (ETL ์๋น์ค)
```java
@Service
@RequiredArgsConstructor
public class DocumentIngestionService {
private final VectorStore vectorStore;
public void ingestManual(Resource pdfFile) {
// 1. PDF ๋ฌธ์ ํ์ฑ
PagePdfDocumentReader reader = new PagePdfDocumentReader(pdfFile);
List<Document> documents = reader.get();
// 2. ํ ํฐ ๋จ์ ํ
์คํธ ๋ถํ (์ฒญํน)
TokenTextSplitter splitter = new TokenTextSplitter(500, 100, 5, 10000, true);
List<Document> chunks = splitter.apply(documents);
// 3. ๋ฉํ๋ฐ์ดํฐ ๋ถ์ฐฉ (์ฌํ ํํฐ๋ง์ ์ฌ์ฉ)
chunks.forEach(c -> c.getMetadata().put("department", "HR"));
// 4. ๋ฒกํฐ ์คํ ์ด์ ์ ์ฌ (์๋ฒ ๋ฉ ๋ชจ๋ธ ์๋ ํธ์ถ)
vectorStore.accept(chunks);
}
}
```
### ๐ 3. QuestionAnswerAdvisor๋ฅผ ํตํ RAG ์์คํฑ ์ง๋ฌธ
```java
@RestController
@RequiredArgsConstructor
public class RagController {
private final ChatClient chatClient;
private final VectorStore vectorStore;
@GetMapping("/ask-policy")
public String askPolicy(@RequestParam String question) {
return chatClient.prompt()
.user(question)
// Spring AI๊ฐ ์ง๋ฌธ๊ณผ ์ ์ฌํ ์์ ๋ฌธ์๋ฅผ ์ฐพ์ ํ๋กฌํํธ์ ์๋ ์ฃผ์
.advisors(new QuestionAnswerAdvisor(
vectorStore,
SearchRequest.builder()
.topK(4)
.similarityThreshold(0.75) // ์ ์ฌ๋ 75% ์ด์๋ง ์ฑํ
.build()
))
.call()
.content();
}
}
```
## 9. ๋ํ ๋ฉ๋ชจ๋ฆฌ (Chat Memory) & ์ด๋๋ฐ์ด์ (Advisor) ์ฒด์ธ
Statelessํ HTTP ํ๊ฒฝ์์ ์ฌ์ฉ์๋ณ ์ด์ ๋ํ ๋ฌธ๋งฅ(Multi-turn Chat)์ ์์ ํ๊ฒ ์ ์งํ๊ณ ๊ธฐ์ตํฉ๋๋ค.
### ๐ง 1. ChatMemory ๋น ๋ฑ๋ก (์ธ๋ฉ๋ชจ๋ฆฌ ๋๋ Redis)
```java
@Configuration
public class ChatMemoryConfig {
@Bean
public ChatMemory chatMemory() {
// ๋ก์ปฌ/ํ
์คํธ ํ๊ฒฝ: ์ธ๋ฉ๋ชจ๋ฆฌ ์ ์ฅ์
// ์ด์ ํ๊ฒฝ: Redis(spring-ai-autoconfigure-model-chat-memory-repository-redis) ๋๋ JdbcChatMemory
return new InMemoryChatMemory();
}
@Bean
public ChatClient memoryChatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultAdvisors(
// ์ฌ์ฉ์ ์ธ์
๋ณ ์ต๊ทผ 10๊ฐ ๋ฉ์์ง๋ฅผ ๊ธฐ์ตํ์ฌ ์๋ ์ฃผ์
new MessageChatMemoryAdvisor(chatMemory, "default", 10)
)
.build();
}
}
```
### ๐ฌ 2. ์ฌ์ฉ์ ID(`conversationId`)๋ณ ์ธ์
๊ฒฉ๋ฆฌ ๋ํ
```java
@GetMapping("/chat")
public String chatWithUserMemory(
@RequestParam String userId,
@RequestParam String message) {
return chatClient.prompt()
.user(message)
// ์ฌ์ฉ์๋ณ(userId)๋ก ๋
๋ฆฝ๋ ๋ํ ๋งฅ๋ฝ ์ ์ง
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID_ATTRIBUTE, userId))
.call()
.content();
}
```
### ๐ก๏ธ 3. ์ปค์คํ
์ด๋๋ฐ์ด์ (ํ ํฐ ๊ฐ์ฌ ๋ฐ ๋ก๊น
)
```java
public class AuditLoggingAdvisor implements RequestResponseAdvisor {
@Override
public AdvisedRequest adviseRequest(AdvisedRequest request, Map<String, Object> context) {
System.out.println("๐ค [AI ์์ฒญ] " + request.userText());
return request;
}
@Override
public ChatResponse adviseResponse(ChatResponse response, Map<String, Object> context) {
var usage = response.getMetadata().getUsage();
System.out.println("๐ [ํ ํฐ ์๋น] ์
๋ ฅ: " + usage.getPromptTokens() +
" | ์์ฑ: " + usage.getGenerationTokens());
return response;
}
@Override
public int getOrder() { return 0; }
}
```
## 10. ๋ฉํฐ๋ชจ๋ฌ (Multimodal: Vision ์ด๋ฏธ์ง ๋ถ์ & OCR)
GPT-4o, Claude 3.5 Sonnet, Gemini ๋ฑ ๋น์ ์ง์ ๋ชจ๋ธ์ ์ด๋ฏธ์ง, ์์์ฆ, ๋๋ฉด ๋ฑ์ ์ ์กํ์ฌ ๋ถ์ ๋ฐ ํ
์คํธ ์ถ์ถ์ ์ํํฉ๋๋ค.
### ๐ผ๏ธ ์ด๋ฏธ์ง ๋ถ์ ๋ฐ ๋ฐ์ดํฐ ์ถ์ถ
```java
@RestController
@RequiredArgsConstructor
public class VisionController {
private final ChatClient chatClient;
// 1. URL ๊ธฐ๋ฐ ์ด๋ฏธ์ง ์ง์
@GetMapping("/analyze-chart")
public String analyzeChartUrl() throws MalformedURLException {
var imageUrl = new URI("https://luckytechworld.pe.kr/samples/sales-chart.png").toURL();
var media = new Media(MimeTypeUtils.IMAGE_PNG, imageUrl);
return chatClient.prompt()
.user(u -> u.text("์ด ๋งค์ถ ๊ทธ๋ํ์์ ๊ฐ์ฅ ์ฑ์ฅ์ด ๋๋๋ฌ์ง ๋ถ๊ธฐ๋ฅผ ๋ถ์ํ๊ณ ์ธ์ฌ์ดํธ๋ฅผ ๋์ถํด์ค.")
.media(media))
.call()
.content();
}
// 2. ์ฌ์ฉ์๊ฐ ์
๋ก๋ํ ์์์ฆ/์ธ๋ณด์ด์ค OCR ๋ถ์ (Record ๋งคํ)
public record InvoiceData(String vendor, String date, int totalAmount, List<String> items) {}
@PostMapping(value = "/invoice", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public InvoiceData parseInvoice(@RequestParam("file") MultipartFile file) {
var media = new Media(
MimeTypeUtils.parseMimeType(file.getContentType()),
file.getResource()
);
return chatClient.prompt()
.user(u -> u.text("์์์ฆ ์ด๋ฏธ์ง์์ ๊ณต๊ธ์, ์ผ์, ์ด์ก, ํ๋ชฉ์ JSON ์คํค๋ง์ ๋ง์ถฐ ์ถ์ถํ์ธ์.")
.media(media))
.call()
.entity(InvoiceData.class); // Java Record๋ก ์๋ ์ญ์ง๋ ฌํ!
}
}
```
## 11. ์ค๋์ค (Whisper STT / Speech TTS) & ์ด๋ฏธ์ง ์์ฑ (DALL-E)
Spring AI๋ ํ
์คํธ ๋ํ ๋ชจ๋ธ๋ฟ ์๋๋ผ ์์ฑ ์ ์ฌ(STT), ์์ฑ ํฉ์ฑ(TTS), ์ด๋ฏธ์ง ์์ฑ ๋ชจ๋ธ๋ ํ์ค ์ธํฐํ์ด์ค๋ก ์ ๊ณตํฉ๋๋ค.
### ๐๏ธ 1. Whisper ์์ฑ ์ ์ฌ (STT - Speech to Text)
```java
@Autowired
private OpenAiAudioTranscriptionModel transcriptionModel;
public String convertAudioToText(Resource audioFile) {
var options = OpenAiAudioTranscriptionOptions.builder()
.language("ko") // ํ๊ตญ์ด ์ ์ฌ ์ต์ ํ
.temperature(0.2f)
.responseFormat(OpenAiAudioApi.TranscriptResponseFormat.TEXT)
.build();
AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(audioFile, options);
AudioTranscriptionResponse response = transcriptionModel.call(prompt);
return response.getResult().getOutput();
}
```
### ๐ 2. TTS ํ
์คํธ ์์ฑ ํฉ์ฑ (Text to Speech)
```java
@Autowired
private OpenAiAudioSpeechModel speechModel;
public byte[] synthesizeSpeech(String text) {
var options = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.ALLOY) // alloy, echo, fable, onyx, nova, shimmer
.speed(1.0f)
.responseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.MP3)
.build();
TextToSpeechPrompt prompt = new TextToSpeechPrompt(text, options);
return speechModel.call(prompt).getResult().getOutput(); // MP3 ๋ฐ์ด๋๋ฆฌ ๋ฐ์ดํธ ๋ฐํ
}
```
### ๐จ 3. DALL-E 3 ์ด๋ฏธ์ง ์์ฑ
```java
@Autowired
private ImageModel imageModel;
public String generateImage(String promptText) {
ImageResponse response = imageModel.call(
new ImagePrompt(
promptText,
OpenAiImageOptions.builder()
.quality("hd")
.model("dall-e-3")
.width(1024)
.height(1024)
.build()
)
);
return response.getResult().getOutput().getUrl(); // ์์ฑ๋ ์ด๋ฏธ์ง CDN URL ๋ฐํ
}
```
## 12. ํ๊ฐ(Evaluation), ๋ชจ๋ํฐ๋ง(Observability) & ํ๋ก๋์
๋ณด์
Spring AI 2.0.1 ๊ธฐ๋ฐ ์ํฐํ๋ผ์ด์ฆ ํ๋ก๋์
ํ๊ฒฝ์์ ์๊ตฌ๋๋ ๊ด์ธก์ฑ(Micrometer) ๋ฐ ๋ณด์ ๋ชจ๋ฒ ์ฌ๋ก์
๋๋ค.
### ๐ Micrometer & Prometheus ๊ด์ธก์ฑ (Observability)
Spring AI๋ ๋ณ๋ ์ฝ๋ ์์ฑ ์์ด ๋ชจ๋ธ ํธ์ถ ์๊ฐ, ์ฑ๊ณต๋ฅ , ํ ํฐ ์ฌ์ฉ๋์ ํ์ค ๋ฉํธ๋ฆญ์ผ๋ก ๋
ธ์ถํฉ๋๋ค.
```yaml
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
metrics:
export:
prometheus:
enabled: true
```
* `gen_ai.client.token.usage` : ๋ชจ๋ธ๋ณ ํ๋กฌํํธ/์๋ต ํ ํฐ ์๋น ์๋ ์ถ์
* `gen_ai.client.operation.duration` : LLM API ํธ์ถ ๋ ์ดํด์ ๋ฐฑ๋ถ์์(p95, p99)
### ๐ก๏ธ Spring AI 2.0.1 ๋ณด์ & ์ด์ ํต์ฌ ์ฒดํฌ๋ฆฌ์คํธ
```text
1. ๋๊ตฌ ์คํ ๋ณด์ : 2.0.1๋ถํฐ spring.ai.tools.resolution.fallback.enabled=false ๊ธฐ๋ณธ๊ฐ์ผ๋ก
๋ช
์์ ์ผ๋ก .tools()์ ๋๊ธด ๋๊ตฌ๋ง ์คํํ์ฌ ์๊ฒฉ ๋๊ตฌ ํ์ทจ ๋ฐฉ์ง (CVE-2026-59318)
2. API ํค ๋ณดํธ : Git ์ปค๋ฐ ์ ๋ ๊ธ์ง. Vault, AWS Secrets Manager ๋๋ ํ๊ฒฝ๋ณ์ ์ฃผ์
3. ํ๋กฌํํธ ์ธ์ ์
: ์ฌ์ฉ์ ์
๋ ฅ์ SystemMessage์ ๋ถ๋ฆฌํ๊ณ UserMessage ๋งค๊ฐ๋ณ์๋ก ์ฃผ์
4. ์ํท ๋ธ๋ ์ด์ปค : Resilience4j์ ์ฐ๋ํ์ฌ LLM ์ฅ์ ์ Fallback ๊ณ ์ ์๋ต ๋ฐํ
5. ํ์์์ ๋ฐฉ์ด : RestClient์ readTimeout์ ๋ฐ๋์ 30~60์ด๋ก ๋ช
์ (๋ฌดํ ๋ธ๋กํน ๋ฐฉ์ง)
```
### โฑ๏ธ RestClient ํ์์์ ์ปค์คํ
๋น
```java
@Bean
public RestClientCustomizer restClientCustomizer() {
return restClientBuilder -> restClientBuilder
.requestFactory(new SimpleClientHttpRequestFactory() {{
setConnectTimeout(Duration.ofSeconds(10)); // ์ฐ๊ฒฐ ์ ํ 10์ด
setReadTimeout(Duration.ofSeconds(60)); // ์๋ต ๋๊ธฐ ์ ํ 60์ด
}});
}
```
์๊ฒฌ ๋ฐ ์ง๋ฌธ
0์์ง ๋ฑ๋ก๋ ์๊ฒฌ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋๊ธ์ ๋จ๊ฒจ๋ณด์ธ์!
๋๊ธ ์์
๋๊ธ ์ญ์