Spring AI - chatMemory와 Tool Call

케로로·2026년 9월 1일
post-thumbnail

ChatMemory

LLM의 무상태성(Stateless)과 한계

  • 상태가 없는 구조: 대규모 언어 모델(LLM)은 근본적으로 상태가 없어(Stateless) 각 요청이 이전 상호작용과 완전히 독립적으로 처리됩니다.

  • 대화 맥락 유실: 첫 턴에 "내 이름은 제임스 본드야"라고 말하더라도 다음 턴에 "내 이름이 뭐지?"라고 물으면 LLM은 이전 기억을 유지하지 못해 답변하지 못합니다.

  • 별도 메커니즘의 필요성: 여러 턴에 걸쳐 자연스러운 대화를 이어가기 위해서는 대화의 맥락을 붙들어 매어줄 별도의 장치가 필수적입니다.

외부 세계와의 연계와 Memory의 포지션

  • 지식 단절 및 한계 극복: LLM 단독으로는 최신 정보 부족, 환각 현상, 컨텍스트 한계 등의 문제를 겪으므로 외부 시스템과의 연계가 필수적입니다.
  • 역할 분담:
    • LLM: 생각과 추론 담당
    • Retrieval: Vector Store 등을 통한 외부 지식 및 최신·사내 데이터 검색
    • Tools: Function Calling 및 API 연계를 통한 외부 DB 조회 및 시스템 자동화
    • Memory: 대화 상태 유지 및 사용자 히스토리·장기 컨텍스트 저장

Chat Memory의 정의 및 주요 기능

  • 정의: 사용자와 LLM 간 주고받은 대화 이력(System, User, Assistant 메시지 목록)을 일관되게 저장, 관리, 재구성하여 지속적인 대화 맥락을 유지해 주는 메모리 관리 컴포넌트입니다.

  • 주요기능

    • 대화 저장 및 조회: conversationId(사용자/세션별 식별자)를 기반으로 이전 메시지들을 식별하고 체계적으로 저장합니다.
    • 토큰 및 메시지 수 제한 (Memory Windowing): 대화가 길어질수록 LLM의 토큰 한계(Context Window)를 초과하고 비용이 증가하므로 최근 N개의 대화만 유지하거나(Sliding Window) 요약하여 관리합니다.
    • 영속성(Persistence) 지원: 애플리케이션이 재시작되어도 대화가 유실되지 않도록 In-Memory 방식 외에도 Redis나 외부 데이터베이스(DB)에 메시지 내역을 안전하게 보존합니다.

Spring AI의 ChatMemory 인터페이스

  • 인터페이스 정의: ChatMemory 인터페이스는 대화 기억을 관리하기 위해 기본 메서드들을 정의합니다.

  • 핵심 메서드:

    • add(String conversationId, Message message): 유효성 검증(Assert)을 거쳐 단일 메시지를 추가하는 default 메서드입니다.
    • add(String conversationId, List<Message> messages): 대화 이력 목록을 추가합니다.
    • get(String conversationId): 특정 세션의 메시지 목록을 조회합니다.
    • clear(String conversationId): 특정 세션의 대화 기억을 삭제합니다.

ChatMemory의 기본 구현체: MessageWindowChatMemory

  • 기본 특징: Spring AI에서 기본으로 제공 및 자동 구성되는 핵심 구현체로 기본 윈도우 크기(maxMessage)는 20개(UserMessage + AssistantMessage 합산)입니다.
  • 저장소 활용: InMemoryChatMemoryRepository 등 다양한 ChatMemoryRepository를 선택하여 이용할 수 있습니다.
  • 일반적인 사용법 (자동 주입): ChatClient.Builder 설정 시 MessageChatMemoryAdvisor에 chatMemory를 등록하여 일반 Bean처럼 자동 주입받아 사용합니다.
  • 수동 설정: 윈도우 크기를 변경하려면 MessageWindowChatMemory.builder().maxMessage(10).build()와 같이 수동으로 생성할 수 있습니다.
