사용자별로 이전 대화 내용을 기억해두고, 새 요청 시 자동으로 히스토리를 프롬프트에 포함시켜 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();
}
};