Published on

LangChain v0.2 메모리 폐기 후 대화상태 유지법

Authors

LangChain v0.2로 넘어오면서 많은 팀이 가장 먼저 부딪히는 변화가 Memory 중심 설계의 약화(혹은 사실상 폐기)입니다. v0.1에서 흔히 쓰던 ConversationBufferMemory 같은 구성은 체인에 자연스럽게 끼워 넣기 쉬웠지만, v0.2의 핵심은 Runnable 인터페이스와 LCEL(LangChain Expression Language)로 재편되면서 “상태는 런타임 외부에서 관리하고, 실행은 순수하게”에 가까운 방향으로 이동했습니다.

문제는 실서비스에서 대화형 에이전트가 상태 없이 동작할 수 없다는 점입니다. 사용자의 이전 발화, 도구 호출 결과, 요약된 컨텍스트, 세션 메타데이터가 없으면 품질이 급격히 떨어집니다. 이 글에서는 v0.2에서 권장되는 방식으로 대화 상태를 유지하는 설계를 단계별로 정리하고, 코드로 바로 적용 가능한 패턴을 제공합니다.

또한 LangChain 업그레이드 과정에서 자주 같이 터지는 의존성 이슈가 있는데, 특히 Pydantic v2 호환 문제는 먼저 정리해두는 것이 좋습니다. 관련해서는 LangChain Pydantic v2 호환 오류 5분 해결법도 함께 참고하면 업그레이드 삽질을 줄일 수 있습니다.

v0.2에서 “메모리”가 사라졌다는 의미

정확히 말하면 메모리라는 개념이 완전히 사라진 것은 아닙니다. 다만 다음이 바뀌었습니다.

  • 체인 내부에 메모리를 “주입”하는 방식보다, 메시지 히스토리를 외부 저장소에 두고 실행 시점에 불러오는 방식이 중심이 됨
  • Runnable 조합이 표준이 되면서, 상태를 가진 객체보다 “입력과 출력이 명확한 함수형 파이프라인” 구성이 쉬워짐
  • 대화 상태는 보통 chat_history 혹은 메시지 리스트 형태로 모델 입력에 포함되는 것으로 귀결됨

즉, 핵심은 “대화 상태를 어디에, 어떤 형태로 저장하고, 실행 시 어떤 키로 불러오느냐”입니다.

권장 패턴 1: RunnableWithMessageHistory로 세션별 메시지 유지

v0.2 계열에서 가장 실무적인 기본 해법은 RunnableWithMessageHistory입니다. 이 래퍼는 다음을 해줍니다.

  • session_id 같은 식별자로 메시지 히스토리를 조회
  • 새로 들어온 사용자 메시지를 히스토리에 추가
  • 모델 응답을 다시 히스토리에 추가
  • 다음 요청에서 같은 세션이면 이전 히스토리를 자동으로 포함

파이썬 예제: 인메모리로 시작하고, 나중에 저장소만 교체

아래 코드는 “세션별 대화 유지”의 최소 구성입니다. 프로덕션에서는 인메모리 대신 Redis나 DB로 바꾸는 것이 일반적이지만, 인터페이스는 유지한 채 저장소만 교체하는 식으로 확장할 수 있습니다.

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory

# 1) 세션별 히스토리 저장소(예: 인메모리 딕셔너리)
_store = {}

def get_history(session_id: str) -> ChatMessageHistory:
    if session_id not in _store:
        _store[session_id] = ChatMessageHistory()
    return _store[session_id]

# 2) 프롬프트: 히스토리 플레이스홀더 + 사용자 입력
prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful assistant."),
    MessagesPlaceholder("history"),
    ("human", "{input}"),
])

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
chain = prompt | llm

# 3) RunnableWithMessageHistory로 감싸기
chain_with_history = RunnableWithMessageHistory(
    chain,
    get_history,
    input_messages_key="input",
    history_messages_key="history",
)

# 4) 호출 시 config에 session_id를 넣는다
config = {"configurable": {"session_id": "user-123"}}

res1 = chain_with_history.invoke({"input": "내 이름은 민수야."}, config=config)
res2 = chain_with_history.invoke({"input": "내 이름 기억해?"}, config=config)

print(res1.content)
print(res2.content)

이 패턴의 장단점

  • 장점

    • v0.2 철학에 맞게 “상태 저장은 외부, 실행은 Runnable”로 분리됨
    • 세션 스코프가 명확해 웹 API와 결합이 쉬움
    • 저장소 교체가 상대적으로 간단
  • 단점

    • 히스토리가 무한히 커질 수 있음(토큰 비용, 응답 지연)
    • 멀티 인스턴스 환경에서 인메모리는 바로 한계(수평 확장 불가)

따라서 다음 섹션의 “요약/윈도잉”과 “영속 저장소”를 같이 고려해야 합니다.