// 1) 자동 주입 및 기본 사용법: Spring이 자동 구성한 chatMemory와 chatClientBuilder를 주입받아 설정
public MessageChatMemoryService(ChatMemory chatMemory, ChatClient.Builder chatClientBuilder) {
    this.chatClient = chatClientBuilder
        .defaultAdvisors(
            // 대화 맥락을 자동으로 관리하기 위해 Advisor에 chatMemory 등록
            MessageChatMemoryAdvisor.builder(chatMemory).build(),
            // 프롬프트 및 응답 과정을 로그로 확인하기 위한 로거 어드바이저 추가
            new SimpleLoggerAdvisor()
        )
        .build();
}
// 2) 수동 설정: 대화 윈도우 크기를 10개로 제한하여 ChatMemory 인스턴스 직접 생성
ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .maxMessage(10) // 최근 유지할 메시지 개수 설정 (UserMessage + AssistantMessage 합산)
    .build();

RedisChatMemoryRepository 적용 방법 (영속성 확장)

  • 의존성 추가: Redis를 백엔드 저장소로 활용하기 위해 spring-ai-starter-model-chat-memory-repository-redis 의존성을 주입합니다.
  • 코드 설정: @Autowired로 주입받은 RedisChatMemoryRepository를 MessageWindowChatMemory.builder()에 바인딩하여 애플리케이션 재시작 후에도 대화가 유지되도록 구성할 수 있습니다.
// Redis 기반의 영속성 저장소를 주입받아 애플리케이션 재시작 후에도 대화 이력이 유지되도록 설정
@Autowired
RedisChatMemoryRepository chatMemoryRepository;

// 주입받은 Redis 저장소와 최대 메시지 수(10개)를 조합하여 MessageWindowChatMemory 인스턴스 수동 생성
ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .chatMemoryRepository(chatMemoryRepository) // 대화 데이터를 Redis에 저장/조회하도록 지정
    .maxMessages(10) // 메모리에 유지할 최대 메시지 개수 설정 (User + Assistant 합산)
    .build();

MessageChatMemoryAdvisor

  • 자동 주입 기능: ChatMemory 기능을 ChatClient 호출 과정에 적용하여 이전 대화를 Prompt에 자동 주입하고 LLM에 전달합니다.
  • 세션 기반 관리: 대화 내용을 Session 단위로 ChatMemory에 저장하여 대화 이력을 영속적으로 관리합니다.
  • MessageChatMemoryAdvisor: ChatMemory에서 받은 대화 기억을 User Message와 AssistantMessage로 구분하여 생성한 뒤 프롬프트에 추가하며 LLM이 역할(Role) 기반으로 추론할 수 있도록 그대로 유지합니다.
// 1. 최대 메시지 수(윈도우 크기)를 10개로 제한하여 대화를 기억하는 ChatMemory 인스턴스 생성
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.maxMessage(10)
.build();

// 2. 생성한 chatMemory를 기반으로 대화 이력을 프롬프트에 자동으로 주입하는 MessageChatMemoryAdvisor 생성
MessageChatMemoryAdvisor advisor = MessageChatMemoryAdvisor.builder(chatMemory)

// 3. ChatClient 빌더에 defaultAdvisor로 위에서 만든 advisor를 등록하여 대화 맥락이 유지되는 ChatClient 완성
ChatClient chatClient = chatClientBuilder.defaultAdvisors(advisor).build();

MessageChatMemoryAdvisor 저장 방식 및 구조

  • 메시지 배열 방식: 대화 기록을 별도의 메시지 객체(Message) 리스트로 만들어 현재 메시지에 그대로 끼워 넣는 방식을 사용합니다.
  • 표준 지원: OpenAI(GPT), Claude, Gemini 등 대부분의 최신 Chat Model이 이 방식(Messages Array)을 표준으로 지원합니다.
  • 순서 인식: 모델은 순서가 있는 대화 목록에서 가장 아래에 있는 메시지를 현재 요청으로 인식합니다.
{
    "messages": [ // 대화 기록을 별도의 메시지 객체(Message) 리스트로 만들어 현재 메시지에 끼워 넣는 방식의 JSON 구조
        {
            "role": "system",
            "content": "..."
        },
        {
            "role": "user",
            "content": "Spring AI가 뭐야?" // 과거 User 메시지 (대화 이력)
        },
        {
            "role": "assistant",
            "content": "Spring 기반 AI 애플리케이션 개발 프레임워크입니다." // 과거 Assistant 메시지 (대화 이력)
        },
        {
            "role": "user",
            "content": "그럼 Advisor는 뭐야?" // 현재 User 메시지 (가장 마지막에 위치하여 모델이 현재 요청으로 인식)
        }
    ]
}

