본문으로 건너뛰기
홈
기술
기술 전체
프로그래밍68
컴퓨터 과학63
AI48
웹 개발36
인프라33
데이터31
소프트웨어 공학18
소개
← 목록으로AI › 개발 도구 › Spring AI

Spring AI

목차

Spring AI는 Spring 애플리케이션에서 모델, 프롬프트, 도구, 검색을 공통 인터페이스로 연결한다. 아래 코드는 채팅 모델에 한 번 질문하고 점진적으로 응답을 받는 흐름에 집중한다. 예제의 메서드와 starter 이름은 Spring AI 2.0 레퍼런스를 기준으로 확인하고, 실제 프로젝트의 Spring Boot·Spring AI 버전을 함께 고정한다.

spring-ai-integration-diagram-3.svg

Spring AI는 본질적으로 AI 통합의 근본적인 과제, 즉 데이터와 API를 AI 모델에 연결에 대한 문제를 해결

스프링이 제공하는 Model API

구분설명주요 API
Chat Model텍스트·이미지 등 입력을 받아 대화를 생성하는 모델ChatModel
Image ModelText To Image 모델ImageModel
Audio Model음성 생성과 음성 인식모델별 API
Embedding ModelText To Vector 모델EmbeddingModel

실행 환경과 요청 흐름

예시는 Spring Boot 4와 Spring AI 2.0 계열의 Ollama Chat starter를 사용한다고 가정한다. Spring AI 시작 문서에서 두 버전의 호환성을 확인하고, Ollama 연동 문서의 spring-ai-starter-model-ollama 의존성을 추가한다. 모델은 Ollama에 미리 받아 둔다. 모델 이름은 코드에 고정하기보다 배포 설정 spring.ai.ollama.chat.model로 지정한다.

// build.gradle.kts의 dependencies 예시. Spring AI BOM은 프로젝트 버전에 맞춰 추가한다.
implementation("org.springframework.ai:spring-ai-starter-model-ollama")
# application.yml
spring:
  ai:
    ollama:
      base-url: `${OLLAMA_BASE_URL:http://localhost:11434}
      chat:
        model: `${OLLAMA_MODEL}

OLLAMA_MODEL에는 실제로 준비된 모델 이름을 넣는다. 모델을 자동으로 내려받게 하는 설정은 서비스 시작 시간이 길어질 수 있어 운영 환경에서는 설치·배포 단계에서 모델을 준비하는 편이 낫다(Ollama 연동 문서).

sequenceDiagram
    participant U as 사용자
    participant C as Controller
    participant S as AiService
    participant M as ChatModel
    U->>C: 질문
    C->>C: 길이·형식 검증
    C->>S: 질문 전달
    S->>M: Prompt + ChatOptions
    M-->>S: ChatResponse 또는 Flux
    S-->>C: 답변 또는 오류
    C-->>U: 응답

Controller는 외부 입력의 경계를, Service는 프롬프트와 모델 호출을 맡는다. ChatModel은 요청 형식을 제공자에게 맞게 변환하고 응답을 ChatResponse로 돌려준다. 스트리밍은 완성된 문장 하나가 아니라 여러 조각의 Flux로 돌아오므로, 클라이언트가 조각을 이어 읽어야 한다.

Chat Model

참고 : https://docs.spring.io/spring-ai/reference/api/chatmodel.html

Spring AI Message API

spring-ai-message-api.jpg

메시지별 역할

메시지 타입역할 설명
SystemMessageLLM의 행동과 응답 스타일을 지시하는 메시지
UserMessage사용자 질문, 명령을 담고 있는 메시지
AssistantMessageLLM의 응답 메시지 (단순 답변 전달 및 대화 내용 기억 유지 등 사용)
ToolResponseMessage도구 호출 결과를 LLM으로 다시 반환할 때 사용

Spring AI가 채팅 모델의 구성 및 실행을 처리하는 방식

chat-options-flow.jpg

  • 시작 대화 옵션
    • Spring AI가 지원하는 각 업체별 스타터의 의존성으로 추가하면 자동 구성에서 기본 대화 옵션이 초기화된다.(application.properties를 통해 재구성)
  • 런타임 대화 옵션
    • LLM 요청 시 전송되는 Prompt에는 개발자가 추가로 런타임 대화 옵션을 포함시킬 수 있다.
  • 대화 옵션 적용
    • ChatModel.call(Prompt)에 런타임 옵션을 주면 기본 옵션을 전체적으로 대체한다. ChatClient의 요청별 옵션은 기본 옵션의 일부를 덮는 방식이다. 두 API의 동작을 동일한 “병합”으로 이해하면 누락된 설정을 놓칠 수 있다(Chat Model API).
  • 입력 변환
    • Prompt의 메시지들과 병합된 대화 옵션을 LLM별로 이해할 수 있는 네이티브 형식으로 변환
  • 출력 변환
    • LLM의 출력을 표준화된 ChatResponse형식으로 변환

