지금까지 IoC/DI, Bean, Spring MVC 흐름을 익히면서 Spring이 "복잡한 것을 추상화해서 개발자가 비즈니스 로직에 집중하게 해준다"는 철학을 반복해서 봐왔다. 오늘은 그 철학이 AI 영역에서 어떻게 적용되는지를 다룬다.
이번 글을 읽으면서 스스로에게 던져볼 질문이 있다.
가장 단순한 방법은 프론트엔드에서 직접 AI API를 호출하는 것이다. 코드도 짧고, 백엔드를 거치지 않으니 빠르다. 그런데 이 방식에는 치명적인 문제가 있다.
// ❌ 브라우저에서 직접 호출
fetch('https://api.openai.com/v1/chat/completions', {
headers: { 'Authorization': 'Bearer sk-proj-abc123...' } // API Key 노출
})
브라우저 개발자 도구의 Network 탭을 열면 API Key가 그대로 보인다. 누구든 이 키를 복사해서 쓸 수 있고, 그 비용은 키 소유자가 전부 부담하게 된다. 실제로 GitHub에 키를 실수로 올렸다가 몇 시간 만에 수천 달러가 청구된 사례가 적지 않다.
해결책은 단순하다. AI API 호출을 백엔드로 옮기는 것이다.
브라우저 → Spring Boot → AI API
↓
API Key 안전 보관
인증/인가 체크
사용량 제한
프롬프트 가공
브라우저는 우리 서버만 알면 되고, AI API Key는 서버 내부(환경 변수)에서만 존재한다. 인증된 사용자만 AI를 쓸 수 있고, 하루 호출 횟수도 제한할 수 있다.
AI를 백엔드에서 직접 연동하면 또 다른 문제가 생긴다. OpenAI, Gemini, Claude는 각자 API 명세가 다르다. 오늘 OpenAI로 짠 코드는 내일 Gemini로 바꾸는 순간 대부분 다시 써야 한다.
이전에 DI/IoC를 공부하면서 배운 내용이 여기서 그대로 적용된다. 구현체가 바뀌어도 호출하는 쪽 코드가 바뀌지 않으려면 Interface가 필요하다.
Spring AI가 정확히 이걸 한다.
ChatClient (Interface)
↑
OpenAIImpl GeminiImpl ClaudeImpl
개발자는 ChatClient만 바라보고 코드를 짠다. 모델을 바꾸고 싶으면 application.yml의 설정값 하나만 수정하면 끝이다. 비즈니스 로직은 손댈 필요가 없다.
Spring AI의 모든 AI 호출은 ChatClient에서 시작한다. Fluent API 방식으로 체이닝해서 사용한다.
@RestController
@RequestMapping("/api/ai")
public class AiController {
private final ChatClient chatClient;
public AiController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@PostMapping("/chat")
public String chat(@RequestBody String message) {
return chatClient.prompt()
.user(message) // 사용자 질문
.call() // AI 호출
.content(); // String으로 응답 추출
}
}
각 메서드의 역할은 명확하다.
| 메서드 | 역할 |
|---|---|
.prompt() | 대화 시작 |
.user() | 사용자 질문 설정 |
.call() | AI 모델 호출 |
.content() | 응답을 String으로 추출 |
기본 ChatClient는 AI가 어떤 역할인지 모른다. 고객센터 챗봇을 만들었는데 AI가 갑자기 날씨 얘기를 할 수도 있다. .system()으로 AI의 성격과 행동 지침을 고정한다.
chatClient.prompt()
.system("당신은 스파르타 몰의 친절한 쇼핑 어시스턴트입니다. 항상 존댓말을 사용하세요.")
.user(message)
.call()
.content();
System Message는 사용자 메시지보다 우선순위가 높다. 사용자가 어떤 질문을 해도 AI는 설정된 페르소나를 유지한다.
동적인 값이 들어가는 프롬프트를 문자열 연결로 만들면 금방 관리하기 어려워진다.
// ❌ 문자열 직접 조합
.user("상품명 " + productName + "의 마케팅 문구를 작성해줘. 특징: " + features)
Prompt Template은 뼈대와 데이터를 분리한다.
// ✅ Prompt Template 사용
String template = """
상품명 {productName}의 마케팅 문구를 작성해줘.
특징: {features}
조건: 감성적이고 100자 이내로.
""";
chatClient.prompt()
.user(u -> u.text(template)
.param("productName", productName)
.param("features", features))
.call()
.content();
{변수명} 플레이스홀더에 .param()으로 값을 주입한다. 프롬프트 구조가 바뀌어도 Java 코드를 건드릴 필요가 없다.
두 개념을 정리하면 이렇다.
| 구분 | System Message | Prompt Template |
|---|---|---|
| 역할 | AI 역할 고정 | 동적 데이터 삽입 |
| 변화 | 고정적 | 매번 바뀜 |
| 비유 | 배우의 배역 | 대본의 빈칸 |
실제 서비스에서는 둘을 함께 쓸 때 가장 강력하다.
AI 응답을 String으로 받으면 문제가 생긴다. 점수, 감정, 요약을 각각 꺼내 쓰려면 파싱이 필요한데, AI는 매번 형식이 달라진다.
"이 리뷰는 긍정적입니다. 점수는 8점이고, 품질이 우수합니다."
"굳이 따지자면 8점 정도고, 나쁘지 않네요."
정규식으로 파싱하면 AI가 조금만 다르게 답해도 로직이 망가진다. 타입 안정성도 없다.
Spring AI의 .entity()는 이 문제를 한 줄로 해결한다.
// DTO 정의
@Getter
@NoArgsConstructor
public class ProductAnalysis {
String sentiment; // positive / neutral / negative
int score; // 1 ~ 10
String summary;
}
// 자동 파싱
ProductAnalysis result = chatClient.prompt()
.user("이 리뷰 분석해줘: " + review)
.call()
.entity(ProductAnalysis.class); // JSON 스키마 자동 생성 + 파싱
int score = result.getScore(); // 바로 사용 가능
내부적으로 Spring AI가 DTO 구조를 JSON 스키마로 변환해서 AI에게 전달하고, 응답을 다시 Java 객체로 파싱해준다. 개발자는 DTO 클래스만 정의하면 된다.
Spring AI는 Spring이 늘 해왔던 일을 AI 영역에서 그대로 한다. 복잡한 것을 추상화하고, 개발자가 비즈니스 로직에 집중하게 해준다. ChatClient 인터페이스 하나로 모델을 교체하고, System Message로 AI 역할을 고정하고, .entity()로 응답을 안전하게 받아낸다.