Session 별 대화 기억 Index 관리

  • 고유 Index 필요: 대화를 ChatMemory에 저장하거나 추출할 때 세션을 구분하기 위한 고유 Index가 필수적입니다.
  • 식별자 활용: 서블릿 컨테이너(내장 Tomcat)에서 발급한 세션 식별자(HttpSession)나 사용자 ID, Token 등을 활용할 수 있습니다.
  • 대화 ID 유지: ChatMemory.CONVERSATION_ID Context Key에 고유 ID를 할당하여 Session 별 대화 기억을 유지합니다.
// /default 엔드포인트로 들어오는 GET 요청을 처리하며, 사용자 질문(question)과 세션(session) 정보를 파라미터로 받음
@GetMapping("/default")
public Map<String, Object> chatMemory(@RequestParam String question, HttpSession session) {
    String answer = chatClient.prompt().user(userText)
            // 1. 현재 HTTP 세션의 고유 ID를 ChatMemory의 대화 ID(CONVERSATION_ID)로 전달하여 세션별 대화 맥락을 분리/유지함
            .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, session.get()))
            .call()
            .content(); // 응답 본문(텍스트)을 문자열로 추출

    // 2. 클라이언트에게 전달할 결과 데이터를 Map 객체로 생성하여 반환 (질문, 세션 ID, 답변 포함)
    return Map.of(
            "question", question,
            "conversationId", session.getId(),
            "answer", answer);
}

Advisor를 통한 ChatClient와 ChatMemory 통합 흐름

AOP 스타일 메커니즘: ChatClient의 호출 전후에 특정 로직을 실행하는 AOP 스타일의 매커니즘을 제공합니다.

  1. chatClient.prompt() 호출: 사용자가 보낸 질문과 함께 대화 생성을 요청하는 초기 진입점입니다.
  2. Advisor 호출 (Invoke 'Advisor'): 실제 LLM에 요청이 전달되기 전 AOP 스타일의 어드바이저가 가로채어 대화 기억을 불러오는 부가 로직을 실행합니다.
  3. chatMemory.get(conversationId) 호출: 메모리 관리 컴포넌트가 세션 고유 식별자(conversationId)를 사용해 해당 사용자의 이전 대화 이력을 조회합니다.
  4. ChatMemoryRepository에서 메시지 패치 (Fetch Messages): 메모리 저장소(InMemoryChatMemoryRepository나 Redis 등)에 실제로 저장되어 있던 과거 메시지 데이터들을 읽어옵니다.
    (이 단계에서 저장소에 보관 중인 과거의 UserMessage와 AssistantMessage 목록을 가져오게 됩니다.)
  5. 검색된 메시지 반환 (Retrieved Messages): 저장소에서 가져온 대화 기록 목록이 어드바이저 쪽으로 전달됩니다.
  6. 프롬프트에 대화 이력 주입 (Augment Prompt): 가져온 과거 대화 목록을 현재 프롬프트의 메시지 배열(messages)에 그대로 끼워 넣어 LLM이 이전 맥락을 파악할 수 있도록 프롬프트를 확장합니다.
  7. 확장된 프롬프트를 LLM에 전송 (Send Augmented Prompt): 과거 대화 이력과 현재 사용자 질문이 모두 포함된 완성된 프롬프트가 LLM에게 최종 전달되어 문맥에 맞는 답변을 생성하게 됩니다.