ChatModel 사용예시


import org.springframework.ai.chat.messages.AssistantMessage
import org.springframework.ai.chat.messages.SystemMessage
import org.springframework.ai.chat.messages.UserMessage
import org.springframework.ai.chat.model.ChatModel
import org.springframework.ai.chat.model.ChatResponse
import org.springframework.ai.chat.prompt.ChatOptions
import org.springframework.ai.chat.prompt.Prompt
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux

@Service
class AiService(
    private val chatModel: ChatModel
) {

    fun generateText(question: String): String {
        val (systemMessage: SystemMessage, userMessage: UserMessage, chatOption: ChatOptions) = chatOption(question)

        //프롬프트 설정
        val prompt: Prompt = Prompt.builder()
            .messages(systemMessage, userMessage)
            .chatOptions(chatOption)
            .build()

        val chatResponse: ChatResponse = chatModel.call(prompt)

        val assistantMessage: AssistantMessage? = chatResponse.result?.output

        return requireNotNull(assistantMessage?.text) { "모델이 텍스트 응답을 반환하지 않았습니다." }
    }

    private fun chatOption(question: String): Triple<SystemMessage, UserMessage, ChatOptions> {
        //시스템 메시지 새성
        val systemMessage: SystemMessage = SystemMessage.builder()
            .text("사용자 질문에 대해 한국어로 답변해야 합니다.")
            .build()

        //사용자 메시지 생성
        val userMessage: UserMessage = UserMessage.builder()
            .text(question)
            .build()

        //대화 옵션 설정
        val chatOption: ChatOptions = ChatOptions.builder()
            .temperature(0.3)
            .maxTokens(1000)
            .build()
        return Triple(systemMessage, userMessage, chatOption)
    }

    fun generateTextByStream(question: String): Flux<String> {
        val (systemMessage: SystemMessage, userMessage: UserMessage, chatOption: ChatOptions) = chatOption(question)

        //프롬프트 설정
        val prompt: Prompt = Prompt.builder()
            .messages(systemMessage, userMessage)
            .chatOptions(chatOption)
            .build()

        val chatResponse: Flux<ChatResponse> = chatModel.stream(prompt)

        return chatResponse.map { response ->
            val assistantMessage = response.result?.output
            assistantMessage?.text.orEmpty()
        }.filter { it.isNotEmpty() }
    }

}

ChatClient 사용 예시

ChatClient를 사용하면 ChatModel설정 등을 간단하게 작성할 수 있다.

import org.springframework.ai.chat.client.ChatClient
import org.springframework.ai.chat.prompt.ChatOptions
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux

@Service
class ChatClientAiService(
    private val chatClient: ChatClient
) {

    fun generateText(question: String) : String {
        return chatClient.prompt()
            .system { "사용자 질문에 대해 한국어로 답변을 해야 합니다." }
            .user { question }
            .options(
                ChatOptions.builder()
                    .temperature(0.3)
                    .maxTokens(1000)
                    .build()
            )
            .call()
            .content().let { requireNotNull(it) { "모델이 텍스트 응답을 반환하지 않았습니다." } }
    }

    fun generateTextByStream(question: String): Flux<String> {
        return chatClient.prompt()
            .system { "사용자 질문에 대해 한국어로 답변을 해야 합니다." }
            .user { question }
            .options(
                ChatOptions.builder()
                    .temperature(0.3)
                    .maxTokens(1000)
                    .build()
            )
            .stream()
            .content()
    }
}

API Controller

스트림을 JSON 객체 단위로 받고 싶다면 application/x-ndjson을 사용할 수 있다. Flux<String>을 그대로 반환하면 조각이 JSON 객체라는 보장이 없으므로, 아래 Controller는 문자열 조각을 ChatChunk 객체로 감싼다. Spring의 JSON 직렬화가 객체마다 한 줄을 만든다는 전제의 예시다. 프로젝트에서 사용 중인 Spring MVC/WebFlux 코덱 설정으로 실제 응답을 확인한다.

ndjson 포맷은 Newline Delimited JSON 포맷의 줄임말로 줄단위 JSON 스트리밍이다.

