챗봇은 왜 방금 한 말을 잊어버릴까
처음 LangChain으로 챗봇을 만들면 가장 먼저 마주치는 이상한 장면이 있습니다. 사용자가 이렇게 말합니다.
안녕하세요. 제 이름은 지민이고, 저는 백엔드 개발자예요.
모델은 친절하게 답합니다.
반가워요, 지민님! 백엔드 개발을 하고 계시는군요.
그런데 바로 다음 질문에서 사용자가 묻습니다.
제 이름이 뭐라고 했죠?
메모리를 붙이지 않은 챗봇은 태연하게 이렇게 답할 수 있습니다.
죄송하지만 저는 사용자의 이름을 알 수 없습니다.
처음 보면 모델이 방금 한 말을 정말로 잊어버린 것처럼 느껴집니다. 하지만 더 정확히 말하면, 모델은 애초에 기억하지 않았습니다. 대부분의 LLM 호출은 기본적으로 단발성 함수 호출에 가깝습니다. 우리가 invoke()를 한 번 실행하면 모델은 그때 전달받은 메시지만 보고 답합니다. 다음 invoke()는 완전히 새로운 요청입니다.
따라서 "기억하는 챗봇"을 만든다는 말은 모델 내부에 기억 장치를 심는다는 뜻이 아닙니다. 애플리케이션이 이전 대화를 저장해 두었다가, 다음 모델 호출 때 다시 함께 보내는 구조를 만든다는 뜻입니다. LangChain 문서에서도 메모리를 이전 상호작용 정보를 기억하고 활용하는 시스템으로 설명하며, 특히 대화형 에이전트에서는 사용자 피드백과 선호를 반영하는 데 중요하다고 설명합니다.1
이 장의 목표는 단순합니다. 지금까지 만든 prompt | model 체인을 사용자별 대화 맥락을 유지하는 챗봇으로 바꾸는 것입니다. 이 변화 하나만으로 앱은 "질문 하나 받고 답하는 도구"에서 "사용자와 이어서 대화하는 서비스"로 발전합니다.