VectorStoreChatMemoryAdvisor

  • 벡터 저장소 활용: 대화 내용을 임베딩 모델을 통해 벡터로 변환한 뒤 Vector Store(PGVector 등)에 저장하여 관리합니다.
  • 의미 기반 검색: 시간 순서대로 단순히 이전 대화를 가져오는 것이 아니라 현재 대화와 의미적으로 가장 관련성이 높은 과거 대화 내용을 검색하여 컨텍스트에 포함합니다.
  • 장기 기억(Long-term Memory): 현재 대화와 관련된 기억만 선택적으로 활용할 수 있어 특정 주제에 대한 심층적인 대화나 장기 기억 유지에 매우 효과적입니다.

애플리케이션 구성 (VectorStoreConfig)

  • Bean 설정: ChatMemory 전용 VectorStore Bean을 생성하는 Configuration 클래스입니다.
  • PgVectorStore 활용: JdbcTemplate과 EmbeddingModel을 주입받아 PgVectorStore.builder()를 통해 스키마 이름과 테이블 이름(chat_memory_vector_store)을 지정하여 설정합니다.

VectorStoreChatMemoryAdvisor 사용법

  • Advisor 등록: VectorStore를 주입받아 VectorStoreChatMemoryAdvisor.builder(vectorStore).build() 형태로 ChatClient의 기본 어드바이저에 등록합니다.
  • 세션 연동: 기존 방식과 동일하게 .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, conversationId))를 통해 세션별 대화 ID를 전달하여 동작합니다.
public class VectorStoreService {
    private ChatClient chatClient;

    // 1. 생성자 주입: VectorStore와 ChatClient.Builder를 받아 VectorStore 전용 ChatClient 설정
    public VectorStoreService(
            @Qualifier("chatMemoryVectorStore") VectorStore vectorStore,
            ChatClient.Builder chatClientBuilder) {
        
        // ChatClient 객체 생성 및 기본 어드바이저 설정
        this.chatClient = chatClientBuilder
                // VectorStoreChatMemoryAdvisor를 등록하여 대화 내용을 벡터로 변환/검색하도록 지정
                .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(vectorStore).build(), new SimpleLoggerAdvisor())
                .build();
    }

    // 2. 대화 요청 메서드: 사용자 입력과 세션 ID를 받아 LLM 응답 반환
    public String chat(String userText, String conversationId) {
        String answer = chatClient.prompt()
                .user(userText) // 사용자가 입력한 현재 텍스트 전달
                // 현재 대화가 속한 세션 ID를 파라미터로 전달하여 해당 세션의 기억을 관리/조회함
                .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, conversationId))
                .call()
                .content(); // LLM의 응답 내용을 문자열로 추출
        
        return answer;
    }
}

특성 비교 요약 (MessageChatMemoryAdvisor vs VectorStoreChatMemoryAdvisor)

항목MessageChatMemoryAdvisorVectorStoreChatMemoryAdvisor
기억 형태단기 기억 (Working Memory)장기 기억 (Semantic Memory)
저장 위치다양한 ChatMemoryRepositoryVector Store (PGVector, Weaviate 등)
LLM 전달 방식대화 원문 전체/일부를 Prompt에 삽입의미 기반으로 검색된 메시지만 삽입
토큰 소비많음 (대화가 길어짐)적음 (필요한 메시지만 전달)
저장 한계토큰 제한으로 오래된 내용 삭제사실상 무제한
적합한 질문"내가 뭐라고 했지?""이전 주제 다시 설명해줘"
활용 목적대화 흐름 이어가기지식 검색 / 장기 기억

Tool Call

AI 모델이 외부 시스템의 기능(Tool)을 호출해서 자신의 능력을 확장하기 위한 기술 및 패턴으로 Function Call이라고도 불립니다.

  • 정보 조회 (Query): 데이터베이스, 웹 검색 엔진, 파일 시스템, 최신 날씨 등을 검색하여 최신 정보나 사용자 맞춤형 데이터를 실시간으로 확인하고 스스로 답할 수 없는 질문에 적절한 응답을 생성합니다.
  • 기능 실행 (Control): 이메일 전송, 데이터베이스 갱신, API 호출, 장치 제어 등의 외부 조치를 자동으로 실행합니다.

Tool Calling 동작 흐름 (단계별 상세)

1) 도구 정의 포함: 모델이 Tool을 사용할 수 있도록 채팅 요청에 Tool 정의(Name, description, Input Param Schema)를 포함합니다.

