Published on

LangChain 대화기억 폭증? Redis TTL로 해결

Authors

서빙 중인 챗봇이 잘 돌아가다가 어느 순간 Redis 메모리 사용량이 기하급수적으로 늘고, 비용과 장애 리스크가 같이 커지는 경우가 많습니다. 원인을 뜯어보면 대개 대화기억(chat memory) 을 세션 단위로 저장해두고 만료 정책을 걸지 않았거나, 만료가 걸려도 실제로는 갱신되지 않아 유령 세션이 계속 남는 패턴입니다.

이 글에서는 LangChain의 대화기억이 왜 폭증하는지, Redis에 저장할 때 어떤 키 설계와 TTL 전략을 써야 운영이 안정되는지, 그리고 TTL을 걸었는데도 줄지 않는 흔한 함정까지 한 번에 정리합니다.

참고로 운영 중 메모리 압박과 OOM 징후를 같이 보는 것도 도움이 됩니다. Redis나 앱 프로세스가 OOM으로 죽는 상황이라면 리눅스 OOM Killer 로그로 원인 프로세스 찾기도 함께 확인해보세요.

왜 LangChain 대화기억이 폭증하나

LangChain에서 대화기억은 보통 ConversationBufferMemory 류를 쓰고, 이를 RedisChatMessageHistory 같은 저장소에 연결합니다. 폭증 패턴은 크게 4가지입니다.

1) 세션 키가 무한히 생성된다

  • session_id 를 사용자별로 고정하지 않고 요청마다 새로 발급
  • Web 환경에서 탭마다, 혹은 새로고침마다 다른 ID
  • 익명 사용자에게도 UUID를 발급하지만 만료가 없음

결과적으로 Redis에는 chat:{session_id} 류의 키가 끝없이 늘어납니다.

2) 세션은 재사용되지만 메시지가 무한히 누적된다

한 세션에서 대화가 길어지면 메시지 리스트가 계속 커지고, 매 요청마다 더 큰 payload를 읽고 쓰게 됩니다.

  • 토큰 비용 증가
  • Redis 네트워크 트래픽 증가
  • 응답 지연 증가

3) TTL을 걸었는데도 실제로 만료가 안 된다

Redis TTL은 키 단위 로 동작합니다. 그런데 구현에 따라 메시지를 LISTHASH 로 추가만 하고 키 TTL을 갱신하지 않으면,

  • 처음 TTL을 설정한 이후
  • 새 메시지가 계속 들어오지만
  • TTL은 그대로 흘러가거나, 아예 설정이 안 된 상태로 남습니다

또는 반대로, 슬라이딩 TTL 을 기대했지만 실제로는 고정 TTL 이라서 활성 세션이 갑자기 끊기는 문제도 생깁니다.

4) 만료는 되지만 지연 삭제로 메모리 피크가 발생한다

Redis는 만료 키를 즉시 다 지우지 않고, lazy eviction과 주기적 샘플링으로 처리합니다. 트래픽이 크면 만료가 따라가지 못해 순간 피크가 커질 수 있습니다.

해결 전략: TTL을 “정책”으로 설계하라

대화기억을 Redis에 저장할 때는 단순히 expire 한 번 거는 수준이 아니라, 아래 3가지를 함께 설계해야 합니다.

  1. 키 스페이스 설계: 어떤 단위로 TTL을 걸 것인가
  2. TTL 모델: 고정 TTL vs 슬라이딩 TTL
  3. 데이터 상한: 메시지 개수 제한 또는 요약 적용

이 3개가 같이 들어가야 폭증이 멈춥니다.

Redis 키 설계: 세션 키와 인덱스 키를 분리

가장 흔한 운영 실수는 세션 데이터세션 목록 을 같은 키에 섞어 저장하는 것입니다.

권장 패턴은 아래처럼 분리합니다.

  • 세션 메시지: chat:session:{session_id}
  • 사용자-세션 인덱스(선택): chat:user:{user_id}:sessions

세션 메시지 키에 TTL을 걸고, 인덱스는 별도 TTL 또는 주기적 정리로 관리합니다.

예시: Redis CLI로 TTL 확인

