Spring AI로 LLM API 연동하기 — 백엔드 개발자의 첫 AI 기능 도입기
Spring AI 프레임워크를 활용해 Spring Boot 프로젝트에서 LLM을 연동하고, ChatClient와 프롬프트 템플릿, 스트리밍 응답까지 실무에 적용한 과정을 정리했습니다.

"상품 설명을 AI로 자동 생성해주면 좋겠다"는 기획 요청이 들어왔습니다. 처음에는 OpenAI API를 RestTemplate으로 직접 호출하면 되겠거니 생각했습니다. HTTP 요청 보내고 JSON 파싱하면 끝 아닌가요.
그런데 막상 구현에 들어가니 생각보다 신경 쓸 게 많았습니다. API 키 관리, 타임아웃 설정, 재시도 로직, 토큰 사용량 추적, 프롬프트 관리, 스트리밍 응답 처리까지. RestTemplate 하나로 이 모든 걸 깔끔하게 처리하기엔 보일러플레이트가 너무 많아졌습니다.
그러던 중 Spring AI 프레임워크를 알게 됐습니다. Spring 생태계에서 LLM을 연동하기 위한 공식 프레임워크인데, Spring Boot에 익숙한 개발자라면 기존 패턴 그대로 AI 기능을 붙일 수 있다는 점이 매력적이었습니다.
RestTemplate으로 직접 호출했던 첫 시도
처음에는 별도 라이브러리 없이 직접 구현했습니다.
@Component
@RequiredArgsConstructor
public class LlmClient {
private final RestTemplate restTemplate;
@Value("${openai.api-key}")
private String apiKey;
public String generate(String prompt) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = Map.of(
"model", "gpt-4o",
"messages", List.of(Map.of("role", "user", "content", prompt)),
"max_tokens", 500
);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(
"https://api.openai.com/v1/chat/completions",
request, Map.class
);
// JSON 파싱 지옥의 시작
Map choices = ((List<Map>) response.getBody().get("choices")).get(0);
Map message = (Map) choices.get("message");
return (String) message.get("content");
}
}동작은 했지만, 문제가 금방 드러났습니다. 에러 핸들링이 허술했고, 프롬프트가 코드 안에 하드코딩되어 있었고, 다른 LLM(Claude, Gemini)으로 교체하려면 클라이언트 전체를 다시 작성해야 했습니다. 이전에 REST API 응답 구조를 깔끔하게 설계해 뒀던 것과는 대조적으로, AI 연동 코드만 지저분하게 남아 있었습니다.
Spring AI 도입: 익숙한 방식으로 돌아오다
Spring AI는 LLM 연동을 Spring Boot의 관습대로 할 수 있게 해주는 프레임워크입니다. spring-boot-starter-web을 추가하듯, AI 스타터 의존성 하나로 시작합니다.
dependencies {
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0'
}spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
temperature: 0.7
max-tokens: 500API 키는 환경 변수로 주입하고, 모델 설정은 application.yml에서 관리합니다. Spring Boot가 자동 설정(Auto Configuration)으로 ChatClient 빈을 생성해주기 때문에, 서비스에서는 주입받아 바로 사용할 수 있었습니다.
ChatClient로 대화하기
Spring AI의 핵심은 ChatClient입니다. REST API를 호출할 때 RestClient를 쓰듯, LLM과 대화할 때는 ChatClient를 씁니다.
@Service
@RequiredArgsConstructor
public class ProductDescriptionService {
private final ChatClient chatClient;
public String generateDescription(Product product) {
String response = chatClient.prompt()
.user(u -> u.text("""
다음 상품에 대해 매력적인 설명을 3문장으로 작성해주세요.
상품명: {name}
카테고리: {category}
가격: {price}원
주요 특징: {features}
""")
.param("name", product.getName())
.param("category", product.getCategory())
.param("price", product.getPrice())
.param("features", product.getFeatures()))
.call()
.content();
return response;
}
}RestTemplate으로 직접 구현했던 30줄짜리 코드가 10줄로 줄었습니다. JSON 파싱도, 헤더 설정도, URL 관리도 프레임워크가 처리합니다. 이전에 예외 처리 전략에서 "반복되는 보일러플레이트를 공통화하라"고 정리했던 원칙이 여기서도 적용된 셈입니다.
프롬프트 템플릿 분리
프롬프트가 코드 안에 있으면 수정할 때마다 재배포가 필요합니다. Spring AI는 프롬프트를 리소스 파일로 분리하는 방식을 지원합니다.
다음 상품에 대해 매력적인 설명을 3문장으로 작성해주세요.
톤은 친근하고 신뢰감을 주는 느낌으로 부탁드립니다.
상품명: {name}
카테고리: {category}
가격: {price}원
주요 특징: {features}@Service
@RequiredArgsConstructor
public class ProductDescriptionService {
private final ChatClient chatClient;
@Value("classpath:prompts/product-description.st")
private Resource promptResource;
public String generateDescription(Product product) {
return chatClient.prompt()
.user(u -> u.text(promptResource)
.param("name", product.getName())
.param("category", product.getCategory())
.param("price", String.valueOf(product.getPrice()))
.param("features", product.getFeatures()))
.call()
.content();
}
}프롬프트를 별도 파일로 관리하니 기획자가 직접 문구를 수정하고 PR을 올릴 수도 있게 됐습니다. API 문서를 프론트에 전달할 때처럼, 관심사의 분리가 협업의 효율을 높여준 경우였습니다.
구조화된 응답: JSON으로 받기
LLM의 응답은 기본적으로 자유 형식의 텍스트입니다. 하지만 백엔드에서는 구조화된 데이터가 필요한 경우가 많습니다. Spring AI는 응답을 Java 객체로 자동 매핑해주는 기능을 제공합니다.
public record ProductAnalysis(
String summary,
List<String> keywords,
String targetAudience,
int appealScore
) {}public ProductAnalysis analyzeProduct(Product product) {
return chatClient.prompt()
.user(u -> u.text("""
다음 상품을 분석해서 JSON으로 응답해주세요.
- summary: 한 줄 요약
- keywords: SEO 키워드 3~5개
- targetAudience: 주요 타겟층
- appealScore: 매력도 점수 (1~10)
상품명: {name}
카테고리: {category}
""")
.param("name", product.getName())
.param("category", product.getCategory()))
.call()
.entity(ProductAnalysis.class);
}.entity(ProductAnalysis.class)를 호출하면 Spring AI가 LLM 응답을 파싱해서 Java record로 변환해줍니다. JSON 파싱 코드를 직접 작성할 필요가 없어져서, 실수할 여지가 크게 줄었습니다.
스트리밍 응답: 사용자 경험 개선
상품 설명이 길어지면 LLM 응답이 3~5초 걸리는 경우도 있었습니다. 사용자 입장에서 빈 화면을 5초간 보는 건 꽤 답답한 경험이었습니다.
Spring AI의 스트리밍 기능을 사용하면, 응답이 토큰 단위로 실시간 전달됩니다.
@GetMapping(value = "/products/{id}/description/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamDescription(@PathVariable Long id) {
Product product = productService.findById(id);
return chatClient.prompt()
.user(u -> u.text(promptResource)
.param("name", product.getName())
.param("category", product.getCategory())
.param("price", String.valueOf(product.getPrice()))
.param("features", product.getFeatures()))
.stream()
.content();
}Server-Sent Events(SSE) 방식으로 프론트에 토큰이 하나씩 전달되니, ChatGPT 웹에서 보는 것처럼 글자가 실시간으로 타이핑되는 경험을 제공할 수 있었습니다.
LLM 교체가 쉬워진 구조
Spring AI의 가장 큰 장점 중 하나는 LLM 공급자를 쉽게 교체할 수 있다는 점이었습니다. ChatClient 인터페이스가 추상화되어 있기 때문에, 의존성과 설정만 바꾸면 서비스 코드는 그대로 유지됩니다.
dependencies {
// implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0'
implementation 'org.springframework.ai:spring-ai-anthropic-spring-boot-starter:1.0.0'
}spring:
ai:
anthropic:
api-key: ${ANTHROPIC_API_KEY}
chat:
options:
model: claude-sonnet-4-20250514
max-tokens: 500실제로 비용 최적화를 위해 기능별로 다른 모델을 사용하는 구성도 가능합니다. 간단한 키워드 추출에는 가벼운 모델을, 복잡한 텍스트 생성에는 고성능 모델을 쓰는 식입니다. 이전에 멀티 모듈 아키텍처에서 모듈별 관심사를 분리했던 것처럼, AI 기능도 용도에 따라 분리할 수 있었습니다.
실무에서 부딪힌 문제들
토큰 사용량과 비용 관리
LLM API는 토큰 단위로 과금됩니다. 개발 환경에서 테스트를 돌릴 때마다 API가 호출되면 비용이 빠르게 쌓입니다.
@Configuration
@Profile("!test")
public class AiClientConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
}테스트 환경에서는 Mock ChatClient를 주입하고, Testcontainers 통합 테스트에서 구축한 방식과 유사하게 실제 API 호출 없이 검증할 수 있도록 했습니다.
응답 품질의 일관성
같은 프롬프트를 보내도 매번 다른 답이 돌아오는 건 LLM의 본질적인 특성입니다. temperature를 0에 가깝게 설정하면 일관성은 올라가지만, 창의성이 떨어집니다.
| temperature | 특성 | 적합한 용도 |
|---|---|---|
| 0.0~0.3 | 일관적, 예측 가능 | 데이터 추출, 분류, 요약 |
| 0.5~0.7 | 균형 잡힌 | 상품 설명, 일반 텍스트 생성 |
| 0.8~1.0 | 창의적, 다양한 | 마케팅 카피, 브레인스토밍 |
결국 용도별로 temperature를 다르게 가져가는 것이 현실적인 답이었습니다.
타임아웃과 폴백
LLM API가 간헐적으로 느려지거나 장애가 나는 경우도 대비해야 했습니다.
spring:
ai:
openai:
chat:
options:
timeout: 10s
retry:
max-attempts: 2
backoff:
initial-interval: 1s타임아웃과 재시도를 설정하고, 그래도 실패하면 사전에 준비해둔 기본 설명 템플릿으로 폴백하는 구조를 만들었습니다. AI 기능이 실패한다고 핵심 비즈니스 로직까지 같이 실패하면 안 되니까요.
Before & After 비교
| 항목 | RestTemplate 직접 구현 | Spring AI |
|---|---|---|
| 코드량 | 30줄+ (헤더, JSON 파싱 포함) | 10줄 내외 |
| LLM 교체 | 클라이언트 전체 재작성 | 의존성 + 설정만 변경 |
| 프롬프트 관리 | 코드 내 하드코딩 | 리소스 파일 분리 |
| 구조화된 응답 | 수동 JSON 파싱 | .entity() 자동 매핑 |
| 스트리밍 | 직접 SSE 구현 필요 | .stream() 한 줄 |
| 테스트 | Mock 구성 복잡 | ChatClient Mock 간단 |
마무리
처음 AI 기능을 도입할 때는 "HTTP 요청 하나 더 보내는 것"이라고 가볍게 생각했습니다. 하지만 실제로 운영 가능한 수준으로 만들려면 프롬프트 관리, 비용 통제, 응답 품질, 장애 대비 같은 고민이 꼬리를 물고 이어졌습니다.
Spring AI 덕분에 이 복잡성의 상당 부분을 프레임워크에 맡길 수 있었고, 저는 "어떤 프롬프트를 설계할 것인가", "어떤 응답 구조가 비즈니스에 맞는가"에 집중할 수 있게 됐습니다.
결국 배운 건, AI 기능도 다른 외부 시스템 연동과 본질적으로 다르지 않다는 점이었습니다. 잘 추상화하고, 장애에 대비하고, 테스트 가능하게 만드는 것. Spring 개발자로서 익숙한 원칙들이 AI 연동에서도 그대로 통했습니다.