2) 모델의 호출 요청: 모델이 Tool 호출이 필요하다고 판단하면 정의된 스키마를 기반으로 Tool 이름과 입력 변수값을 포함하는 응답을 Spring AI에 전송합니다.

3) 애플리케이션의 Tool 실행: Spring AI는 Tool 이름을 기준으로 해당 Tool을 식별하고 전달받은 입력 파라미터로 Tool을 실행합니다. (예: getCurrentWeather 메서드 실행)

4) 실행 결과 전달: Tool의 실행 결과를 다시 모델에게 전달합니다.

5) 최종 답변 생성: 모델은 Tool 실행 결과를 추가 컨텍스트로 사용하여 최종 답변을 생성합니다. (예: "오늘 날씨가 14도로 추워요 감기 조심하세요" 답변 생성)

구분LLM (AI Model)Spring AI (애플리케이션)
역할 및 책임추론 및 판단, 도구 호출 요청도구 정의 제공, 실제 도구 실행 및 중계
수행 작업• 스스로 답할 수 없는 질문인지 인지하고 필요한 Tool 호출 요청
• 필요한 매개 변수(Input Param)를 추론하여 전달
• Tool 실행 결과를 컨텍스트로 받아 최종 답변 생성
• 도구(Tool)들을 정의하고 목록을 LLM에게 노출/전달
• LLM이 보낸 도구 호출 요청을 수신 및 메서드로 매핑
• 전달받은 입력 파라미터로 실제 외부 기능(메서드 등)을 실행

Spaing AI의 Tool

Spring AI Tool 정의 및 구현 방법

  • @Tool 어노테이션
    • 역할: 자바 메서드를 LLM이 인식하고 호출할 수 있는 도구(Tool)로 만드는 선언적이고 직관적인 어노테이션입니다.
    • 주요 속성: name(도구의 이름, 생략 시 메서드명 사용)과 description(LLM이 도구의 기능을 이해하도록 돕는 상세 설명)을 지정할 수 있습니다.
  • @ToolParam 어노테이션
    • 역할: 메서드에 전달되는 파라미터의 상세 정보를 지정합니다. (생략 시 타입 정보와 메서드 Signature만 보고 자동 추론)
    • 주요 속성: description(파라미터에 대한 설명)과 required(필수 여부, 기본값 true)를 설정할 수 있습니다.
@Tool(description = "도시 이름으로 현재 날씨를 조회합니다. 예: Seoul")
public String getCurrentWeather(
	@ToolParam(description = "도시 이름 (예: Seoul)", required = true) String city) {

시간 정보 검색 도구(DateTimeTools) 구현 예제 코드

@Component // 1. 스프링 빈으로 등록하여 관리되도록 설정
@Slf4j
public class DateTimeTools {

    // 2. LLM이 이 메서드를 어떤 도구인지 인식할 수 있도록 @Tool 설명 추가
    @Tool(description = "사용자의 시간대에 맞는 현재 날짜와 시간 정보를 제공합니다.")
    public String getCurrentDateTime(
            // 3. LLM이 어떤 입력값을 넣어야 하는지 알 수 있도록 파라미터 설명 추가
            @ToolParam(description = "지역명 (예: Asia/Seoul)", required = true) String regionName) {
        
        // 전달받은 지역명(ZoneId)을 기반으로 현재 시간 계산
        String nowTime = ZonedDateTime.now(ZoneId.of(regionName)).toString();
        log.info("현재 시간 조회: region={}, time={}", regionName, nowTime);
        
        return nowTime;
    }
}

ChatClient에 Tool 등록 및 사용 방법

@Service
public class ChatService {

    private final ChatClient chatClient;

    // 생성자 주입을 통해 ChatClient와 직접 만든 도구(DateTimeTools 등)들을 연동
    public ChatService(ChatClient.Builder chatClientBuilder, 
                       DateTimeTools dateTimeTools, 
                       FileSystemTool fileSystemTool, 
                       WeatherTools weatherTools) {
        
        this.chatClient = chatClientBuilder
                // .tools() 메서드를 통해 애플리케이션이 가진 도구 객체들을 등록
                // Spring AI가 메서드의 메타데이터를 Tool Definition 형태로 LLM에 전달함
                .defaultTools(dateTimeTools, fileSystemTool, weatherTools)
                .build();
    }

    public String chat(String request, ChatMemory session) {
        return this.chatClient.prompt()
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, session.getId()))
                .user(request)
                .call()
                .content(); // LLM이 필요 시 도구를 호출하고, 그 결과를 바탕으로 최종 답변 반환
    }
}
  • .tools() 메서드 호출: chatClient.prompt() 체이닝 과정에서 .tools(dateTimeTools, fileSystemTool, weatherTools)와 같이 작성하여 구현한 도구 객체들을 등록합니다.
  • Tool Definition 전달: 이 과정을 통해 Spring AI는 자바 메서드의 메타데이터(Name, Description, Input Param Schema)를 Tool Definition 형태로 LLM에 전달하여 모델이 필요 시 해당 도구를 호출하도록 유도합니다.

