지난 글에서는 LLM과 대화를 설계하는 방법을 다뤘다. System / User / Assistant 세 가지 메시지 타입을 이해하고, 히스토리를 어떻게 구성해야 AI가 맥락을 잘 이해하는지 살펴봤다.
그런데 히스토리를 쌓다 보면 자연스럽게 이런 의문이 생긴다.
이번 글에서는 이 세 가지 질문에 답한다.
LLM은 Stateless다. 매 요청이 완전히 독립적으로 처리된다. 이전 대화가 무엇이었는지, 사용자가 누구인지, 아무것도 모르는 상태에서 시작한다.
그래서 "기억"을 제공하려면 개발자가 직접 이전 대화를 매 요청마다 함께 보내줘야 한다. 이게 컨텍스트 관리의 출발점이다.
문제는 대화가 길어질수록 비용이 폭증한다는 것이다.
턴 1: 200 토큰
턴 10: 2,000 토큰
턴 50: 25,000 토큰
턴 100: 100,000 토큰 ← 비용 급증
게다가 컨텍스트가 너무 길어지면 LLM이 중간에 있는 중요한 정보를 놓치는 "Lost in the Middle" 현상도 발생한다. 비용과 성능, 두 마리 토끼를 모두 잡아야 한다.
이 문제를 해결하기 위해 메모리를 세 가지 레이어로 나눠서 관리한다. 각 레이어는 속도, 영속성, 용량 측면에서 서로 다른 특성을 가진다.
| 레이어 | 저장소 | 특성 | 활용처 |
|---|---|---|---|
| Volatile Memory | Redis, RAM | 초고속, 휘발성 | 현재 진행 중인 활성 세션 |
| Non-volatile Memory | RDBMS (DB) | 영구 저장 | 세션 종료 후 맥락 복원 |
| Semantic Memory | Vector DB | 의미 기반 검색 | 과거 대화에서 유사 내용 추출 (RAG) |
각 레이어의 역할을 냉장고에 비유하면 이렇다. Volatile은 손이 닿는 곳에 올려둔 오늘 먹을 음식, Non-volatile은 냉장고 안에 보관된 식재료, Semantic은 레시피 북에서 지금 요리에 맞는 레시피를 검색하는 것이다.
실무에서는 세 레이어를 조합한 하이브리드 방식을 사용한다. 캐시 확인 → DB 복원 → Vector DB 검색 순서로 컨텍스트를 구성한다.
Non-volatile Memory를 구현할 때 핵심은 테이블 설계다. 대화(Conversation)와 메시지(Message)를 분리해서 관리한다.
ChatConversation ───── ChatMessage
(채팅방) (메시지)
id (PK) id (PK)
title conversation_id (FK)
created_at role -- USER, ASSISTANT, SYSTEM, SUMMARY
updated_at message
prompt_tokens
completion_tokens
total_tokens
이렇게 분리하면 여러 채팅방의 컨텍스트가 섞이지 않는다. conversation_id로 특정 채팅방의 메시지만 조회할 수 있기 때문이다.
List<ChatMessage> findByConversation_IdAndStatus(
UUID chatConversationId, StatusType status
);
메시지가 쌓일수록 전부 LLM에 전달하는 건 비효율적이다. 두 가지 전략을 조합해서 해결한다.
최근 N개의 메시지만 원본으로 유지한다. 그 이전 메시지는 요약 대상이 된다.
오래된 메시지를 AI가 직접 요약해서 하나의 메시지로 압축한다. 이렇게 하면 80% 이상의 토큰을 절감하면서도 전체 맥락을 유지할 수 있다.
턴 1-40 → 요약본 1개 (500 토큰)
턴 41-50 → 원본 유지 (5,000 토큰)
합계: 5,500 토큰 (78% 절감)
요약본은 SUMMARY 타입으로 저장하고, LLM에 전달할 때는 System Message로 주입한다. System Message로 주입하면 LLM이 이를 "반드시 알고 있어야 할 배경 지식"으로 인식하기 때문이다.
private Message mapToSpringAiMessage(ChatMessage entity) {
return switch (entity.getRole()) {
case USER -> new UserMessage(content);
case ASSISTANT -> new AssistantMessage(content);
case SYSTEM,
SUMMARY -> new SystemMessage(content); // 요약본은 System으로 주입
};
}
멀티모달(Multimodal)은 텍스트 외에 이미지, 오디오, 비디오 등 다양한 타입의 데이터를 LLM의 입력으로 받는 기능이다.
Spring AI에서 이미지를 전달할 때는 Media 객체를 사용한다. 핵심은 이미지 데이터와 함께 MIME 타입을 반드시 함께 넘겨야 한다는 것이다. LLM이 받은 데이터가 어떤 종류인지 알아야 처리 방식을 결정할 수 있기 때문이다.
chatClient.prompt()
.user(u -> u
.text(message)
.media(MimeTypeUtils.parseMimeType(contentType), image.getResource())
)
.call()
.chatResponse();
이미지 분석 결과를 코드에서 바로 사용하려면 자연어 응답이 아니라 구조화된 데이터가 필요하다.
"영수증 정보 알려줘"라고 하면 이런 응답이 온다.
"이 영수증은 스타벅스에서 2026년 4월 4일에
총 13,500원을 결제한 내역입니다..."
파싱이 불가능하다. 반면 JSON을 강제하면 이렇게 된다.
{
"storeName": "스타벅스",
"date": "2026-04-04",
"totalAmount": 13500
}
objectMapper.readValue()로 바로 Java 객체로 변환할 수 있다. AI 프롬프트에서 출력 형식을 명시적으로 지정하는 것이 실무에서 매우 중요한 이유다.
LLM은 Stateless하기 때문에 컨텍스트 관리는 개발자의 몫이다. 3계층 메모리 전략과 슬라이딩 윈도우 + 요약 조합으로 비용과 성능을 동시에 잡을 수 있다. 멀티모달은 텍스트 외 입력을 가능하게 하고, 구조화된 출력 설계로 AI 응답을 서비스에 바로 녹여낼 수 있다.