각 줄은 독립적인 JSON 값이고 줄바꿈 \n으로 구분한다. \r은 필수가 아니다. 네트워크 연결이 중간에 끊기면 마지막 줄이 완성되지 않을 수 있으므로 소비자는 완성된 줄만 파싱한다.

JSON의 경우 하나의 JSON 객체 또는 배열 정의

[
	{"id":1,"name":"Alice","email":"alice@example.com"},
	{"id":2,"name":"Bob","email":"bob@example.com"},
	{"id":3,"name":"Charlie","email":"charlie@example.com"}
]

NDJSON의 경우 각 줄이 독립적인 JSON 객체

{"id":1,"name":"Alice","email":"alice@example.com"}
{"id":2,"name":"Bob","email":"bob@example.com"}
{"id":3,"name":"Charlie","email":"charlie@example.com"}

위 줄은 형식 비교를 위한 가상 응답이다. chatStream의 실제 필드는 id·name이 아니라 delta이며, 클라이언트가 여러 delta를 순서대로 이어 최종 답을 만든다.

장점

  • 점진적 처리 : 데이터가 도착하는 대로 즉시 처리 가능
  • 메모리 효율: 한번에 한 줄만 메모리에 유지
  • 장애 복구: 연결이 끊겨도 이미 받은 줄은 유효
  • 단순한 파싱: 줄단위로 호출가능

import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Flux

@RestController
@RequestMapping("/ai")
class AiController(
    private val chatClientAiService: ChatClientAiService
) {
    data class ChatChunk(val delta: String)

    @PostMapping(
        value = ["/chat"],
        consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE],
        produces = [MediaType.TEXT_PLAIN_VALUE]
    )
    fun chat(@RequestParam("question") question: String): String {
        val cleaned = question.trim()
        require(cleaned.isNotEmpty() && cleaned.length <= 500) { "질문은 1자 이상 500자 이하로 입력하세요." }
        return chatClientAiService.generateText(cleaned)
    }

    @PostMapping(
        value = ["/chat-stream"],
        consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE],
        produces = [MediaType.APPLICATION_NDJSON_VALUE]
    )
    fun chatStream(@RequestParam("question") question: String): Flux<ChatChunk> {
        val cleaned = question.trim()
        require(cleaned.isNotEmpty() && cleaned.length <= 500) { "질문은 1자 이상 500자 이하로 입력하세요." }
        return chatClientAiService.generateTextByStream(cleaned)
            .map { ChatChunk(delta = it) }
    }
}

Controller가 require로 던지는 입력 오류는 애플리케이션의 예외 처리기에서 HTTP 400과 짧은 사용자 메시지로 바꾼다. 모델 연결 실패는 같은 입력 오류로 합치지 말고 재시도 가능한 서버 오류로 처리한다. 여러 JSON 조각을 받은 뒤 연결이 끊기면 이미 받은 조각이 완성된 최종 답인 것은 아니다. 상태 completed를 별도로 보내거나, 최종 결과를 조회하는 API를 둔다. 단순히 텍스트만 실시간으로 보여주고 싶다면 Spring의 SSE 응답 방식도 선택지다.

프롬프트 템플릿

프롬프트란 AI 모델에게 사용자가 원하는 작업을 구체적으로 지시하거나 질문의 형태로 요구사항을 전달하는 일종의 명령문

Spring AI는 프롬프트를 Prompt 클래스로 표현하고, 메시지들(Message)과 대화 옵션(ChatOption)을 담고 있다.

메시지 타입역할 설명
SystemMessageLLM의 행동과 응답 스타일을 지시하는 메시지로, LLM이 입력을 해석하는 방법과 답변하는 방식을 지시
UserMessage사용자의 질문, 명령을 담고 있는 메시지
AssistantMessageLLM의 응답 메시지. 단순한 답변 전달을 넘어. 대화 기억 유지에도 사용되어 일관되고 맥락에 맞는 대화에 도움을 준다.

import org.springframework.ai.chat.client.ChatClient
import org.springframework.ai.chat.prompt.PromptTemplate
import org.springframework.ai.chat.prompt.SystemPromptTemplate
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux

