
Java 개발자를 위한 Spring AI 입문 가이드. LangChain과의 비교부터 실시간 스트리밍 챗봇 구현까지 핵심 개념을 코드와 함께 정리합니다.
AI 모델을 직접 연동해본 개발자라면 공감할 것입니다. 프롬프트를 구성하고, 응답을 파싱하고, 대화 맥락을 유지하고, 외부 도구를 연동하는 전 과정을 날것으로 구현하면 코드 복잡도가 급격히 치솟습니다. 여기에 모델을 교체하거나 기능을 추가하는 날엔 하드코딩된 로직이 발목을 잡습니다.
Python 생태계에서는 이 문제를 LangChain이 해결해왔습니다. 프롬프트 → 응답 → 후처리를 하나의 체인으로 연결하는 LangChain의 철학은 RAG, 대화 기억, 도구 호출 등 고도화된 기능의 표준을 만들었습니다.
Java 진영의 대답이 바로 Spring AI입니다.
| 비교 항목 | LangChain | Spring AI |
|---|---|---|
| 언어 / 플랫폼 | Python / Node.js | Java / Spring Boot |
| 핵심 개념 | 프롬프트-응답 단계를 체인으로 연결 | 스프링 빈으로 자동 주입된 API로 메서드 호출 처리 |
| 대화 기억 | 지원 | 지원 |
| 구조화된 출력 | JSON 형식 지원 | JSON 및 Java 객체 역직렬화 지원 |
| 벡터 저장소 | 지원 | 지원 |
| 도구 호출 | 지원 | 지원 |
| 멀티모달 | 지원 | 지원 |
| RAG | 지원 | 지원 |
| MCP Server 개발 | 미지원 | 지원 |
Spring AI의 차별점은 친숙한 Spring 개발 경험을 그대로 유지한다는 점입니다. Spring Boot Starter 의존성 하나로 AI 기능을 활성화하고, OpenAI, Anthropic, Gemini, Ollama 등 주요 LLM을 마치 로컬 Bean처럼 다룰 수 있습니다.
// 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 버전으로 강제되므로 현재 조합을 유지하는 것이 안전합니다.
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
Spring AI는 다양한 모델 유형을 공통 인터페이스로 추상화합니다.
| 모델 구분 | 설명 | 주요 인터페이스 |
|---|---|---|
| Chat Model | 텍스트/멀티모달 입력 처리 및 응답 생성 | ChatModel |
| Image Model | 텍스트 프롬프트 기반 이미지 생성/편집 | ImageModel |
| Speech Model | 텍스트 → 음성 변환 (TTS) | SpeechModel |
| Transcription Model | 음성 → 텍스트 변환 (STT) | TranscriptionModel |
| Embedding Model | 텍스트 → 벡터 변환 (유사도 검색용) | EmbeddingModel |
동기식 (ChatModel.call) 스트리밍 (ChatModel.stream)
───────────────────────── ─────────────────────────
[LLM 처리 중...] 토큰1 → 토큰2 → 토큰3 → ...
↓ ↓
응답 전체 한번에 반환 첫 토큰부터 즉시 반환
String / ChatResponse Flux<String> / Flux<ChatResponse>
| 구분 | call() | stream() |
|---|---|---|
| 처리 방식 | 동기 (Blocking) | 리액티브 스트림 (Async) |
| 반환 타입 | String, ChatResponse | Flux<String>, Flux<ChatResponse> |
| 적합한 용도 | 데이터 분석, 요약, 백엔드 로직 | 챗봇 UI, 실시간 텍스트 표시 |
Spring AI 1.0 이후부터 ChatModel이 StreamingChatModel을 직접 확장하는 구조로 설계되어, 하나의 Bean 주입으로 두 방식을 모두 사용할 수 있습니다.
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
└── List<Generation>
└── Generation
├── AssistantMessage → getText() // 실제 텍스트
└── ChatGenerationMetadata → getFinishReason() // STOP, LENGTH 등
// 활용 예시
ChatResponse response = chatModel.call(prompt);
String content = response.getResult().getOutput().getText();
String finishReason = response.getResult().getMetadata().getFinishReason();
생텍쥐페리의 소설 <어린왕자>의 주인공처럼 응답하는 챗봇을 강의 내용을 따라 만들어 보았습니다.
인터페이스 정의
// 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";
}
}
동기식과의 차이점은 딱 두 가지입니다. 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가 "생각하고 있음"을 실시간으로 느낄 수 있습니다.
| 구분 | ChatModel (저수준) | ChatClient (고수준) |
|---|---|---|
| 특징 | 모델 직접 제어, 세밀한 응답 관리 | 복잡한 데이터 흐름 및 대화 컨텍스트 관리 |
| 주요 용도 | 단순 단일 질문-답변 | 챗봇, RAG 시스템, 에이전트 |
| 핵심 기능 | 기본 LLM 호출 | Advisor 체이닝, 대화 기억, 도구 호출 관리 |
| 권장 여부 | 특수한 경우 | 일반적인 경우 ChatClient 권장 |
ChatClient의 핵심은 Advisor 체인입니다. 여러 어드바이저를 순차 실행하여 프롬프트를 전/후처리합니다. 대화 기억 유지, RAG 구현, 도구 호출 흐름 관리 등 고수준 기능이 이 위에서 동작합니다.
// 방법 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();
}
}
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> 반환
| 메서드 | 역할 |
|---|---|
.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, 도구 호출 등 고도화된 기능은 이후 글에서 계속 다룰 예정입니다.
본 글은 아래 강의 자료를 바탕으로 작성되었습니다.
Copyright ⓒ TeamSparta All rights reserved.