지금까지의 채팅은 매 요청이 독립적이었다. "내 이름은 최승호야"라고 알려 줘도, 다음 요청에서 "내 이름이 뭐였지?"라고 물으면 모른다. LLM은 본래 상태가 없기(stateless) 때문이다. Chat Memory는 대화 내용을 쌓아 두었다가 다음 요청에 함께 보내, 맥락이 이어지는 대화를 만든다.
이 편에서 만드는 것
conversationId로 대화를 구분해 맥락을 기억하는 /memory/chat 엔드포인트.
1. 왜 기억이 필요한가
sequenceDiagram
participant U as 사용자
participant App as Spring AI
participant M as ChatMemory
participant LLM
U->>App: "내 이름은 최승호야" (conversationId=alice)
App->>M: alice 대화에 메시지 저장
App->>LLM: [이전 대화 + 새 메시지]
LLM-->>U: "반갑습니다, 최승호님!"
U->>App: "내 이름이 뭐였지?" (conversationId=alice)
App->>M: alice의 이전 대화 불러오기
App->>LLM: [이전 대화 + 새 질문]
LLM-->>U: "최승호님이라고 하셨습니다."핵심은 메모리 어드바이저가 요청 앞뒤로 끼어들어 (1) 이전 대화를 불러와 프롬프트에 붙이고, (2) 이번 주고받은 메시지를 다시 저장한다는 점이다.
2. 메모리 빈 등록
MessageWindowChatMemory는 대화별로 최근 N개 메시지만 유지하는 메모리다(너무 길어지면 비용·토큰 한도 문제가 생기므로). 저장소는 실습용 인메모리를 쓴다.
@Configuration
public class MemoryConfig {
@Bean
public ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(10) // 대화당 최근 10개 메시지 유지
.build();
}
}저장소(Repository)와 메모리(Memory)의 분리
ChatMemory는 정책(최근 N개 유지)이고, 실제 보관은 ChatMemoryRepository가 한다. 실습의 InMemoryChatMemoryRepository는 앱을 끄면 사라진다. 운영에서는 JdbcChatMemoryRepository(DB)나 Redis 기반으로 바꾸면 서버를 재시작해도 대화가 유지된다.
3. 메모리 어드바이저 적용
MessageChatMemoryAdvisor를 ChatClient의 기본 어드바이저로 걸어 둔다. 그러면 모든 요청이 자동으로 메모리를 거친다.
@Service
public class MemoryService {
private final ChatClient chatClient;
public MemoryService(ChatClient.Builder builder, ChatMemory chatMemory) {
this.chatClient = builder
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
public String chat(String conversationId, String message) {
return chatClient.prompt()
.user(message)
// 이 요청이 어느 대화에 속하는지 지정
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
}conversationId가 대화의 열쇠
conversationId가 같으면 같은 대화로 묶여 맥락이 이어지고, 다르면 완전히 별개의 대화가 된다. 실제 서비스에서는 로그인 사용자 ID나 세션 ID를 conversationId로 쓰면 된다.
4. 동작 확인
같은 conversationId=alice로 이어서 물으면 앞 대화를 기억한다.
curl -G "http://localhost:8080/memory/chat" \
--data-urlencode "conversationId=alice" --data-urlencode "message=내 이름은 최승호야. 기억해줘."
# 안녕하세요, 최승호님! 만나서 반갑습니다.
curl -G "http://localhost:8080/memory/chat" \
--data-urlencode "conversationId=alice" --data-urlencode "message=내 이름이 뭐라고 했지?"
# 최승호님이라고 하셨습니다.conversationId를 bob으로 바꾸면 alice의 대화는 보이지 않는다 — 대화가 격리된다.
curl -G "http://localhost:8080/memory/chat" \
--data-urlencode "conversationId=bob" --data-urlencode "message=내 이름이 뭐라고 했지?"
# 죄송하지만, 대화 중에 이름을 언급하지 않으셔서 알 수 없습니다.정리
LLM은 stateless라 직전 대화를 기억하지 못한다 → ChatMemory로 맥락을 쌓는다.
MessageChatMemoryAdvisor를 걸고, 요청마다CONVERSATION_ID로 대화를 구분한다.메모리 정책(
MessageWindowChatMemory)과 저장소(...Repository)는 분리돼 있어, 실습용 인메모리를 운영용 DB/Redis로 쉽게 교체한다.
어드바이저가 핵심 메커니즘
메모리가 동작하는 비결은 '요청 앞뒤로 끼어드는' 어드바이저다. 그 어드바이저를 직접 만들어 보면 Spring AI의 확장 포인트를 이해할 수 있다 — 다음 편의 주제다.
➡️ 다음 편: Advisor