가장 작은 메모리: 메시지 목록을 직접 넘기기
메모리의 본질은 메시지 목록입니다. 사용자의 말은 HumanMessage, 모델의 답은 AIMessage로 저장하고, 다음 요청에서 이 목록을 프롬프트에 함께 넣으면 됩니다.
먼저 가장 직접적인 방식으로 구현해 보겠습니다.
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
model = ChatOpenAI(model="gpt-4o-mini", temperature=0.2)
prompt = ChatPromptTemplate.from_messages([
("system", "당신은 친절한 한국어 AI 어시스턴트입니다. 이전 대화를 참고해 답하세요."),
MessagesPlaceholder(variable_name="chat_history"),
("human", "{input}"),
])
chain = prompt | model
chat_history = []
user_input = "안녕하세요. 제 이름은 지민이고, 저는 백엔드 개발자예요."
response = chain.invoke({
"input": user_input,
"chat_history": chat_history,
})
print(response.content)
chat_history.extend([
HumanMessage(content=user_input),
AIMessage(content=response.content),
])
user_input = "제 이름이 뭐라고 했죠?"
response = chain.invoke({
"input": user_input,
"chat_history": chat_history,
})
print(response.content)핵심은 MessagesPlaceholder입니다. 이 자리는 이전 메시지 목록이 들어갈 공간입니다. 첫 번째 호출에서는 chat_history가 비어 있으므로 모델은 현재 입력만 보고 답합니다. 호출이 끝난 뒤에는 사용자의 입력과 모델의 응답을 직접 chat_history에 추가합니다. 두 번째 호출에서는 이전 메시지들이 프롬프트에 포함되므로 모델이 "지민"이라는 이름을 참고할 수 있습니다.
이 방식은 메모리의 원리를 이해하기에 가장 좋습니다. 그러나 실제 서비스에 그대로 쓰기에는 몇 가지 문제가 있습니다. chat_history를 전역 리스트로 두면 모든 사용자가 같은 대화 기록을 공유하게 될 수 있고, 서버가 재시작되면 기록이 사라집니다. 동시에 여러 요청이 들어오면 메시지 순서가 꼬일 수도 있습니다. 무엇보다 대화가 길어질수록 매번 모델에 보내는 토큰 수가 늘어납니다.
LLM의 컨텍스트 윈도우는 유한합니다. 긴 대화 전체를 계속 넣으면 컨텍스트 한도를 넘거나, 비용과 응답 시간이 증가하며, 오래되고 관련 없는 내용 때문에 응답 품질이 떨어질 수 있습니다.2 그러므로 메모리 설계는 단순히 "전부 저장하기"가 아니라 "무엇을, 어디에, 얼마나 오래, 어떤 형태로 다시 넣을 것인가"를 결정하는 작업입니다.
프롬프트에 들어가는 기록과 저장소에 남는 기록은 다르다
메모리를 다룰 때 헷갈리기 쉬운 구분이 있습니다. 하나는 대화 히스토리 저장소이고, 다른 하나는 모델에 전달할 컨텍스트입니다.
대화 히스토리 저장소는 실제 메시지를 보관하는 공간입니다. 인메모리 딕셔너리일 수도 있고, Redis, PostgreSQL, MongoDB, SQLite 같은 데이터베이스일 수도 있습니다. 반면 모델에 전달할 컨텍스트는 저장된 기록 중 이번 답변에 필요한 부분만 골라 프롬프트에 넣은 결과입니다.
예를 들어 고객 상담 챗봇을 생각해 봅시다. 저장소에는 지난 6개월 동안의 상담 내역이 전부 있을 수 있습니다. 하지만 사용자가 "아까 말한 환불 건은 어떻게 됐나요?"라고 물을 때 모델에 6개월치 대화를 모두 넣는 것은 낭비입니다. 최근 대화 몇 턴, 환불 관련 요약, 주문 번호 같은 핵심 정보만 넣어도 충분할 수 있습니다.
이 구분을 기억하면 메모리 전략이 훨씬 선명해집니다. 저장은 넉넉하게 하되, 모델에 넣는 내용은 토큰 예산에 맞게 선택해야 합니다.
RunnableWithMessageHistory로 자동 히스토리 관리하기
앞의 예제에서는 우리가 직접 chat_history.extend(...)를 호출했습니다. 작은 실습에서는 괜찮지만 실제 체인마다 이 코드를 반복하면 금방 지저분해집니다. LangChain에서는 LCEL 체인을 감싸서 메시지 기록을 자동으로 읽고 쓰는 RunnableWithMessageHistory를 사용할 수 있습니다.
이 방식의 흐름은 다음과 같습니다.
세션 ID를 기준으로 해당 대화의 히스토리 객체를 가져옵니다.
체인을 실행할 때 이전 히스토리를 프롬프트에 자동으로 넣습니다.
실행이 끝나면 새 사용자 메시지와 AI 응답을 히스토리에 자동으로 저장합니다.
코드로 보면 더 쉽습니다.
from langchain_openai import ChatOpenAI
from langchain_core.chat_history import BaseChatMessageHistory, InMemoryChatMessageHistory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
model = ChatOpenAI(model="gpt-4o-mini", temperature=0.2)
prompt = ChatPromptTemplate.from_messages([
("system", "당신은 사용자의 대화 맥락을 기억하는 친절한 어시스턴트입니다."),
MessagesPlaceholder(variable_name="history"),
("human", "{input}"),
])
chain = prompt | model
store: dict[str, BaseChatMessageHistory] = {}
def get_session_history(session_id: str) -> BaseChatMessageHistory:
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
chatbot = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="input",
history_messages_key="history",
)이제 호출할 때는 입력값과 함께 configurable.session_id를 넘깁니다.
config = {"configurable": {"session_id": "user-001"}}
response = chatbot.invoke(
{"input": "안녕하세요. 제 이름은 지민이고, Python을 좋아해요."},
config=config,
)
print(response.content)
response = chatbot.invoke(
{"input": "제가 좋아하는 언어가 뭐라고 했죠?"},
config=config,
)
print(response.content)같은 session_id로 호출했기 때문에 두 번째 요청에서는 첫 번째 대화가 자동으로 포함됩니다. 반대로 다른 session_id를 쓰면 완전히 다른 대화가 됩니다.
alice_config = {"configurable": {"session_id": "alice"}}
bob_config = {"configurable": {"session_id": "bob"}}
chatbot.invoke({"input": "저는 Alice이고 Python을 좋아해요."}, config=alice_config)
chatbot.invoke({"input": "저는 Bob이고 JavaScript를 좋아해요."}, config=bob_config)
alice_answer = chatbot.invoke({"input": "제가 좋아하는 언어는요?"}, config=alice_config)
bob_answer = chatbot.invoke({"input": "제가 좋아하는 언어는요?"}, config=bob_config)
print("Alice:", alice_answer.content)
print("Bob:", bob_answer.content)이 예제에서 alice와 bob은 서로의 대화를 볼 수 없습니다. 이것이 세션 분리입니다. 실제 서비스에서는 이 session_id를 사용자 ID, 브라우저 탭 ID, 상담방 ID, 프로젝트 ID와 조합해 설계합니다.
여기서 중요한 선택이 하나 있습니다. session_id를 "사용자 단위"로 둘지, "대화방 단위"로 둘지입니다. 사용자 단위로 두면 한 사용자의 모든 대화가 길게 이어지지만, 서로 다른 주제가 섞일 수 있습니다. 대화방 단위로 두면 주제별 맥락이 깔끔하게 분리됩니다. 일반적인 챗봇 UI에서는 user_id와 conversation_id를 따로 두고, LangChain의 session_id에는 conversation_id를 넘기는 방식이 안전합니다.
인메모리 저장소의 한계
방금 만든 예제의 store는 파이썬 딕셔너리입니다. 개발 중에는 훌륭합니다. 빠르고, 설정이 거의 없고, 동작 원리를 확인하기 쉽습니다. 하지만 운영 환경에서는 조심해야 합니다.
인메모리 저장소는 프로세스가 살아 있는 동안만 유지됩니다. 서버를 재시작하면 모든 기록이 사라집니다. 서버가 여러 대로 늘어나면 사용자의 다음 요청이 이전 요청과 다른 서버로 갈 수 있고, 그러면 기록을 찾지 못합니다. 또한 메모리에 무한히 메시지가 쌓이면 서버 메모리 사용량이 계속 증가합니다.
그래서 운영 환경에서는 보통 Redis나 데이터베이스 같은 외부 저장소를 사용합니다. Redis는 빠르고 TTL, 즉 만료 시간을 주기 좋아 세션성 대화 기록에 적합합니다. PostgreSQL이나 MongoDB 같은 데이터베이스는 영속성이 강하고 검색, 감사 로그, 사용자 요청에 따른 삭제 처리에 유리합니다.
간단한 구조는 다음과 같습니다.
# 개념 예시입니다. 실제 import 경로와 생성자는 사용하는 패키지 버전에 맞춰 확인하세요.
from langchain_redis import RedisChatMessageHistory
def get_session_history(session_id: str):
return RedisChatMessageHistory(
session_id=session_id,
redis_url="redis://localhost:6379",
ttl=60 * 60 * 24,
)이제 RunnableWithMessageHistory의 나머지 코드는 거의 그대로 둔 채 저장소만 바꿀 수 있습니다. 이것이 LangChain 히스토리 추상화의 장점입니다. 체인은 "이전 메시지 목록이 필요하다"는 사실만 알고, 그 메시지가 메모리에서 왔는지 Redis에서 왔는지 데이터베이스에서 왔는지는 몰라도 됩니다.
직접 만드는 SQLite 메시지 히스토리
Redis를 설치하지 않고도 영속 저장소의 감각을 익히고 싶다면 SQLite로 작은 히스토리 클래스를 만들 수 있습니다. SQLite는 파일 하나로 동작하므로 실습에 좋습니다.
import json
import sqlite3
from pathlib import Path
from typing import List
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import BaseMessage, message_to_dict, messages_from_dict
class SQLiteChatMessageHistory(BaseChatMessageHistory):
def __init__(self, session_id: str, db_path: str = "chat_history.db"):
self.session_id = session_id
self.db_path = db_path
self._ensure_table()
def _connect(self):
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
return sqlite3.connect(self.db_path)
def _ensure_table(self):
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
message_json TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_session ON chat_messages(session_id, id)"
)
@property
def messages(self) -> List[BaseMessage]:
with self._connect() as conn:
rows = conn.execute(
"SELECT message_json FROM chat_messages WHERE session_id = ? ORDER BY id ASC",
(self.session_id,),
).fetchall()
items = [json.loads(row[0]) for row in rows]
return messages_from_dict(items)
def add_message(self, message: BaseMessage) -> None:
payload = json.dumps(message_to_dict(message), ensure_ascii=False)
with self._connect() as conn:
conn.execute(
"INSERT INTO chat_messages(session_id, message_json) VALUES (?, ?)",
(self.session_id, payload),
)
def clear(self) -> None:
with self._connect() as conn:
conn.execute(
"DELETE FROM chat_messages WHERE session_id = ?",
(self.session_id,),
)이 클래스를 사용하면 저장소를 다음처럼 바꿀 수 있습니다.
def get_session_history(session_id: str) -> BaseChatMessageHistory:
return SQLiteChatMessageHistory(session_id=session_id, db_path="data/chat_history.db")
chatbot = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="input",
history_messages_key="history",
)이제 프로그램을 종료했다가 다시 실행해도 같은 session_id로 호출하면 이전 대화를 복원할 수 있습니다. 물론 이 코드는 학습용입니다. 운영 환경에서는 커넥션 풀, 동시성 제어, 암호화, 백업, 마이그레이션, 사용자별 삭제 정책까지 고려해야 합니다.
메시지를 계속 넣으면 생기는 문제
메모리를 붙이면 챗봇이 똑똑해진 것처럼 보입니다. 그러나 곧 새로운 문제가 생깁니다. 대화가 길어질수록 모델에 전달되는 메시지가 많아집니다.
예를 들어 사용자가 100턴 동안 대화했다고 가정해 봅시다. 한 턴은 사용자 메시지 하나와 AI 메시지 하나로 이루어지므로 총 200개의 메시지가 생깁니다. 매번 이 200개의 메시지를 전부 모델에 보내면 비용이 증가하고 응답이 느려집니다. 더 나쁜 경우, 오래된 잡담이나 이미 해결된 주제가 현재 답변에 섞여 모델을 혼란스럽게 만들 수 있습니다.
따라서 메모리 전략은 다음 질문에 답해야 합니다.
어떤 메시지를 그대로 유지할 것인가?
어떤 메시지를 삭제할 것인가?
어떤 메시지를 요약할 것인가?
어떤 정보는 대화 로그가 아니라 사용자 프로필로 따로 저장할 것인가?
현재 질문과 관련 있는 과거 정보만 검색해 넣을 것인가?
LangChain과 LangGraph 문서에서도 긴 대화에서는 메시지 트리밍, 삭제, 요약 같은 방식으로 컨텍스트를 관리하는 패턴을 제시합니다.2
버퍼 메모리: 전부 기억하기
가장 단순한 전략은 전체 대화를 그대로 유지하는 것입니다. 흔히 버퍼 메모리라고 부릅니다. 앞에서 만든 InMemoryChatMessageHistory나 SQLite 히스토리가 기본적으로 이 방식입니다.
버퍼 메모리는 짧은 대화에서는 가장 정확합니다. 사용자가 앞에서 말한 표현 그대로를 다시 참고할 수 있고, 요약 과정에서 정보가 손실되지 않습니다. 고객이 "제가 아까 정확히 뭐라고 했죠?"라고 물었을 때 원문을 그대로 볼 수 있습니다.
하지만 대화가 길어지면 약점이 분명해집니다. 토큰 사용량이 빠르게 증가하고, 오래된 정보가 계속 프롬프트에 남습니다. 그래서 버퍼 메모리는 프로토타입, 짧은 상담, 짧은 작업 세션에 적합합니다.
윈도우 메모리: 최근 N개 메시지만 유지하기
두 번째 전략은 최근 메시지만 유지하는 것입니다. 예를 들어 최근 10개 메시지만 보관하고 그보다 오래된 메시지는 버립니다. 이 방식은 구현이 쉽고 토큰 사용량을 예측하기 좋습니다.
간단히 InMemoryChatMessageHistory를 확장해 보겠습니다.
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import BaseMessage
class WindowChatMessageHistory(InMemoryChatMessageHistory):
def __init__(self, max_messages: int = 10):
super().__init__()
self.max_messages = max_messages
def add_message(self, message: BaseMessage) -> None:
super().add_message(message)
if len(self.messages) > self.max_messages:
self.messages[:] = self.messages[-self.max_messages:]세션별로 적용하면 다음과 같습니다.
window_store = {}
def get_window_history(session_id: str):
if session_id not in window_store:
window_store[session_id] = WindowChatMessageHistory(max_messages=8)
return window_store[session_id]윈도우 메모리는 "방금 말한 것"이 중요한 챗봇에 잘 맞습니다. 예를 들어 문장 교정 챗봇, 코딩 중 짧은 질의응답, 주문 과정 안내처럼 최신 맥락이 중요한 경우에 좋습니다. 하지만 초반에 사용자가 알려준 이름, 선호도, 제약 조건이 창 밖으로 밀려나면 모델은 더 이상 그 정보를 볼 수 없습니다.
이 점이 중요합니다. 윈도우 메모리를 쓰는 챗봇이 오래된 정보를 답했다면, 실제로 기억해서 답한 것이 아니라 최근 문맥을 보고 추측했을 가능성이 있습니다. 운영 환경에서는 "모델이 그 정보를 실제로 프롬프트에서 봤는가?"를 로그로 확인할 수 있어야 합니다.
요약 메모리: 오래된 대화를 압축하기
세 번째 전략은 오래된 대화를 요약하는 것입니다. 최근 몇 턴은 원문 그대로 유지하고, 그보다 오래된 내용은 짧은 요약으로 바꿉니다.
예를 들어 프로젝트 코치 챗봇과 사용자가 긴 대화를 나누고 있다고 해 봅시다. 초반에는 프로젝트 목표, 기술 스택, 일정, 제약 조건을 논의했습니다. 중반에는 API 설계를 이야기했고, 지금은 테스트 전략을 이야기합니다. 이때 초반 대화 원문 전체를 매번 넣을 필요는 없습니다. 대신 다음과 같은 요약을 넣으면 됩니다.
사용자는 B2B SaaS 제품의 결제 모듈을 개발 중이다. 백엔드는 FastAPI, 데이터베이스는 PostgreSQL을 사용한다. 이번 주 목표는 웹훅 처리와 결제 실패 재시도 정책을 정리하는 것이다.
요약 메모리는 긴 대화에서 강력합니다. 그러나 요약은 항상 손실을 동반합니다. 모델이 중요하지 않다고 판단해 버린 세부사항이 나중에 중요해질 수 있습니다. 또 요약에 오류가 있으면 그 오류가 다음 요약에 누적될 수 있습니다.
따라서 요약 메모리를 사용할 때는 원문 로그를 완전히 버리기보다, 저장소에는 원문을 남기고 모델에 전달할 컨텍스트만 요약으로 바꾸는 편이 안전합니다.
간단한 요약 함수는 다음처럼 만들 수 있습니다.
from langchain_core.messages import SystemMessage
from langchain_core.prompts import ChatPromptTemplate
summary_prompt = ChatPromptTemplate.from_messages([
("system", "다음 대화를 후속 답변에 필요한 정보 중심으로 간결하게 요약하세요."),
("human", "대화 내용:n{conversation}"),
])
summary_chain = summary_prompt | model
def summarize_messages(messages):
text = "n".join(
f"{message.type}: {message.content}"
for message in messages
)
response = summary_chain.invoke({"conversation": text})
return response.content실제 앱에서는 "메시지 수가 30개를 넘으면 오래된 20개를 요약하고 최근 10개는 유지한다"처럼 임계값을 정합니다. 더 정교하게는 토큰 수를 기준으로 판단합니다. 메시지 개수는 짧은 메시지와 긴 메시지를 구분하지 못하기 때문입니다.
하이브리드 메모리: 요약과 최근 원문을 함께 쓰기
실전에서는 하나의 전략만 쓰기보다 조합하는 경우가 많습니다. 가장 흔한 조합은 요약 + 최근 원문입니다.
이 방식에서는 오래된 대화는 요약으로 압축하고, 최근 대화는 원문 그대로 유지합니다. 사용자는 최근에 한 표현을 그대로 이어갈 수 있고, 모델은 오래된 프로젝트 목표도 잊지 않습니다.
프롬프트 구조는 다음과 같이 잡을 수 있습니다.
prompt = ChatPromptTemplate.from_messages([
("system", "당신은 사용자의 장기 맥락과 최근 대화를 함께 참고하는 어시스턴트입니다."),
("system", "이전 대화 요약:n{summary}"),
MessagesPlaceholder(variable_name="recent_history"),
("human", "{input}"),
])이 구조에서 summary는 압축된 과거이고, recent_history는 최근 메시지 목록입니다. 요약이 부정확할 때를 대비해 사용자가 "그때 제가 정확히 뭐라고 했나요?"라고 물으면 원문 로그 저장소에서 관련 메시지를 검색해 보여주는 식으로 보완할 수 있습니다.
벡터스토어 기반 장기 메모리
네 번째 전략은 과거 대화를 임베딩해서 벡터스토어에 저장하고, 현재 질문과 관련 있는 기억만 검색해 넣는 것입니다. 이 방식은 RAG와 비슷합니다. 차이는 검색 대상이 외부 문서가 아니라 사용자의 과거 대화, 선호도, 피드백, 작업 이력이라는 점입니다.
예를 들어 사용자가 몇 주 전에 "저는 답변을 짧게 받는 걸 좋아해요"라고 말했습니다. 오늘 사용자가 새 대화방에서 질문했습니다.
LangChain 메모리 전략을 추천해 주세요.
단기 메모리만 있다면 오늘 대화방에는 선호 정보가 없으므로 모델은 그 사실을 모릅니다. 하지만 장기 메모리에 "사용자는 짧고 실행 가능한 답변을 선호한다"는 정보가 저장되어 있고, 현재 질문과 함께 검색되어 프롬프트에 들어가면 응답 스타일을 개인화할 수 있습니다.
벡터스토어 기반 장기 메모리는 강력하지만 운영 난도가 높습니다. 검색 품질이 낮으면 엉뚱한 과거 기억이 주입됩니다. 오래된 선호가 현재와 충돌할 수도 있습니다. 무엇보다 개인정보 관리가 중요합니다. 사용자가 삭제를 요청했는데 벡터스토어에 임베딩 형태로 남아 있다면 문제가 됩니다.
따라서 장기 메모리는 "무조건 많이 저장하기"가 아니라 "명확한 목적이 있는 정보만 저장하기"로 접근해야 합니다.
대화 로그와 사용자 프로필 분리하기
사용자 이름, 선호 언어, 응답 스타일, 관심 분야 같은 정보는 매번 대화 로그에서 찾는 것보다 구조화된 프로필로 분리하는 편이 좋습니다.
예를 들어 다음과 같은 프로필을 둘 수 있습니다.
user_profile = {
"user_id": "user-001",
"name": "지민",
"role": "백엔드 개발자",
"preferred_language": "ko",
"favorite_stack": ["Python", "FastAPI", "PostgreSQL"],
"answer_style": "짧은 예제 중심",
}그리고 프롬프트에는 필요한 항목만 넣습니다.
profile_text = "사용자 이름: 지민n직무: 백엔드 개발자n선호 답변: 짧은 예제 중심"
prompt = ChatPromptTemplate.from_messages([
("system", "당신은 개인화된 개발 도우미입니다."),
("system", "사용자 프로필:n{profile}"),
MessagesPlaceholder(variable_name="history"),
("human", "{input}"),
])프로필은 대화 로그와 성격이 다릅니다. 대화 로그는 "무슨 말을 주고받았는가"이고, 프로필은 "앞으로도 유효하게 사용할 사용자 정보"입니다. 모든 대화 내용을 프로필로 승격하면 안 됩니다. 사용자가 일시적으로 말한 내용과 지속적인 선호를 구분해야 합니다.
예를 들어 사용자가 "오늘은 짧게 답해 주세요"라고 말했다면 이것은 현재 세션의 지시일 수 있습니다. 반면 "앞으로 항상 코드 예제를 먼저 보여 주세요"라고 명시했다면 장기 선호로 저장할 수 있습니다. 좋은 메모리 시스템은 저장 여부를 판단하는 기준을 갖고 있어야 합니다.
LangGraph checkpointer로 상태 관리하기
지금까지는 RunnableWithMessageHistory를 중심으로 보았습니다. 간단한 LCEL 체인에 대화 기록을 붙이는 데는 이 방식이 매우 편리합니다. 그러나 에이전트, 도구 호출, 여러 단계의 워크플로, 중간 상태 저장이 필요해지면 LangGraph의 상태 관리가 더 자연스럽습니다.
LangGraph에서는 대화를 하나의 thread로 보고, 그 thread의 상태를 checkpointer에 저장합니다. LangChain의 short-term memory 문서는 에이전트에 단기 메모리를 추가하려면 checkpointer를 지정하고 실행 시 configurable.thread_id를 넘기는 방식을 제시합니다.2
가장 작은 예제는 다음과 같습니다.
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
model="openai:gpt-4o-mini",
tools=[],
system_prompt="당신은 이전 대화 맥락을 참고하는 친절한 어시스턴트입니다.",
checkpointer=InMemorySaver(),
)
config = {"configurable": {"thread_id": "chat-001"}}
agent.invoke(
{"messages": [{"role": "user", "content": "안녕하세요. 제 이름은 지민입니다."}]},
config,
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "제 이름이 뭐라고 했죠?"}]},
config,
)
print(result["messages"][-1].content)여기서는 session_id 대신 thread_id라는 이름을 씁니다. 개념은 비슷합니다. 같은 thread_id로 호출하면 같은 대화 상태를 이어가고, 다른 thread_id로 호출하면 별도 대화가 됩니다.
RunnableWithMessageHistory와 LangGraph checkpointer의 차이를 정리하면 이렇습니다.
RunnableWithMessageHistory는 LCEL 체인에 메시지 히스토리를 붙이는 가벼운 방식입니다.LangGraph checkpointer는 그래프 상태 전체를 저장하고 복원하는 방식입니다.
단순 챗봇은 전자로 충분하지만, 도구 호출과 멀티스텝 에이전트는 후자가 더 적합합니다.
앞으로 에이전트와 LangGraph를 다루는 장에서 이 구조를 더 깊게 보게 됩니다. 지금은 "세션별 메시지 기록"에서 "thread별 상태 저장"으로 개념이 확장된다고 이해하면 충분합니다.
세션 ID 설계하기
메모리 버그의 상당수는 모델 문제가 아니라 세션 설계 문제에서 나옵니다. 같은 사용자의 대화가 이어지지 않거나, 다른 사용자의 대화가 섞이거나, 브라우저 탭 두 개에서 서로 다른 주제로 대화했는데 기록이 한곳에 합쳐지는 식입니다.
실제 서비스에서는 보통 다음 값을 구분합니다.
user_id: 로그인한 사용자를 식별합니다.conversation_id: 하나의 대화방이나 상담 스레드를 식별합니다.session_id: 기술적으로 히스토리 저장소에서 사용하는 키입니다.request_id: 개별 요청을 추적하고 디버깅하기 위한 ID입니다.
LangChain에 넘기는 session_id나 LangGraph에 넘기는 thread_id는 보통 conversation_id에 대응시키는 것이 좋습니다. 사용자가 새 채팅을 열면 새 conversation_id를 만들고, 기존 채팅을 다시 열면 같은 ID를 사용합니다.
브라우저 탭 단위로 상태를 분리하고 싶다면 conversation_id를 탭마다 따로 발급해야 합니다. 반대로 한 사용자의 모든 대화를 하나의 장기 개인화 맥락으로 묶고 싶다면 별도의 사용자 프로필 저장소를 두고, 대화 히스토리는 대화방별로 분리하는 편이 안전합니다.
동시 요청과 순서 문제
운영 환경에서는 사용자가 한 번에 하나의 메시지만 보내지 않습니다. 네트워크 재시도, 모바일 앱의 중복 전송, 스트리밍 중 새 질문 입력, 여러 탭에서의 동시 요청이 발생할 수 있습니다. 이때 같은 session_id에 동시에 쓰기가 일어나면 메시지 순서가 꼬일 수 있습니다.
예를 들어 사용자가 거의 동시에 두 질문을 보냈다고 해 봅시다.
A: 제 프로젝트 구조를 봐 주세요.
B: 방금 내용 요약해 주세요.
B가 A보다 먼저 저장되면 모델은 요약할 내용을 보지 못할 수 있습니다. 따라서 서비스 계층에서 같은 대화방에 대한 요청을 순차 처리하거나, 메시지마다 증가하는 sequence 번호를 부여하고 저장소에서 순서를 보장해야 합니다.
Redis를 쓴다면 리스트 append 연산과 lock을 조합할 수 있고, 데이터베이스를 쓴다면 트랜잭션과 created_at, id 정렬을 함께 사용합니다. 특히 스트리밍 응답에서는 AI 메시지를 언제 저장할지 결정해야 합니다. 응답이 완전히 끝난 뒤 저장할 수도 있고, 중간 chunk를 별도 테이블에 쌓은 뒤 완료 시 하나의 메시지로 확정할 수도 있습니다.
토큰 예산으로 메모리 관리하기
메시지 개수만으로는 메모리 크기를 정확히 제어할 수 없습니다. "네"라는 메시지와 긴 코드 블록이 들어간 메시지는 둘 다 메시지 하나지만 토큰 수는 크게 다릅니다. 그래서 실제 운영에서는 토큰 예산을 기준으로 컨텍스트를 관리합니다.
간단한 기준을 세워 봅시다.
모델의 최대 컨텍스트가 128K 토큰이라고 해서 128K를 전부 히스토리에 쓰면 안 됩니다. 답변 생성 공간도 필요하고, 시스템 프롬프트, 검색 문서, 도구 결과, 출력 포맷 지시도 들어갑니다. 따라서 예를 들어 전체 입력 예산을 20K로 잡고, 그중 대화 히스토리에 6K, 검색 문서에 10K, 시스템 및 기타 지시에 4K를 배정할 수 있습니다.
메모리 선택 알고리즘은 처음에는 단순하게 시작해도 됩니다. 시스템 메시지는 유지하고, 최근 메시지를 뒤에서부터 추가하다가 예산을 넘기기 직전에 멈춥니다. 그 다음 오래된 메시지는 요약으로 대체합니다.
개념 코드는 다음과 같습니다.
def rough_token_count(text: str) -> int:
return max(1, len(text) // 4)
def message_token_count(message) -> int:
return rough_token_count(message.content)
def select_recent_messages(messages, max_tokens: int):
selected = []
used = 0
for message in reversed(messages):
cost = message_token_count(message)
if used + cost > max_tokens:
break
selected.append(message)
used += cost
return list(reversed(selected))이 함수는 실제 토크나이저를 쓰지 않는 단순 예시입니다. 운영에서는 모델별 토큰 카운터를 사용해야 합니다. 그래도 원리는 같습니다. "최근 메시지를 무조건 N개"가 아니라 "토큰 예산 안에서 필요한 메시지를 선택한다"가 핵심입니다.
현재 질문과 관련 있는 기억만 고르기
토큰 예산이 빠듯할 때는 최근성만으로 부족합니다. 오래된 메시지라도 현재 질문과 강하게 관련 있으면 포함해야 합니다.
예를 들어 사용자가 대화 초반에 이렇게 말했습니다.
우리 팀의 배포 정책은 금요일에는 운영 반영을 하지 않는 것입니다.
그리고 한참 뒤에 묻습니다.
그럼 이번 배포 일정을 어떻게 잡는 게 좋을까요?
이때 초반 메시지는 오래되었지만 중요합니다. 단순 윈도우 메모리에서는 사라졌을 수 있습니다. 이런 경우에는 키워드, 엔티티, 임베딩 검색을 사용해 관련 메시지를 찾아야 합니다.
초기 버전에서는 다음 정도의 규칙만으로도 효과가 있습니다.
사용자 이름, 날짜, 금액, 주문번호, 프로젝트명 같은 엔티티가 있는 메시지를 우선 유지합니다.
현재 질문과 같은 키워드를 포함한 메시지를 우선 유지합니다.
최근 메시지는 기본적으로 일정량 유지합니다.
명시적인 결정 사항이나 제약 조건은 요약 또는 프로필로 승격합니다.
더 발전시키면 과거 대화를 청크로 나누어 임베딩하고, 현재 질문과 의미적으로 가까운 기억을 검색해 넣을 수 있습니다. 이 패턴은 뒤의 RAG 장에서 배우는 벡터스토어 검색과 거의 같습니다. 차이는 검색 대상이 "문서"에서 "대화 기억"으로 바뀐다는 점뿐입니다.
개인정보를 기억한다는 것의 책임
메모리는 사용자 경험을 좋아지게 만듭니다. 사용자가 매번 이름, 선호도, 프로젝트 정보를 반복하지 않아도 되기 때문입니다. 하지만 동시에 개인정보 보호 책임도 커집니다.
개인화 정보는 편의성을 위해 저장하지만, 사용자는 그 정보가 어디에 저장되는지, 얼마나 오래 남는지, 삭제할 수 있는지에 관심을 가질 수 있습니다. 특히 이름, 이메일, 전화번호, 위치, 회사 내부 정보, 건강 정보, 금융 정보 같은 민감한 데이터는 조심해야 합니다.
메모리 설계에는 다음 원칙을 포함하는 것이 좋습니다.
필요한 정보만 최소한으로 수집합니다.
민감정보는 저장 전에 필터링하거나 마스킹합니다.
저장 데이터는 암호화하고 접근 권한을 제한합니다.
보존 기간을 정하고 만료된 기록은 삭제합니다.
사용자가 자신의 기억을 조회, 수정, 삭제할 수 있게 합니다.
장기 메모리에 저장할 때는 명시적 동의나 분명한 사용자 의도를 확인합니다.
예를 들어 사용자가 "제 주민등록번호는 …"이라고 입력했다면, 챗봇이 그것을 기억해서는 안 됩니다. 오히려 "민감한 개인정보는 저장하지 않겠습니다"라고 안내하고 로그 저장 단계에서 마스킹해야 합니다.
간단한 필터는 다음처럼 시작할 수 있습니다.
import re
def redact_sensitive(text: str) -> str:
text = re.sub(r"bd{6}-d{7}b", "[REDACTED_RRN]", text)
text = re.sub(r"bd{2,3}-d{3,4}-d{4}b", "[REDACTED_PHONE]", text)
text = re.sub(r"[w.-]+@[w.-]+.w+", "[REDACTED_EMAIL]", text)
return text이 정도 정규식만으로 완벽해지지는 않습니다. 하지만 메모리를 설계할 때 "무엇을 잘 기억할까"만큼 "무엇을 기억하지 않을까"를 함께 생각해야 한다는 점이 중요합니다.
작은 프로젝트: 세션별로 기억하는 개발 도우미
이제 지금까지의 내용을 하나로 묶어 작은 챗봇을 만들어 보겠습니다. 목표는 사용자의 이름과 기술 스택을 같은 세션 안에서 기억하는 개발 도우미입니다.
from langchain_openai import ChatOpenAI
from langchain_core.chat_history import BaseChatMessageHistory, InMemoryChatMessageHistory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
model = ChatOpenAI(model="gpt-4o-mini", temperature=0.2)
prompt = ChatPromptTemplate.from_messages([
(
"system",
"당신은 개발자를 돕는 한국어 코딩 코치입니다. "
"이전 대화에서 사용자가 말한 이름, 역할, 기술 스택, 현재 작업을 참고하세요. "
"확실하지 않은 내용은 기억한다고 말하지 말고 다시 질문하세요."
),
MessagesPlaceholder(variable_name="history"),
("human", "{input}"),
])
chain = prompt | model
histories: dict[str, BaseChatMessageHistory] = {}
def get_history(session_id: str) -> BaseChatMessageHistory:
if session_id not in histories:
histories[session_id] = InMemoryChatMessageHistory()
return histories[session_id]
assistant = RunnableWithMessageHistory(
chain,
get_history,
input_messages_key="input",
history_messages_key="history",
)
def ask(session_id: str, text: str) -> str:
result = assistant.invoke(
{"input": text},
config={"configurable": {"session_id": session_id}},
)
return result.content
print(ask("dev-chat-1", "안녕하세요. 저는 지민이고 FastAPI로 결제 API를 만들고 있어요."))
print(ask("dev-chat-1", "제가 지금 뭘 만들고 있다고 했죠?"))
print(ask("dev-chat-2", "제가 지금 뭘 만들고 있다고 했죠?"))첫 번째와 두 번째 호출은 같은 dev-chat-1 세션이므로 맥락이 이어집니다. 세 번째 호출은 dev-chat-2이므로 모델은 지민이나 결제 API에 대해 알지 못해야 합니다. 이 테스트는 매우 중요합니다. 메모리 기능은 "잘 기억하는지"뿐 아니라 "다른 세션을 기억하지 않는지"도 확인해야 합니다.
지난 장의 LCEL 감각과 연결하기
이전 장에서 LCEL로 prompt | model | parser처럼 체인을 조립하는 방법을 배웠습니다. 메모리는 이 체인 바깥에서 상태를 공급하는 레이어라고 볼 수 있습니다.
단순 체인은 다음처럼 생겼습니다.
chain = prompt | model여기에 히스토리를 붙이면 이렇게 됩니다.
chain_with_history = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="input",
history_messages_key="history",
)즉, LCEL 체인의 핵심 구조는 그대로 유지됩니다. 프롬프트에는 MessagesPlaceholder를 추가하고, 실행 시에는 session_id를 넘깁니다. 이 패턴은 앞으로 RAG 챗봇, 도구 호출 챗봇, 에이전트에도 계속 반복됩니다.
이전 장의 라우팅 예제에서 의도 분류 후 체인을 선택했다면, 메모리가 붙은 구조에서는 라우팅 체인과 응답 체인이 모두 같은 세션 정보를 공유해야 합니다. 예를 들어 사용자가 "방금 말한 정책을 요약해줘"라고 하면, 의도 분류기는 "요약 요청"으로 분류해야 하고, 응답 체인은 "방금 말한 정책"이 무엇인지 히스토리에서 찾아야 합니다. 그래서 상태 관리는 라우팅보다 바깥에 있는 애플리케이션 설계 문제가 됩니다.
운영 체크리스트
메모리가 붙은 챗봇을 운영하기 전에 다음 질문에 답할 수 있어야 합니다.
세션 ID는 누가 만들고, 언제 새로 발급하는가?
사용자별 대화가 절대 섞이지 않도록 인증 정보와 세션 ID를 검증하는가?
서버 재시작 후에도 필요한 대화가 복구되는가?
대화 히스토리의 최대 길이나 보존 기간이 정해져 있는가?
모델에 전달되는 메시지의 토큰 예산을 제한하는가?
오래된 대화를 삭제할지, 요약할지, 검색할지 기준이 있는가?
사용자가 저장된 기억을 조회하고 삭제할 수 있는가?
민감정보가 로그와 장기 메모리에 그대로 저장되지 않는가?
동시 요청에서 메시지 순서가 보장되는가?
디버깅을 위해 어떤 히스토리가 모델에 들어갔는지 추적할 수 있는가?
이 질문들은 귀찮아 보이지만, 실제 챗봇 품질을 좌우합니다. 메모리는 기능이 아니라 인프라에 가깝습니다. 한 번 잘못 설계하면 환각, 개인정보 문제, 비용 폭증, 디버깅 난이도가 동시에 올라갑니다.
마무리
이번 장에서는 LLM이 기본적으로 이전 호출을 기억하지 못한다는 사실에서 출발했습니다. 그리고 HumanMessage와 AIMessage로 대화 히스토리를 구성해 프롬프트에 직접 넣는 방식, RunnableWithMessageHistory로 세션별 기록을 자동 관리하는 방식, 인메모리 저장소와 영속 저장소의 차이를 살펴보았습니다.
핵심은 하나입니다. 메모리를 붙이면 LLM 호출은 단발성 응답에서 맥락을 이어가는 대화형 서비스로 발전합니다. 하지만 모든 기록을 무작정 넣는 것은 답이 아닙니다. 버퍼, 윈도우, 요약, 벡터 검색, 사용자 프로필을 목적에 맞게 조합해야 합니다.
다음 장부터는 챗봇이 대화만 기억하는 수준을 넘어 외부 문서를 읽고 답하게 만듭니다. 문서를 불러오고, 적절한 크기로 나누고, 임베딩과 벡터스토어를 거쳐 RAG 파이프라인으로 확장할 것입니다. 그때도 오늘 배운 메모리 감각은 계속 필요합니다. 사용자의 현재 질문은 문서만으로 이해되지 않습니다. 항상 "이 사용자가 방금까지 무엇을 말했는가"라는 대화 맥락 위에서 해석되기 때문입니다.
참고
3LangChain Memory Tutorial - Build Chatbots with Conversation History
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.