@Service
class AIPromptTemplateService(
    private val chatClient: ChatClient
) {

    companion object {
        private val systemTemplate: PromptTemplate = SystemPromptTemplate.builder()
            .template(
                """
                    답변을 생성할 때 HTML과 CSS를 사용하여 파란 글자로 출력해주세요.
                    <span> 태그 안에 들어갈 내용만 출력해주세요.
                """
            ).build()

        private val userTemplate: PromptTemplate = PromptTemplate.builder()
            .template("""
                다음 한국어 문장을 {language}로 번역해주세요. \n 문장 : {statement}    
            """.trimIndent())
            .build()
    }

    fun promptTemplate1(statement: String, language: String): Flux<String> {
        val prompt = userTemplate.create(mapOf("statement" to statement, "language" to language))
        return chatClient.prompt(prompt)
            .stream()
            .content()
    }

    fun promptTemplate2(statement: String, language: String): Flux<String> {
        return chatClient.prompt()
            .messages(systemTemplate.createMessage(), userTemplate.createMessage(mapOf("statement" to statement, "language" to language)))
            .stream()
            .content()
    }

    fun promptTemplate3(statement: String, language: String): Flux<String> {
        return chatClient.prompt()
            .system(systemTemplate.render())
            .user(userTemplate.render(mapOf("statement" to statement, "language" to language)))
            .stream()
            .content()
    }

    fun promptTemplate4(statement: String, language: String): Flux<String> {
        val systemText = """
            답변을 생성할 때 HTML과 CSS를 사용하여 파란 글자로 출력해주세요.
                    <span> 태그 안에 들어갈 내용만 출력해주세요.
        """
        val userText = """
            다음 한국어 문장을 %s로 번역해주세요. \n 문장 : %s
        """.format(language, statement)

        return chatClient.prompt()
            .system(systemText)
            .user(userText)
            .stream()
            .content()
    }
}

디폴트 메시지 및 옵션

LLM에게 공통으로 사용되는 메시지와 옵션을 설정

메소드설정
defaultSystem()기본 SystemMessage를 추가
defaultUser()기본 UserMessage를 추가
defaultOption()기본 대화 옵션을 설정
@Service
class AIDefaultMethodService(chatClientBuilder: ChatClient.Builder) {

    private val chatClient: ChatClient = chatClientBuilder
        .defaultSystem("적절한 감탄사, 웃음 등을 넣어서 친절하게 대화해 주세요.")
        .defaultOptions(ChatOptions.builder()
            .temperature(1.0)
            .maxTokens(300)
            .build()
        )
        .build()

    fun defaultMethod(question: String): Flux<String> {
        return chatClient.prompt()
            .user { question }
            .stream()
            .content()

    }
}

Advisor

Advisor는 스프링 애플리케이션과 LLM간의 상호작용을 가로채어, LLM에게 전달되는 프롬프트를 강화하거나 LLM의 응답을 변환하여 유연하고 강력한 방법을 제공

Advisor를 사용하면 LLM과 상호작용하여 반복적으로 사용되는 전처리 및 후처리 로직을 캡슐화하여, 재사용 가능하고 유지 관리가 용이한 AI 구성 요소를 만들 수 있다.

전처리 작업은 주로 프롬프트에 컨텍스트를 추가하는 과정을 의미

advisors-flow.jpg

스프링 AI의 Advisor 구조는 기존 Spring AOP의 철학을 계승한 것으로, 공통 기능의 분리와 재사용이 가능하다.

  • 사용자 요청을 데이터베이스에서 검색하여 프롬프트에 추가하는 기능
  • 요청과 응답에 대한 안정성을 필터링 하는 기능
  • 사용자 위치, 날짜 등 외부 정보를 프롬프트에 추가하는 기능
  • 로깅을 위한 기능

Advisor를 넣으면 요청이 어떻게 달라지는가?

예를 들어 “출장비는 얼마인가요?”라는 질문에 회사 규정 문서를 붙이고 싶다고 하자. 검색 Advisor는 사용자 질문을 받아 허용된 문서를 검색하고, 검색된 텍스트를 모델 요청에 추가한다. 모델 응답 후에는 출처나 검색 상태를 응답 컨텍스트로 남길 수 있다. Advisor가 여러 개라면 실행 순서에 따라 다음 Advisor가 받는 요청이 달라진다(Spring AI Advisors API). 따라서 “로깅 → 권한 필터 → 검색 → 모델”처럼 의도한 순서를 정하고 테스트한다.

flowchart LR
    Q[사용자 질문] --> V[입력·사용자 검증]
    V --> A[Advisor: 허용 문서 검색]
    A --> P[Prompt에 근거 추가]
    P --> M[ChatModel 호출]
    M --> R[답변·출처 확인]
    R --> U[사용자 응답]

