Intro
최근 AI 기술이 급속도로 발전하면서, 백엔드 개발자들도 AI 기능을 서비스에 통합해야 하는 상황이 많아졌다.
Spring 생태계에서는 이를 위해 Spring AI라는 프로젝트를 제공하고 있는데, 처음 접하면 어디서부터 시작해야 할지 막막할 수 있다.
이 글에서는 Spring AI의 핵심인 ChtClient를 활용하여 OpenAI API와 통신하는 방법을 단계별로 알아본다.
개인적으로 Spring AI에 대해 공부하면서, 복잡한 설정 없이도 몇 가지 코드만으로 LLM 모델과 통신할 수 있다는 점이 인상적이었다.
1. 프로젝트 설정
1.1 의존성 추가
먼저 `build.gradle.kts`에 Spring AI 관련 의존성을 추가한다. Spring AI는 BOM(Bill of Materials)를 통해 버전을 관리하므로, 아래와 같이 설정하면 된다.
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.5.9"
id("io.spring.dependency-management") version "1.1.7"
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
// Spring AI BOM 설정
extra["springAiVersion"] = "1.1.2"
dependencyManagement {
imports {
mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}")
}
}
dependencies {
// Spring Web
implementation("org.springframework.boot:spring-boot-starter-web")
// Jackson (Kotlin용)
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// Kotlin Reflect
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Spring AI OpenAI Starter - 핵심 의존성
implementation("org.springframework.ai:spring-ai-starter-model-openai")
}
핵심은 `spring-ai-starter-model-openai` 의존성이다. 이 의존성 하나만 추가하면 OpenAI와 통신하기 위한 모든 설정이 자동으로 구성된다.
1.2 API Key 설정
application.yml에 OpenAI API 키를 설정한다. 보안을 위해 환경변수로 관리하는 것을 권장한다.
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
환경 변수는 터미널에서 직접 설정하거나, `.env` 파일을 활용할 수 있다.
export OPENAI_API_KEY="API 키를 입력"
2. 기본 ChatClient 사용법
2.1 ChatClient Bean 등록
Spring AI를 사용하려면 먼저 `ChatClient` Bean을 등록해야 한다. `ChatClient.Builder`가 자동으로 주입되므로, 이를 활용해 빌드하면 된다.
@Configuration
class ChatClientConfig {
@Bean
fun chatClient(builder: ChatClient.Builder): ChatClient {
return builder.build()
}
}
이렇게 하면 가장 기본적인 `ChatClient`가 생성된다. 별도의 설정 없이 바로 OpenAI API와 통신할 수 있다.
2.2 간단한 LLM 통신 구현해보기
이제 실제로 AI와 대화하는 코드를 작성해보자. `ChatClient`의 API는 직관적인 Fluent API로 설계되어 있어 사용이 매우 간편하다.
@Service
class ChatService(
private val chatClient: ChatClient,
) {
fun chat(chatMessage: ChatMessage): ChatAnswer {
val answer = chatClient.prompt()
.user(chatMessage.message) // 사용자 메시지 설정
.call() // AI 호출
.content() // 응답 텍스트 추출
return ChatAnswer(message = answer ?: "")
}
}
data class ChatMessage(
val message: String,
)
`prompt()` -> `user()` -> `call()` -> `content()` 순서로 체이닝 하면 AI의 응답을 문자열로 받을 수 있다.
2.3 Controller 연결
위에서 만든 기본적인 LLM 모델과의 통신 기능을 REST API로 제공하기 위해 Controller를 작성해보자.
@RestController
class ChatController(
private val chatService: ChatService,
) {
@GetMapping("/api/v1/chat")
fun chat(@RequestBody request: ChatRequest): ApiResponse<ChatResponse> {
val answer = chatService.chat(request.toChatMessage())
return ApiResponse.success(ChatResponse.from(answer))
}
}
data class ChatRequest(
val message: String,
) {
fun toChatMessage(): ChatMessage {
return ChatMessage(
message = message,
)
}
}
data class ChatResponse(
val message: String,
) {
companion object {
fun from(answer: ChatAnswer): ChatResponse {
return ChatResponse(
message = answer.message,
)
}
}
}
이제 IntelliJ HTTP Client를 이용해서, 아래와 같이 요청을 보내면 AI의 응답을 받아볼 수 있다.
GET http://localhost:8080/api/v1/chat
Content-Type: application/json
{
"message": "안녕? 너 이름은 뭐야?"
}