redis-cli TTL chat:session:abc123
redis-cli MEMORY USAGE chat:session:abc123

TTL-1 이면 만료가 없는 상태입니다. 폭증의 시작점이 보통 여기입니다.

LangChain에서 Redis TTL 적용하기

LangChain의 Redis 기반 히스토리는 버전에 따라 TTL 지원 방식이 다를 수 있습니다. 핵심은 메시지를 추가할 때마다 키에 TTL을 보장 하는 것입니다.

아래 예시는 Python에서 RedisChatMessageHistory 를 쓰되, 메시지 추가 후 expire 를 강제로 걸어 슬라이딩 TTL을 구현하는 방식입니다.

Python 예시: 슬라이딩 TTL을 강제 적용

import os
from redis import Redis
from langchain_community.chat_message_histories import RedisChatMessageHistory
from langchain.memory import ConversationBufferMemory

REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
SESSION_TTL_SECONDS = 60 * 60 * 6  # 6시간

redis_client = Redis.from_url(REDIS_URL)

def build_memory(session_id: str) -> ConversationBufferMemory:
    history = RedisChatMessageHistory(
        session_id=session_id,
        url=REDIS_URL,
        key_prefix="chat:session:"
    )

    # 슬라이딩 TTL: 대화가 발생할 때마다 만료 시간을 갱신
    # RedisChatMessageHistory 내부 키 규칙을 prefix+session_id로 가정
    redis_key = f"chat:session:{session_id}"
    redis_client.expire(redis_key, SESSION_TTL_SECONDS)

    return ConversationBufferMemory(
        chat_memory=history,
        return_messages=True
    )

포인트는 expire 를 한 번만 걸지 말고, 대화가 일어나는 시점마다 갱신하는 것입니다. 그래야 활성 세션은 유지되고, 비활성 세션만 자연스럽게 정리됩니다.

만약 이 코드로도 TTL이 기대대로 갱신되지 않는다면,

  • 실제 Redis 키 이름이 다르거나
  • LangChain 구현이 여러 키를 쓰는 구조이거나
  • key_prefix 가 적용되지 않는 버전

일 수 있으니, SCAN 으로 실제 키를 확인하세요.

redis-cli --scan --pattern 'chat:session:*'

메시지 무한 누적 방지: 상한을 두거나 요약하라

TTL만으로는 부족한 경우가 많습니다. 활성 사용자가 하루 종일 대화하면 TTL은 계속 갱신되고, 한 세션 키가 계속 커지기 때문입니다.

따라서 아래 중 하나는 반드시 추가하는 것을 권장합니다.

옵션 A) 최근 N개 메시지만 유지

Redis에 저장하는 구조가 리스트라면 LTRIM 으로 최근 N개만 유지하는 방식이 가장 단순합니다. LangChain 내부 구현에 직접 훅을 넣기 어렵다면, 앱 레벨에서 메시지 저장 직후 Redis 명령으로 보정하는 식으로 운영할 수 있습니다.

MAX_MESSAGES = 40
redis_key = f"chat:session:{session_id}"

# 예: 리스트 구조라고 가정하면 최근 N개만 남김
redis_client.ltrim(redis_key, -MAX_MESSAGES, -1)
redis_client.expire(redis_key, SESSION_TTL_SECONDS)

리스트가 아니라 다른 자료구조면 동일한 목표로 최근 N개만 남기도록 구조를 바꾸는 게 낫습니다.

옵션 B) 일정 길이 이상이면 요약 메모리로 전환

사용자 경험을 해치지 않으면서 메모리를 줄이려면, 일정 토큰 이상부터는 요약(summary) 을 저장하고 원문 메시지는 일부만 남기는 전략이 효과적입니다.

  • 원문: 최근 10~20턴
  • 요약: 그 이전 대화의 압축본

LangChain에서는 ConversationSummaryMemory 류를 조합할 수 있고, 요약 결과도 Redis에 저장해 TTL을 동일하게 적용하면 됩니다.

TTL 운영 팁: 고정 TTL vs 슬라이딩 TTL

고정 TTL이 맞는 경우

  • 보안상 세션을 일정 시간 후 무조건 만료해야 함
  • 결제, 개인정보 등 민감 컨텍스트가 섞이는 챗