권장 패턴 2: 윈도우 + 요약으로 토큰을 제어

대화 상태를 유지할 때 가장 흔한 장애는 품질 문제가 아니라 비용과 지연입니다. 히스토리를 전부 넣으면 토큰이 폭증하고, 모델 컨텍스트 제한에 걸리거나 응답이 느려집니다.

실무에서는 보통 아래 중 하나를 씁니다.

  • 최근 N턴만 유지하는 슬라이딩 윈도우
  • 오래된 대화는 요약해 “대화 요약 메모”로 남기고, 최근 대화만 원문 유지
  • 주제별로 요약을 여러 개로 쪼개거나, 중요한 사실만 구조화해 저장

파이썬 예제: 간단한 윈도우 커팅

아래는 “최근 10개 메시지만 남기기” 같은 단순 정책을 히스토리 저장 단계에서 적용하는 예시입니다.

from langchain_community.chat_message_histories import ChatMessageHistory

class WindowedChatMessageHistory(ChatMessageHistory):
    def __init__(self, k: int = 10):
        super().__init__()
        self.k = k

    def add_message(self, message):
        super().add_message(message)
        # 최근 k개만 유지
        if len(self.messages) > self.k:
            self.messages = self.messages[-self.k:]

_store = {}

def get_history(session_id: str) -> WindowedChatMessageHistory:
    if session_id not in _store:
        _store[session_id] = WindowedChatMessageHistory(k=10)
    return _store[session_id]

단순하지만 효과는 큽니다. 다만 “사용자 이름/선호/금지사항” 같은 장기 기억이 필요한 서비스라면 요약이나 별도 프로필 저장소가 필요합니다.

요약 메모를 별도 시스템 메시지로 넣는 방식

요약을 만들면 보통 다음 형태로 모델 입력에 포함합니다.

  • system 메시지에 Conversation summary: 같은 접두어로 주입
  • 혹은 프롬프트에 summary 슬롯을 별도로 두고 MessagesPlaceholder와 함께 사용

요약 생성 자체도 Runnable로 구성할 수 있고, 요약은 DB에 저장해 다음 요청에 재사용할 수 있습니다.

권장 패턴 3: Redis나 DB로 세션 히스토리 영속화

인메모리 저장소는 개발 단계에서는 편하지만, 운영에서는 다음 문제가 즉시 발생합니다.

  • 서버 재시작 시 대화가 사라짐
  • 여러 파드/인스턴스로 확장하면 세션이 분산됨
  • 장애 분석이나 CS 대응을 위한 로그 보존이 어려움

따라서 최소한 Redis(짧은 TTL) 혹은 DB(장기 보관)로 옮기는 것이 일반적입니다.

Redis 설계 팁

  • 키 스키마: chat:history:{session_id}
  • TTL: 예를 들어 1일 또는 7일
  • 메시지 포맷: JSON으로 role, content, timestamp 저장
  • 동시성: 동일 세션에 대한 동시 요청이 있을 수 있으니 append 시 원자성 고려

파이썬 예제: 저장소 인터페이스만 분리

아래는 “히스토리 저장소를 교체 가능하게” 만드는 최소 구조입니다.

import json
from dataclasses import dataclass
from typing import List, Dict

@dataclass
class Msg:
    role: str
    content: str

class HistoryStore:
    def load(self, session_id: str) -> List[Msg]:
        raise NotImplementedError

    def append(self, session_id: str, msg: Msg) -> None:
        raise NotImplementedError

class InMemoryHistoryStore(HistoryStore):
    def __init__(self):
        self.data: Dict[str, List[Msg]] = {}

    def load(self, session_id: str) -> List[Msg]:
        return self.data.get(session_id, [])

    def append(self, session_id: str, msg: Msg) -> None:
        self.data.setdefault(session_id, []).append(msg)

실제로는 LangChain의 메시지 타입과 맞추거나, RunnableWithMessageHistory가 기대하는 히스토리 객체를 반환하도록 어댑터를 두면 됩니다. 핵심은 “저장소를 바꾸더라도 체인 로직을 바꾸지 않게” 계층을 나누는 것입니다.

권장 패턴 4: 체크포인팅으로 “대화 상태 + 실행 상태”까지 보존

단순 챗봇이 아니라 에이전트(툴 호출, 멀티스텝 플래닝)를 운영하면, 메시지 히스토리만으로는 부족한 경우가 있습니다.

  • 도구 호출 결과를 어디까지 반영했는지
  • 특정 스텝에서 실패했을 때 재시도 위치
  • 스트리밍 중간에 연결이 끊겼을 때 이어서 진행