3. System Message 설정하기
AI에게 특정 역할이나 성격을 부여하고 싶다면 System Message를 활용할 수 있다. System Message란, AI의 행동 방식을 정의하는 일종의 "지시문"이다.
3.1 코드에서 직접 설정
`ChatClient.Builder`를 통해 기본 System Message를 설정할 수 있다.
@Configuration
class ChatClientConfig {
@Bean
fun chatClient(builder: ChatClient.Builder): ChatClient {
return builder
.defaultSystem("당신은 친절한 교육 튜터입니다. 모든 질문에 쉽고 자세하게 설명해주세요.")
.build()
}
}
3.2 파일로 관리하기
프롬프트가 길어지면 코드에서 관리하기 어려워진다. 이럴 때는 별도의 파일로 분리하는 것이 좋다.
`src/main/resources/prompt/prompt.txt`
You are an AI assistant that specializes in {subject}
You respond in a {tone} voice with detailed explanations.
@Configuration
class ChatClientConfig(
@Value("classpath:prompt/prompt.txt")
private val promptTextFile: Resource,
) {
@Bean
fun chatClient(builder: ChatClient.Builder): ChatClient {
return builder
.defaultSystem(promptTextFile)
.build()
}
}
`@Value` 애노테이션을 통해 리소스 파일을 주입받고, 이를 `defaultSystem()`에 전달하면 파일 내용이 System Message로 설정된다.
{
"result": "SUCCESS",
"data": {
"message": "뉴턴의 운동 제2법칙은 물체의 운동과 힘의 관계를 설명하는 기본적인 법칙입니다. 이 법칙은 물체에 작용하는 힘과 그 힘에 의해 발생하는 물체의 가속도 사이의 관계를 정의합니다. 제2법칙은 다음과 같은 수식으로 표현됩니다:\n\n\\[ F = ma \\]\n\n여기서:\n- \\( F \\)는 물체에 작용하는 총 힘(뉴턴, N 단위)입니다.\n- \\( m \\)은 물체의 질량(킬로그램, kg 단위)입니다.\n- \\( a \\)는 물체의 가속도(미터/초², m/s² 단위)입니다.\n\n이 법칙의 의미는 다음과 같습니다:\n1. 물체에 작용하는 힘이 클수록, 물체의 가속도도 커집니다. 즉, 더 큰 힘이 가해질수록 물체는 더 빠르게 움직이게 됩니다.\n2. 물체의 질량이 클수록 동일한 힘을 가했을 때 가속도는 작아집니다. 즉, 무거운 물체는 가벼운 물체보다 같은 힘으로 더 적게 가속됩니다.\n\n뉴턴의 운동 제2법칙은 고전역학의 기초를 이루며, 물체의 운동을 이해하고 예측하는 데 중요한 역할을 합니다. 이 법칙은 다양한 물리적 현상과 기술적 응용에 널리 사용됩니다."
}
}
4. Placeholder로 동적 프롬프트 만들기
위에서 만든 `prompt.txt`를 보면 {subject}와 {tone}같은 placeholder가 있다. 이를 런타임에 동적으로 치환시켜줄 수 있다.
4.1 System.Message에 Placeholder 적용
// ChatService.kt
fun chat(subject: String, tone: String, chatMessage: String): ChatAnswer {
val answer = chatClient.prompt()
.system { promptSpec ->
promptSpec
.param("subject", subject) // {subject} 치환
.param("tone", tone) // {tone} 치환
}
.user(chatMessage)
.call()
.content()
return ChatAnswer(chatMessage = answer ?: "")
}
이렇게 하면 같은 프롬프트 템플릿을 다양한 상황에 맞게 재사용할 수 있다.
예를들어, subject는 "자바 전문가", tone은 "친근한 말투"로 지정하여 사용할 수 있다.
4.2 User Message에도 Placeholder 사용
User Message에도 동일한 방식으로 placeholder를 사용할 수 있다.
// ChatService.kt
fun chat(directorName: String): List<Movie> {
val template = """
{directorName} 감독의 영화를 최대 3개 추천해줘.
각 영화에는 타이틀, 감독 이름, 출시년도 정보가 포함되어야 해.
""".trimIndent()
val answer = chatClient.prompt()
.user { userSpec ->
userSpec
.text(template)
.param("directorName", directorName)
}
.call()
.entity(object : ParameterizedTypeReference<List<Movie>>() {})
return answer
}
data class Movie(
val title: String,
val director: String,
val releaseYear: Int,
)
위 코드에서 .`entity()` 메서드를 통해 `Movie`라는 클래스의 규격에 맞는 응답을 받고 있는데, 이에 대한 자세한 설명은 후술하도록 한다.
Controller에서 해당 메서드를 호출하는 API를 만들고 호출해보면, 다음과 같은 응답을 받아볼 수 있다.
{
"result": "SUCCESS",
"data": [
{
"title": "플란다스의 개",
"director": "봉준호",
"releaseYear": 2000
},
{
"title": "괴물",
"director": "봉준호",
"releaseYear": 2003
},
{
"title": "마더",
"director": "봉준호",
"releaseYear": 2006
},
{
"title": "설국열차",
"director": "봉준호",
"releaseYear": 2009
},
{
"title": "황금시대",
"director": "봉준호",
"releaseYear": 2013
},
{
"title": "옥자",
"director": "봉준호",
"releaseYear": 2017
},
{
"title": "기생충",
"director": "봉준호",
"releaseYear": 2019
}
]
}
5. ChatResponse로 메타데이터 확인하기
단순히 텍스트 응답만 필요한 게 아니라면, `ChatResponse` 객체를 통해 다양한 메타데이터를 확인할 수 있다.
5.1 ChatResponse 구조
// ChatService.kt
fun chat(chatMessage: ChatMessage): ChatResponse {
val chatResponse = chatClient.prompt()
.user(chatMessage.message)
.call()
.chatResponse() // content() 대신 chatResponse() 사용
return chatResponse
}
`ChatResponse` 객체에는 대표적으로 다음과 같은 정보가 포함된다.
- 응답 텍스트: `chatResponse.result.output.text`
- 사용된 모델: `chatResponse.metadata.model`
- 토큰 사용량: `chatResponse.metadata.usage`
토큰 사용량은 OpenAI의 과금 체계에서 중요한 정보이므로, 비용 모니터링이 필요한 서비스에서 유용하게 활용할 수 있다.
좀 더 자세하게 어떤 필드가 있는지 알아보고 싶다면, 여기에서 확인해볼 수 있다.
6. Structured Output: AI 응답을 객체로 변환하기
AI의 응답을 단순 문자열이 아닌 구조화된 객체로 받고 싶은 경우가 많다. Spring AI는 이를 위한 다양한 방법을 제공한다.
6.1 List로 받기
// ChatService.kt
fun chat(chatMessage: String): List<String> {
val outputConverter = ListOutputConverter(DefaultConversionService())
val answer = chatClient.prompt()
.user(chatMessage)
.call()
.entity(outputConverter)
return answer
}
요청:
GET http://localhost:8080/api/v5/chat
Content-Type: application/json
{
"message": "미국의 주요 도시 5개를 알려줘."
}
응답:
{
"result": "SUCCESS",
"data": ["뉴욕", "로스앤젤레스", "시카고", "휴스턴", "피닉스"]
}
6.2 Map<String, Any>로 받기
fun chat(chatMessage: String): Map<String, Any> {
val answer = chatClient.prompt()
.user(chatMessage)
.call()
.entity(object : ParameterizedTypeReference<Map<String, Any>>() {})
return answer
}
요청:
GET http://localhost:8080/api/v6/chat
Content-Type: application/json
{
"message": "국가랑 수도 5개 알려줘"
}
응답:
{
"result": "SUCCESS",
"data": {
"한국": "서울",
"일본": "도쿄",
"미국": "워싱턴 D.C.",
"영국": "런던",
"프랑스": "파리"
}
}
6.2 커스텀 객체로 받기
가장 유용한 기능은 커스텀 객체로 직접 변환하는 것이다.
먼저, 받고싶은 구조대로 data class를 정의해보자.
data class Movie(
val title: String,
val director: String,
val releaseYear: Int,
)
이제 정의해둔 `Movie` 클래스를 `ChatClient` 호출부에서 `.entity` 메서드에 `ParameterizedTypeReference`를 전달해 변환하면 된다.
// ChatService.kt
fun chat(directorName: String): List<Movie> {
val template = """
{directorName} 감독의 영화를 최대 3개 추천해줘.
각 영화에는 타이틀, 감독 이름, 출시년도 정보가 포함되어야 해.
""".trimIndent()
val answer = chatClient.prompt()
.user { userSpec ->
userSpec
.text(template)
.param("directorName", directorName)
}
.call()
.entity(object : ParameterizedTypeReference<List<Movie>>() {})
return answer
}
요청:
GET http://localhost:8080/api/v7/chat
Content-Type: application/json
{
"message": "봉준호"
}
응답:
{
"result": "SUCCESS",
"data": [
{
"title": "기생충",
"director": "봉준호",
"releaseYear": 2019
},
{
"title": "옥자",
"director": "봉준호",
"releaseYear": 2017
},
{
"title": "설국열차",
"director": "봉준호",
"releaseYear": 2013
}
]
}
Structured Output 기능을 활용하면 AI 응답을 별도로 파싱할 필요 없이 바로 비즈니스 로직에서 사용할 수 있어 매우 편리하다.
7. ChatMemory로 대화 맥락 유지하기
챗봇과 같은 기능을 만들 때 가장 중요한 것 중 하나가 대화 맥락 유지다. 사용자가 이전에 했던 말을 기억하고, 그에 맞는 응답을 해야 자연스러운 대화가 가능하다.
Spring AI는 `ChatMemory`를 통해 이 기능을 제공한다.
7.1 ChatMemory Bean 등록
@Configuration
class ChatClientConfig {
@Bean
fun chatMemory(): ChatMemory {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(InMemoryChatMemoryRepository())
.maxMessages(1000) // 최대 1000개 메시지 저장
.build()
}
}
`MessageWindowChatMemory`는 슬라이딩 윈도우 방식으로 최근 N개의 메시지를 유지한다.
`InMemoryChatMemoryRepository`를 사용하면 대화 맥락이 메모리를 통해 저장되기에, 실제 서비스에서는 Redis나 DB를 활용한 다른 ChatMemory 구현체를 활용하는 것을 권장한다.
7.2 Advisor를 통한 대화 이력 관리
ChatMemory를 ChatClient에 적용하려면, Advisor를 사용할 수 있다.
// ChatService.kt
fun getResponse(conversationId: String, userMessage: String): String {
val response = chatClient.prompt()
.user(userMessage)
.advisors { advisorSpec ->
advisorSpec.advisors(
MessageChatMemoryAdvisor.builder(chatMemory)
.conversationId(conversationId) // 대화 세션 ID
.build()
)
}
.call()
.content()
return response ?: ""
}
핵심은 `conversationId`다. 동일한 ID를 사용하면 이전 대화 내용이 유지되고, 다른 ID를 사용하면 새로운 대화가 시작된다.
사용자별로 고유한 ID를 부여하면 각 사용자의 대화 맥락을 독립적으로 관리할 수 있다.
7.3 콘솔 기반 대화 예제
위에서 만든 `getResponse` 메서드를 통해, 실제로 대화 맥락이 유지되는지 테스트해보자.
@Component
class ChatApplication(
private val chatService: ChatService,
) : CommandLineRunner {
override fun run(vararg args: String?) {
chatService.startChat()
}
}
@Service
class ChatService(
private val chatClient: ChatClient,
private val chatMemory: ChatMemory,
) {
fun startChat() {
val conversationId = UUID.randomUUID().toString()
val scanner = Scanner(System.`in`)
println("채팅을 시작합니다. (종료: 'exit' 입력)")
println("========================================")
while (true) {
print("You: ")
val input = scanner.nextLine()
if (input.lowercase() == "exit") {
println("채팅을 종료합니다.")
break
}
val response = getResponse(conversationId, input)
println("AI: $response")
println()
}
}
fun getResponse(conversationId: String, userMessage: String): String {
val response = chatClient.prompt()
.user(userMessage)
.advisors { advisorSpec ->
advisorSpec.advisors(
MessageChatMemoryAdvisor.builder(chatMemory)
.conversationId(conversationId) // 대화 세션 ID
.build()
)
}
.call()
.content()
return response ?: ""
}
}
실행 결과:
채팅을 시작합니다. (종료: 'exit' 입력)
========================================
You: 내 이름은 홍길동이야.
AI: 안녕하세요, 홍길동님! 만나서 반갑습니다. 무엇을 도와드릴까요?
You: 내 이름이 뭐라고 했지?
AI: 홍길동이라고 하셨어요!
You: exit
채팅을 종료합니다.
AI가 이전 대화 내용을 기억하고 있는 것을 확인할 수 있다.
정리
지금까지 Spring AI의 핵심 기능들을 살펴보았고, 정리해보면 다음과 같다.
| 기능 | 메서드 | 설명 |
| 기본 채팅 | chatClient.prompt().user().call().content() | 가장 기본적인 AI 호출 |
| System Message | defaultSystem() 또는 .system() | AI의 역할/성격 정의 |
| Placeholder | .param("key", "value") | 동적 프롬프트 생성 |
| 메타데이터 조회 | .chatResponse() | 토큰 사용량, 모델 정보 등 |
| Structured Output | .entity() | AI 응답을 객체로 변환 |
| 대화 맥락 유지 | MessageChatMemoryAdvisor | 이전 대화 기억 |
Spring AI는 아직 발전 중인 프로젝트이지만, 기본적인 기능만으로도 충분히 실용적인 AI 서비스를 구축할 수 있다.
OpenAI 외에도 Anthropic, Ollama, Gemeni 등 다양한 AI 프로바이더를 지원하므로, 필요에 따라 쉽게 전환할 수 있다는 점도 장점이다
참고자료
'AI' 카테고리의 다른 글
| Gemeni CLI를 사용해보자 (2) | 2025.07.14 |
|---|