Spring AI 입문 — 자바 개발자를 위한 LLM 통합 가이드
Spring AI 입문 — 자바 개발자를 위한 LLM 통합 가이드
자바 개발자가 AI 기능을 통합하려면 어디서부터?
Python에는 LangChain이, TypeScript에는 Vercel AI SDK가 있습니다. 그렇다면 Java/Spring 생태계 에서 LLM을 통합하는 표준적인 방법은 뭘까요?
Spring AI 가 그 답입니다. Spring 팀이 공식으로 개발하는 프로젝트로, Spring Boot의 자동 설정 철학을 그대로 AI 영역에 적용합니다. REST API를 직접 호출하는 대신, ChatClient라는 추상화를 통해 OpenAI, Anthropic, Ollama 등 다양한 모델을 동일한 인터페이스로 사용할 수 있습니다.
의존성 설정
Spring Boot 프로젝트에 Spring AI를 추가합니다. 사용할 모델에 따라 의존성이 다릅니다.
Gradle (build.gradle)
// BOM 추가
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:1.0.0"
}
}
dependencies {
// OpenAI 사용 시
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
// Anthropic(Claude) 사용 시
// implementation 'org.springframework.ai:spring-ai-anthropic-spring-boot-starter'
// 로컬 모델 (Ollama) 사용 시
// implementation 'org.springframework.ai:spring-ai-ollama-spring-boot-starter'
}
Maven (pom.xml)
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
application.yml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
temperature: 0.7
API 키는 환경 변수나 Vault로 관리하세요.
application.yml에 직접 넣지 마세요.
ChatClient — LLM 호출의 핵심
ChatClient는 Spring AI의 중심 인터페이스입니다. Fluent API로 프롬프트를 구성합니다.
기본 호출
@Service
public class AiService {
private final ChatClient chatClient;
public AiService(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
// 단순 텍스트 응답
public String askQuestion(String question) {
return chatClient.prompt()
.user(question)
.call()
.content(); // 텍스트 응답 추출
}
}
시스템 프롬프트 설정
public String translate(String text, String targetLanguage) {
return chatClient.prompt()
.system("당신은 전문 번역가입니다. 원문의 뉘앙스를 살려 번역하세요.")
.user("다음 텍스트를 " + targetLanguage + "로 번역해주세요: " + text)
.call()
.content();
}
대화 맥락 유지
public String chat(List<Message> conversationHistory, String newMessage) {
return chatClient.prompt()
.messages(conversationHistory) // 이전 대화 이력
.user(newMessage) // 새 메시지
.call()
.content();
}
프롬프트 템플릿
프롬프트를 하드코딩하지 않고, 템플릿 으로 관리합니다. Spring의 Resource 시스템과 통합됩니다.
리소스 기반 템플릿
# src/main/resources/prompts/review.st
다음 코드를 리뷰해주세요.
언어: {language}
중점 사항: {focus}
```java
{code}
개선 사항을 3가지 이내로 제안해주세요.
```java
@Service
public class CodeReviewService {
private final ChatClient chatClient;
@Value("classpath:prompts/review.st")
private Resource reviewPromptResource;
public String reviewCode(String code, String language) {
PromptTemplate template = new PromptTemplate(reviewPromptResource);
Prompt prompt = template.create(Map.of(
"language", language,
"focus", "성능, 보안, 가독성",
"code", code
));
return chatClient.prompt(prompt)
.call()
.content();
}
}
Output Parser — LLM 응답을 Java 객체로
LLM의 텍스트 응답을 구조화된 Java 객체 로 변환합니다.
BeanOutputParser — Record/POJO로 매핑
// 응답을 매핑할 Record
public record MovieRecommendation(
String title,
String genre,
int year,
String reason
) {}
@Service
public class RecommendationService {
private final ChatClient chatClient;
public MovieRecommendation recommend(String mood) {
return chatClient.prompt()
.user("현재 기분: " + mood + ". 영화 한 편을 추천해주세요.")
.call()
.entity(MovieRecommendation.class); // 자동으로 JSON 파싱
}
}
entity() 메서드가 내부적으로:
- 프롬프트에 응답 포맷 지시를 추가하고
- LLM 응답을 파싱하여 Java 객체로 변환합니다
리스트로 매핑
public List<MovieRecommendation> recommendMultiple(String mood) {
return chatClient.prompt()
.user("현재 기분: " + mood + ". 영화 3편을 추천해주세요.")
.call()
.entity(new ParameterizedTypeReference<List<MovieRecommendation>>() {});
}
스트리밍 응답
긴 응답을 실시간으로 받아 처리합니다.
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamAnswer(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.stream()
.content(); // Flux<String> 반환
}
WebFlux와 자연스럽게 통합되어 SSE(Server-Sent Events)로 클라이언트에 스트리밍할 수 있습니다.
Embedding — 텍스트를 벡터로
임베딩(Embedding) 은 텍스트를 숫자 벡터로 변환하는 것입니다. 유사한 의미의 텍스트는 유사한 벡터를 가지므로, 의미 기반 검색 의 기초가 됩니다.
@Service
public class EmbeddingService {
private final EmbeddingModel embeddingModel;
public EmbeddingService(EmbeddingModel embeddingModel) {
this.embeddingModel = embeddingModel;
}
// 텍스트 → 벡터 변환
public float[] embed(String text) {
return embeddingModel.embed(text);
}
// 두 텍스트의 유사도 계산
public double similarity(String text1, String text2) {
float[] vec1 = embeddingModel.embed(text1);
float[] vec2 = embeddingModel.embed(text2);
return cosineSimilarity(vec1, vec2);
}
}
VectorStore — 벡터 저장 및 검색
임베딩된 벡터를 저장하고, 유사도 기반 검색을 수행합니다.
pgvector (PostgreSQL 확장)
# application.yml
spring:
ai:
vectorstore:
pgvector:
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 1536
@Service
public class DocumentService {
private final VectorStore vectorStore;
// 문서 저장
public void storeDocuments(List<String> texts) {
List<Document> documents = texts.stream()
.map(text -> new Document(text))
.toList();
vectorStore.add(documents);
}
// 유사 문서 검색
public List<Document> search(String query) {
return vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(5) // 상위 5개
.similarityThreshold(0.7) // 유사도 70% 이상
.build()
);
}
}
지원하는 VectorStore 구현체
| VectorStore | 특징 |
|---|---|
| pgvector | PostgreSQL 확장, 기존 DB에 추가 가능 |
| Redis | Upstash/Redis Stack, 빠른 검색 |
| Milvus | 대규모 벡터 검색 전문 |
| Chroma | 경량, 로컬 개발에 적합 |
| Pinecone | 완전 관리형 서비스 |
| Weaviate | GraphQL 기반 검색 |
RAG — 검색 증강 생성
RAG(Retrieval-Augmented Generation) 는 LLM이 모르는 정보(사내 문서, 최신 데이터)를 검색해서 프롬프트에 주입 하는 패턴입니다.
사용자 질문
↓
[1] 질문을 벡터로 변환 (Embedding)
↓
[2] VectorStore에서 유사 문서 검색 (Retrieval)
↓
[3] 검색된 문서 + 원래 질문을 LLM에 전달 (Augmented Generation)
↓
정확한 응답
Spring AI의 QuestionAnswerAdvisor
Spring AI는 RAG를 Advisor 패턴으로 깔끔하게 제공합니다.
@Service
public class RagService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
public RagService(ChatClient.Builder builder, VectorStore vectorStore) {
this.vectorStore = vectorStore;
this.chatClient = builder
.defaultAdvisors(
new QuestionAnswerAdvisor(vectorStore,
SearchRequest.builder().topK(5).build())
)
.build();
}
public String askAboutDocs(String question) {
// QuestionAnswerAdvisor가 자동으로:
// 1. question을 벡터로 변환
// 2. VectorStore에서 관련 문서 검색
// 3. 검색 결과를 프롬프트에 추가
// 4. LLM에 전달
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
문서 로딩 파이프라인
@Component
public class DocumentLoader {
private final VectorStore vectorStore;
// 텍스트 파일을 읽어서 VectorStore에 저장
public void loadDocuments(Resource... resources) {
// 1. 문서 읽기
var reader = new TextReader(resources);
List<Document> documents = reader.read();
// 2. 청크로 분할 (긴 문서를 적절한 크기로 자름)
var splitter = new TokenTextSplitter();
List<Document> chunks = splitter.split(documents);
// 3. VectorStore에 저장 (임베딩은 자동)
vectorStore.add(chunks);
}
}
Function Calling — LLM이 Java 메서드를 호출
LLM이 필요할 때 사전 정의된 함수를 호출 할 수 있게 합니다.
// 함수 정의
@Bean
@Description("주어진 도시의 현재 날씨를 조회합니다")
public Function<WeatherRequest, WeatherResponse> currentWeather() {
return request -> {
// 실제 날씨 API 호출
return weatherApi.getWeather(request.city());
};
}
public record WeatherRequest(String city) {}
public record WeatherResponse(String city, double temperature, String condition) {}
// 함수를 ChatClient에 등록
public String askAboutWeather(String question) {
return chatClient.prompt()
.user(question)
.functions("currentWeather") // 함수 이름 등록
.call()
.content();
}
// "서울 날씨 어때?" → LLM이 currentWeather("서울") 호출 → 결과를 바탕으로 응답 생성
LLM이 직접 API를 호출하는 게 아닙니다. LLM이 "이 함수를 이 인자로 호출해야 한다"고 판단하면, Spring AI가 실제 함수를 실행하고 결과를 다시 LLM에 전달합니다.
멀티 모델 지원
Spring AI는 모델을 인터페이스로 추상화하므로, 용도에 따라 다른 모델을 사용할 수 있습니다.
@Configuration
public class AiConfig {
// 일반 채팅용 — 빠르고 저렴한 모델
@Bean("chatModel")
public ChatClient chatClient(OpenAiChatModel model) {
return ChatClient.builder(model)
.defaultOptions(ChatOptionsBuilder.builder()
.model("gpt-4o-mini")
.build())
.build();
}
// 분석/추론용 — 고성능 모델
@Bean("reasoningModel")
public ChatClient reasoningClient(AnthropicChatModel model) {
return ChatClient.builder(model)
.defaultOptions(ChatOptionsBuilder.builder()
.model("claude-sonnet-4-6")
.build())
.build();
}
}
Ollama로 로컬 개발
외부 API 없이 로컬에서 개발하려면 Ollama 를 사용합니다.
# Ollama 설치 후
ollama pull llama3.2
spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
model: llama3.2
코드는 전혀 바꿀 필요 없습니다. ChatClient 인터페이스가 동일하니까요. 의존성만 spring-ai-ollama-spring-boot-starter로 바꾸면 됩니다.
실전 아키텍처 예시: 사내 문서 Q&A 봇
[사용자] "배포 절차가 어떻게 되나요?"
↓
[Controller] REST API → AiService
↓
[QuestionAnswerAdvisor]
1. 질문 임베딩 (EmbeddingModel)
2. VectorStore에서 관련 사내 문서 검색
3. 검색된 문서 + 질문을 프롬프트에 추가
↓
[ChatClient] → OpenAI API (또는 Ollama)
↓
[응답] "배포 절차는 다음과 같습니다: 1. develop 브랜치에서..."
@RestController
@RequestMapping("/api/qa")
public class QaController {
private final RagService ragService;
@PostMapping
public ResponseEntity<QaResponse> ask(@RequestBody QaRequest request) {
String answer = ragService.askAboutDocs(request.question());
return ResponseEntity.ok(new QaResponse(answer));
}
}
주의할 점
비용 관리
LLM API 호출은 토큰 단위로 과금됩니다. RAG에서 너무 많은 문서를 컨텍스트에 넣으면 비용이 급증합니다.
// topK와 similarityThreshold로 검색 결과를 제한
SearchRequest.builder()
.query(question)
.topK(3) // 최대 3개 문서만
.similarityThreshold(0.8) // 유사도 80% 이상만
.build();
환각(Hallucination) 대응
LLM은 모르는 것도 그럴듯하게 답합니다. RAG에서 관련 문서가 없으면 "모르겠다"고 답하도록 시스템 프롬프트에 명시하세요.
Spring AI 버전
Spring AI는 빠르게 발전하고 있어 API가 변경될 수 있습니다. BOM 버전을 고정하고, 업그레이드 시 릴리스 노트를 확인하세요.
정리
Spring AI는 Java/Spring 개발자가 익숙한 패턴으로 LLM을 통합 할 수 있게 해줍니다. ChatClient → 프롬프트 템플릿 → Output Parser → VectorStore → RAG 순서로 점진적으로 도입할 수 있습니다.
| 기능 | Spring AI 컴포넌트 | 용도 |
|---|---|---|
| LLM 호출 | ChatClient | 기본 텍스트 생성 |
| 프롬프트 관리 | PromptTemplate | 템플릿 기반 프롬프트 |
| 응답 구조화 | entity() / OutputParser | JSON → Java 객체 |
| 벡터 검색 | VectorStore | 의미 기반 문서 검색 |
| RAG | QuestionAnswerAdvisor | 문서 기반 Q&A |
| 함수 호출 | @Bean + functions() | LLM → Java 메서드 실행 |
공부하면서 느낀 건, Spring AI의 가장 큰 장점이 기존 Spring 생태계와의 자연스러운 통합 이라는 점입니다. DI, 자동 설정, Actuator 모니터링 등 이미 알고 있는 패턴을 그대로 AI 영역에 적용할 수 있다는 것이 Python 기반 도구들과의 차별점입니다.