이때는 “메시지”뿐 아니라 “실행 상태(state)” 자체를 저장하는 체크포인트가 필요합니다. LangGraph 계열에서는 체크포인터를 붙여 상태를 저장하고 복원하는 패턴이 널리 쓰입니다.

여기서 중요한 설계 포인트는 다음입니다.

  • 세션 상태를 “재실행 가능”한 단위로 저장
  • 상태 크기가 커질수록 저장 비용과 복원 비용이 증가
  • 개인정보/민감데이터가 섞이면 암호화 및 마스킹 필요

대화형 워크플로우가 복잡해질수록, 단순 히스토리 저장에서 체크포인팅으로 자연스럽게 확장됩니다.

API 서버에서의 세션 키 설계

대화 상태 유지의 품질은 결국 session_id 설계에서 갈립니다.

  • 로그인 사용자: user_id 기반 + 디바이스/채널 구분 필요 시 suffix
  • 비로그인: 쿠키나 토큰으로 발급한 익명 세션
  • 고객센터/업무툴: tenant_id와 결합해 멀티테넌시 보장

추가로 아래를 권장합니다.

  • session_id는 URL에 노출하지 말고 헤더나 쿠키로 전달
  • 저장소 키에는 테넌트 프리픽스를 넣어 충돌 방지
  • TTL 정책을 명확히(예: 마지막 활동 기준 연장)

운영에서 자주 터지는 함정들

1) 동시 요청으로 히스토리가 꼬임

사용자가 빠르게 연속 질문을 하거나, 프론트에서 재시도를 걸면 같은 세션에 동시에 요청이 들어옵니다. 이때 히스토리에 다음 문제가 생깁니다.

  • 사용자 메시지 A, B 순서가 바뀜
  • A에 대한 모델 응답이 B 뒤에 붙음

해결책은 다음 중 하나입니다.

  • 세션 단위 락(서버에서 mutex, Redis 분산 락)
  • 요청마다 turn_id를 발급해 정렬 가능하게 저장
  • append를 원자적으로 처리하고, 읽을 때 정렬/필터

2) 히스토리 폭증으로 비용 급등

앞서 말한 윈도우/요약 정책이 없으면 곧바로 터집니다. 특히 고객지원 챗처럼 대화가 길어지면 치명적입니다.

  • 최근 N턴 + 요약 1개 조합이 가장 단순하고 강력
  • “사용자 프로필”은 별도 구조화 저장(이름, 선호, 금칙 등)

3) 저장소 커넥션 누수와 파일 디스크립터 고갈

Redis나 DB를 붙이다 보면 커넥션 관리가 부실할 때 리눅스에서 EMFILE로 이어질 수 있습니다. 대화 상태 저장은 요청당 I/O가 늘어나기 때문에 누수의 영향이 빨리 드러납니다. 관련해서는 Linux EMFILE(Too many open files) 원인과 해결도 함께 점검해두면 좋습니다.

추천 아키텍처 조합(현실적인 정답)

서비스 규모별로 현실적인 조합을 정리하면 다음과 같습니다.

  • PoC/프로토타입

    • RunnableWithMessageHistory + 인메모리 저장
    • 최근 N턴 윈도우만 적용
  • 초기 운영(단일 인스턴스 또는 소규모)

    • RunnableWithMessageHistory + Redis
    • 최근 N턴 + 요약(선택)
    • 세션 단위 동시성 제어(최소한의 락)
  • 본격 운영(멀티 인스턴스, 에이전트 복잡)

    • Redis(단기) + DB(장기/감사 로그)
    • 요약 + 프로필 분리 저장
    • 체크포인팅(워크플로우/에이전트 상태)

마이그레이션 체크리스트

v0.1에서 v0.2로 옮기면서 “대화 상태 유지” 관점에서 꼭 확인할 항목입니다.

  1. 기존 Memory 사용 위치를 찾아 session_id 기반 히스토리 로딩으로 치환
  2. 프롬프트에 MessagesPlaceholder를 두고, 히스토리를 그 슬롯으로 주입
  3. 저장소를 인메모리로 시작하더라도, 인터페이스를 분리해 Redis나 DB로 교체 가능하게 설계
  4. 윈도우/요약 정책으로 토큰 상한을 강제
  5. 동시 요청(재시도/중복 클릭/스트리밍 재연결)에서 히스토리 순서 보장

마무리

LangChain v0.2에서 메모리가 약해졌다는 것은 “대화 기능을 포기하라”가 아니라, “상태 관리를 애플리케이션 레이어의 책임으로 명확히 가져오라”에 가깝습니다. RunnableWithMessageHistory로 세션 히스토리를 표준화하고, 토큰 제어(윈도우/요약)와 영속 저장소(Redis/DB), 필요 시 체크포인팅까지 결합하면 v0.1보다 더 예측 가능하고 운영 친화적인 대화 시스템을 만들 수 있습니다.