Tool 호출 및 실행 흐름 단계별 요약

Step 1. Spring AI → LLM (Tool Definition 전달)
스프링 애플리케이션이 실행될 때 @Tool과 @ToolParam으로 정의된 메서드 메타데이터를 파싱하여 ToolDefinition 객체를 만들고 사용자 질문과 함께 LLM에 JSON 형태로 전달합니다.

// [Spring AI 내부 개념] LLM에게 전달될 ToolDefinition의 구조
public final class ToolDefinition {
    private final String name;         // LLM에게 알려주는 메서드 이름 (예: getCurrentWeather)
    private final String description;  // LLM이 이해할 수 있는 메서드 설명 ("도시의 현재 기온을 조회한다")
    private Map<String, Object> inputSchema; // 메서드 파라미터 이름, 타입 스키마 정보 (city: string)
}

Step 2. LLM → Spring AI (Tool Request 응답)
사용자가 "부산 현재 기온 알려줘"라고 질문하면 LLM은 보유한 도구 중 getCurrentWeather가 적합하다고 판단하여 해당 도구를 arguments와 함께 호출해달라고 요청(Assistant Message)합니다.

// LLM이 Spring AI로 보내는 tool_calls 응답 예시
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_1",
      "type": "function",
      "function": {
        "name": "getCurrentWeather",
        "arguments": "{\"city\":\"Busan\"}" // LLM이 추출한 파라미터 값
      }
    }
  ]
}

Step 3. Spring AI 내부 Tool 메서드 실행
Spring AI는 LLM이 보낸 도구 이름과 인자값을 받아 일치하는 맵핑 메서드를 찾아 Java Reflection을 통해 실제로 실행합니다.

@Component
public class WeatherTool {

    @Tool(description = "도시 이름으로 현재 날씨를 조회합니다. 예: Seoul")
    public String getCurrentWeather(
            @ToolParam(description = "도시 이름 (예: Seoul)", required = true) String city) {
        
        // 1. LLM이 넘겨준 city("Busan") 파라미터를 받아 로직 수행
        log.info("날씨 조회 요청 - 도시: {}", city);

        // 2. 외부 API 호출 또는 내부 비즈니스 로직 실행
        String result = "{\"temp\": 14}"; 
        
        // 3. 실행 결과를 반환하면 Spring AI가 이를 가로채서 처리함
        return result; 
    }
}

Step 4 ~ 5. Spring AI → LLM (ToolResponseMessage 전달) 및 최종 답변
메서드 실행 결과값을 ToolResponseMessage(role: "tool") 형태로 포장하여 다시 LLM에 전달하고 LLM은 이 데이터를 바탕으로 사용자에게 자연스러운 최종 답변을 생성합니다.

// 4단계: Spring AI가 LLM에게 전달하는 도구 실행 결과 메시지
{
  "role": "tool",
  "tool_call_id": "call_1", // Step 2의 id와 매칭됨
  "name": "getCurrentWeather",
  "content": "{\"temp\":14}"
}

// 5단계: LLM이 최종적으로 사용자에게 반환하는 자연어 답변
{
  "role": "assistant",
  "content": "부산의 현재 기온은 14°C 입니다."
}

0개의 댓글