이 그림에서 검색 결과가 비어 있으면 모델이 알고 있을 법한 답을 그대로 내보낼지, 근거 부족으로 멈출지 정책을 먼저 정해야 한다. 규정처럼 근거가 필요한 과제라면 보통 후자를 택한다. Advisor가 문서를 추가했다는 사실만으로 그 문서가 현재 사용자에게 허용됐다는 뜻은 아니다. DB 검색 단계에서 접근 범위를 필터링하고, 응답에서 인용한 문서 ID가 검색 결과에 있었는지 확인한다. Spring AI의 QuestionAnswerAdvisor는 검색 결과를 문맥에 넣는 출발점이지만, 권한과 출처 검증은 애플리케이션의 책임이다.

ChatModel과 ChatClient 중 무엇을 사용할까?

ChatModel을 직접 호출하면 Prompt와 ChatResponse를 제어하기 쉽다. 모델의 여러 생성 결과, 응답 메타데이터, 요청별 옵션을 살펴야 할 때 유용하다. ChatClient는 기본 시스템 메시지와 Advisor를 조립해 재사용하고, 단순한 문자열 응답이나 스트림을 받을 때 편하다. 두 API 모두 서버 입력 검증과 권한 확인을 자동으로 해결하지는 않는다.

요구먼저 볼 API확인할 점
간단한 질의응답ChatClientcontent()가 비어 있을 때의 처리
응답 메타데이터·원시 결과 검사ChatModel제공자별 메타데이터 차이
공통 검색·메모리·후처리ChatClient와 AdvisorAdvisor 순서와 사용자별 상태 키
토큰 단위 화면 표시stream()부분 응답·연결 종료·최종 상태

위 코드의 temperature와 maxTokens는 요청 옵션의 예다. 모델마다 지원 범위와 의미가 같지 않을 수 있으므로 제공자 연동 문서에서 확인한다. 특히 모델 이름을 요청마다 코드에 써 넣으면 로컬과 운영 서버에서 다른 모델이 로드돼 예상치 못한 지연이나 실패가 생길 수 있다. 모델 이름, 타임아웃, 스트리밍 사용 여부는 배포 환경별 설정으로 관리하고 평가 데이터셋에 사용한 값을 기록한다.

HTML 응답과 프롬프트 템플릿의 경계

앞의 번역 예시는 시스템 메시지에 <span> 출력을 지시한다. 모델이 반환한 문자열을 브라우저의 innerHTML에 그대로 넣으면, 사용자가 입력한 문장이 HTML로 되돌아오거나 모델이 예상하지 못한 마크업을 만들 때 XSS가 생길 수 있다. 화면에서 색을 입히려면 텍스트 응답을 이스케이프해 출력하고 CSS 클래스로 스타일을 적용하는 편이 단순하다. HTML 생성이 과제 자체라면 허용 태그와 속성만 통과시키는 별도 정화 단계를 둔다.

PromptTemplate의 {language}와 {statement}는 변수 자리를 명확하게 보여준다. 하지만 템플릿 치환은 외부 입력을 신뢰할 수 있는 시스템 지시로 바꾸지 않는다. 사용자가 statement 안에 “이전 지침 무시”를 넣을 수도 있다. 번역 서비스라면 언어 목록을 서버에서 제한하고, 입력 길이와 출력 길이를 제한하고, 결과를 일반 텍스트로 다룬다. 제공자 장애나 응답 누락은 빈 문자열로 숨기지 말고 사용자가 재시도할 수 있는 상태로 돌려준다.

확인할 실패 사례

  1. Ollama 서버가 꺼져 있을 때 응답이 비거나 무한 대기하지 않는가? 요청 시간 제한과 사용자 오류 메시지를 확인한다.
  2. 모델 이름이 서버에 없을 때 자동 다운로드가 서비스 시작을 막지 않는가? 배포 전에 모델을 준비한다.
  3. 질문 길이가 500자를 넘거나 공백만 있을 때 Controller가 HTTP 400을 내는가? 예외 매핑을 테스트한다.
  4. NDJSON 연결이 중간에 끊겼을 때 UI가 임시 조각을 완성 답으로 저장하지 않는가?
  5. 검색 Advisor가 허용되지 않은 문서를 문맥에 넣지 않는가? 검색 결과 ID와 사용자 권한을 함께 기록한다.

이 목록은 실행 결과가 아니라 구현 후 확인할 기준이다. 성공 사례 하나를 수동으로 호출하는 것보다, 실패 경계와 사용자에게 보이는 상태를 먼저 고정해 두면 모델이나 제공자를 바꿀 때 영향 범위를 찾기 쉽다.

참고 문서