이때는 처음 생성 시점 기준 TTL을 유지하고, 갱신하지 않습니다.

슬라이딩 TTL이 맞는 경우

  • 고객센터 챗봇처럼 사용자가 이어서 대화하는 UX가 중요
  • 활성 사용자에 대해 세션이 갑자기 끊기면 불만이 큼

이때는 메시지 추가 시마다 expire 갱신 이 핵심입니다.

실무에서는 두 개를 섞어 슬라이딩 TTL 을 쓰되 최대 수명(max lifetime) 을 따로 두기도 합니다.

  • 슬라이딩 TTL: 6시간
  • 최대 수명: 7일

최대 수명은 별도 키에 created_at 을 저장하거나, 세션 ID에 생성 시각을 포함해 정책적으로 컷하는 방식으로 구현합니다.

Redis 만료가 느려 보일 때 체크리스트

TTL을 걸었는데도 메모리가 바로 안 줄어드는 것처럼 보일 수 있습니다. 아래를 확인하세요.

  1. TTL 이 실제로 -1 이 아닌지
  2. 키가 하나가 아니라 여러 개로 쪼개져 있지 않은지
  3. 만료 키가 많아 샘플링 삭제가 늦는지
  4. maxmemory-policynoeviction 인지

특히 noeviction 이면 메모리가 꽉 차도 Redis가 키를 안 지우고 쓰기 명령이 실패할 수 있습니다.

redis-cli CONFIG GET maxmemory
redis-cli CONFIG GET maxmemory-policy
redis-cli INFO memory

운영에서 장애로 이어지기 전에, 메모리 추이를 보고 원인을 추적하는 습관이 중요합니다. 애플리케이션이 메모리 누수처럼 보이는 상황이라면 Go gRPC 메모리 누수? bufconn·pprof 추적법처럼 프로파일링 관점의 접근도 도움이 됩니다.

프로덕션 적용 예시: FastAPI 미들웨어로 TTL 강제

요청이 들어올 때마다 session_id 를 확인하고, 해당 세션 키에 TTL을 강제 갱신하는 방식은 구현이 단순하고 효과가 큽니다.

from fastapi import FastAPI, Request
from redis import Redis

app = FastAPI()
redis_client = Redis.from_url("redis://localhost:6379/0")
SESSION_TTL_SECONDS = 60 * 60 * 6

@app.middleware("http")
async def refresh_chat_ttl(request: Request, call_next):
    session_id = request.headers.get("X-Session-Id")
    if session_id:
        redis_key = f"chat:session:{session_id}"
        # 존재할 때만 expire을 갱신하고 싶다면 EXISTS 체크를 추가
        redis_client.expire(redis_key, SESSION_TTL_SECONDS)

    return await call_next(request)

이 패턴의 장점은 LangChain 내부 구현 변경에도 영향이 적다는 점입니다. 단, 실제 키가 여러 개라면 패턴 매칭으로 함께 갱신하거나, 저장 계층을 통일하는 편이 낫습니다.

비용과 안정성을 함께 잡는 결론

LangChain 대화기억 폭증은 대부분 기능 문제가 아니라 저장 정책 부재 에서 시작합니다. 해결의 핵심은 아래 3줄입니다.

  • 세션 키에 TTL을 반드시 걸고, UX가 필요하면 슬라이딩 TTL로 갱신한다
  • 활성 세션이 길어질 수 있으니 메시지 상한 또는 요약을 넣어 키 크기를 제한한다
  • Redis에서 실제 키 구조와 TTL 상태를 TTL, SCAN, INFO memory 로 확인하며 운영한다

이렇게만 적용해도 Redis 메모리 증가는 예측 가능해지고, 챗봇 트래픽이 늘어도 비용과 장애 위험을 함께 낮출 수 있습니다.

추가로, API 응답에서 도구 호출 파싱 실패나 예외가 잦아 재시도가 반복되면 대화기억이 더 빨리 쌓일 수 있습니다. 그런 경우에는 OpenAI Responses API tool_calls 파싱 실패 해결법처럼 실패 원인을 먼저 줄이는 것도 효과적입니다.