Spring AI 시작하기: ChatClient로 OpenAI API 연동

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