우와한 개발자

[Spring AI] Spring AI ChatMemory — Advisor 기반 문맥 기억 챗봇 구현

by 우와한개발자

1. ChatMemory란?

  • Spring AI에서 제공하는 대화 히스토리 관리 기능
  • 사용자별로 이전 대화 내용을 기억해두고, 새 요청 시 자동으로 히스토리를 프롬프트에 포함시켜 OpenAI에 전달한다.
  • OpenAI 자체는 대화를 기억하는 기능이 없다. 매 요청마다 이전 대화를 메시지에 포함시켜야 문맥을 이해하는데, ChatMemory가 그걸 자동으로 해준다.

 

2. Advisor란?

  • 요청 전/후에 자동으로 끼어들어 처리를 추가하는 역할
  • MessageChatMemoryAdvisor는 ChatMemory를 이용해 히스토리를 자동으로 삽입/저장한다.
시점 역할
요청 전 ChatMemory에서 히스토리를 꺼내 프롬프트에 자동 추가
응답 후 이번 대화를 ChatMemory에 자동 저장

 

3. 동작 방식

1) ChatMemory 없을 때:

[user: 내 이름은 홍길동이야]  → OpenAI
[user: 내 이름이 뭐야?]       → OpenAI  ← 이전 대화 없음 → "모릅니다"

2) ChatMemory 있을 때:

[user: 내 이름은 홍길동이야]  → OpenAI
[user: 내 이름이 뭐야?]       → MessageChatMemoryAdvisor가 히스토리 자동 삽입
    ↓
[user: 내 이름은 홍길동이야]  ← 히스토리
[assistant: 안녕하세요 홍길동님!]
[user: 내 이름이 뭐야?]       ← 현재 요청
    ↓
OpenAI → "홍길동이에요"

 

4. conversationId의 역할

  • 사용자별 대화 히스토리를 구분하는 키
  • 같은 ID면 이전 대화를 기억하고, 다른 ID면 새로운 대화로 인식한다.
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId))
//                                                  ↑ 이 값으로 대화를 구분
항목 내용
같은 ID 이전 대화를 기억하고 문맥 유지
다른 ID 새로운 대화로 인식, 히스토리 없음
주로 사용하는 값 사용자 ID, 세션 ID 등

 

5. InMemoryChatMemory

항목 내용
저장 위치 JVM 메모리
서버 재시작 시 히스토리 초기화
사용 상황 학습, 테스트, 단순 데모
프로덕션 Redis, DB 기반 저장소 사용 권장
최대 메시지 수 maxMessages로 설정 (기본값 20)

 

6. 구현

1) ChatClient 빈 설정

@Configuration
public class ChatConfig {

    @Bean
    ChatMemory chatMemory() {
        return MessageWindowChatMemory.builder()
                .maxMessages(20)  // 최근 20개 메시지만 유지
                .build();
    }

    @Bean
    ChatClient chatClient(OpenAiChatModel chatModel, ChatMemory chatMemory) {
        return ChatClient.builder(chatModel)
                .defaultAdvisors(MessageChatMemoryAdvisor
                        .builder(chatMemory).build())  // Advisor 등록
                .build();
    }
}

2) Service

@Service
@RequiredArgsConstructor
public class ChatService {

    private final ChatClient chatClient;

    // 일반 호출
    public String chatMemory(String userInput, String userId) {
        return chatClient.prompt()
                .user(userInput)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId))
                .call()
                .content();
    }

    // 스트림 호출
    public Flux<String> chatMemoryStream(String userInput, String userId) {
        return chatClient.prompt()
                .user(userInput)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId))
                .stream()
                .content()
                .map(this::preserveSseLeadingSpace)
                .concatWith(Flux.just("[[END]]"));
    }

    // data: 뒤에 오는 공백은 브라우저가 자동으로 제거하므로 보정
    private String preserveSseLeadingSpace(String chunk) {
        if (chunk == null) return "";
        return chunk.startsWith(" ") ? " " + chunk : chunk;
    }
}

3) Controller

@RestController
@RequiredArgsConstructor
public class ChatController {

    private final ChatService chatService;

    // 일반 호출
    @GetMapping("/chat")
    public String chat(@RequestParam("q") String q, @RequestParam("id") String id) {
        return chatService.chatMemory(q, id);
    }

    // produces 없으면 Spring이 Flux를 JSON 배열로 모아서 한번에 응답함
    @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> stream(
            @RequestParam("q") String q,
            @RequestParam("id") String id) {
        return chatService.chatMemoryStream(q, id)
                .map(chunk -> ServerSentEvent.builder(chunk).build());
    }
}

4) 클라이언트 (HTML)

const url = `/chat/stream?id=${userId}&q=${encodeURIComponent(msg)}`;
const eventSource = new EventSource(url);

let buffer = "";

// 서버에서 청크가 올 때마다 호출
eventSource.onmessage = (e) => {
    if (e.data === "[[END]]") {
        eventSource.close();
        return;
    }
    buffer += e.data;
    document.getElementById("chatBox").textContent = buffer;  // 청크 즉시 렌더링
};

// 연결 오류 또는 서버가 연결을 끊었을 때 호출
eventSource.onerror = () => {
    if (eventSource.readyState === 2) {
        eventSource.close();
    }
};

 

7. 주의사항

항목 내용
ChatMemory는 서버 메모리 저장 서버 재시작 시 모든 대화 히스토리 초기화
maxMessages 설정 너무 크면 토큰 초과 오류 발생 가능
conversationId 관리 같은 ID면 히스토리가 계속 쌓이므로 로그아웃 시 초기화 필요
프로덕션 환경 InMemoryChatMemory 대신 Redis, DB 기반 저장소 사용 권장

블로그의 정보

우와한개발자 님의 블로그

우와한개발자

활동하기