pytest와 Redis를 활용해 LLM 메모리 회귀 테스트를 30분에서 90초로 단축
Source: Dev.to
새벽 2시 17분, 연속 알람이 울리면서 나는 잠에서 깼다 — 프로덕션에 있던 LLM이 갑자기 “기억을 잃어버렸다.” 어느 순간 사용자는 프로젝트 일정에 대해 얘기하고 있었는데, 다음 순간 LLM은 순진하게 “어떻게 도와드릴까요?”라고 물었다. 로그를 뒤져 보니 원인은 Redis의 만료 정책을 한 줄 바꾼 메모리‑스토어 릴리즈였다. 배포 전에 메모리 영속성 흐름을 테스트한 사람이 없었기 때문에 모든 세션 컨텍스트가 5분 안에 사라졌다. 버그를 고치면서 나는 스스로에게 저주했다: “프로덕션 데이터를 건드리지 않는 자동화된 회귀 테스트가 있었다면 이런 일은 일어나지 않았을 텐데.”
핵심적으로 LLM 메모리 스토어는 TTL을 가진 키‑값 시스템이다: 세션 ID가 키가 되고, 대화 기록, 요약, 벡터 인덱스가 직렬화되어 Redis에 저장된다. 비즈니스 요구사항은 명확하다 — “24 × 7 다중 턴 대화는 절대 기억을 잃어서는 안 된다.” 그런데 우리의 테스트 프로세스는 이렇게 머물러 있었다:
- Postman으로 몇 개의 메시지를 수동으로 보내고, Redis 키를 눈으로 확인하며 동작을 추측한다.
- 테스트 데이터가 프로덕션과 같은 Redis 인스턴스를 공유한다; 한 번이라도 실수하면 실제 세션이 삭제된다.
- Redis의 lazy expiration, 비동기 삭제 같은 특성을 무시하고, 타임아웃 테스트는 미신에 불과하다.
문제는 Redis가 신뢰할 수 없어서가 아니라, 우리가 Redis의 실제 동작을 회귀 테스트에 통합하지 않았기 때문이다. 단순히 dict 로 Redis를 모킹하면 타임아웃, 직렬화, 커넥션 풀 고갈 등을 테스트할 수 없다. 이러한 검증 없이 배포하는 것은 눈을 가리고 비행하는 것과 같다.
우리가 필요로 하는 것은 “실제 Redis, 완전 격리, 자동 정리, 재현 가능”한 테스트 환경이다.
기술 선택
- pytest: fixture 시스템이 테스트 자원을 관리하기에 최적이다.
@pytest.mark.redis로 테스트에 태그를 붙이면 CI에서 필요에 따라 실제 백엔드 테스트를 건너뛸 수 있다. - Real Redis: fakeredis는 시간 여행을 지원하지 않으며(만료를 실제로 기다려야 함), Lua 스크립트·Streams·모듈 지원이 뒤처져 있어 테스트는 통과하지만 프로덕션에서는 폭발한다. 로컬 개발에서는
db=15를 전용으로 사용하고, CI에서는docker‑compose로 전용 인스턴스를 띄운다. - 왜 testcontainers‑python이 아닌가: 작은 팀은 네트워크가 불안정하고 매 테스트마다 이미지를 풀어야 하므로 속도가 매우 느려진다. CI 컨테이너 안에서 Docker‑in‑Docker 를 돌리는 것도 설정이 복잡하다. “준비된 전용 Redis”에 직접 연결하는 것이 훨씬 실용적이다.
아키텍처 아이디어
각 테스트는 conftest.py 에 정의된 redis_memory_store fixture 로 고유 네임스페이스를 가진 클라이언트를 얻는다. 테스트가 끝나면 해당 네임스페이스 아래 모든 키가 원자적으로 삭제돼 병렬 테스트 간 간섭을 방지한다.
아래 코드는 “테스트 간 더러운 데이터가 섞이는 문제”를 해결한다. 무작위 프리픽스와 autouse 정리 훅을 사용해 각 테스트가 자체 샌드박스에서 실행된다.
# conftest.py
import pytest
import redis
import uuid
# 명령줄 옵션: 실제 Redis가 필요한 테스트를 건너뛸 수 있게 함
def pytest_addoption(parser):
parser.addoption("--real-redis", action="store_true", default=False,
help="run tests that require a real Redis instance")
@pytest.fixture(scope="session")
def redis_url():
# 기본은 로컬 테스트 DB, 프로덕션에서는 환경 변수로 오버라이드
return "redis://localhost:6379/15"
@pytest.fixture
def redis_memory_store(redis_url):
"""
각 테스트 함수마다 고유 프리픽스로 격리된 Redis 클라이언트를 만든다.
테스트 종료 시 해당 프리픽스 아래 모든 key를 자동 삭제한다.
"""
prefix = f"test:{uuid.uuid4().hex[:8]}:" # 병렬 테스트 key 충돌 방지
client = redis.Redis.from_url(redis_url, decode_responses=True)
client._test_prefix = prefix # 비즈니스 코드에서 사용하기 편하도록 속성에 저장
yield client
# 정리: scan 으로 배치 삭제, keys * 로 인한 블로킹 방지
cursor = 0
while True:
cursor, keys = client.scan(cursor, match=f"{prefix}*", count=100)
if keys:
client.delete(*keys)
if cursor == 0:
break
client.close()
이 테스트들은 핵심 시나리오를 다룬다: 대화 메모리 쓰기 → 읽기 → 업데이트 → 자동 만료. 설계상 MemoryStore 의 의존성을 주입하므로 테스트에서는 redis_memory_store 를 넘겨주고, 프로덕션에서는 커넥션 풀을 주입한다.
# test_memory_store.py
import time
import pytest
from myapp import MemoryStore # 실제 기억 저장 모듈
@pytest.mark.redis
class TestMemoryPersistence:
"""기억 영속성 회귀 테스트"""
def test_write_and_read_conversation(self, redis_memory_store):
"""기본 읽·쓰기: 대화를 저장하고 그대로 불러와야 함"""
store = MemoryStore(redis_memory_store, prefix=redis_memory_store._test_prefix)
session_id = "user_abc"
messages = [{"role": "user", "content": "저는 작은 명입니다"},
{"role": "assistant", "content": "안녕하세요 작은 명"}]
store.save(session_id, messages)
loaded = store.load(session_id)
assert loaded == messages, f"예상 {messages}, 실제 {loaded}"
def test_memory_ttl(self, redis_memory_store):
"""TTL 만료: 짧은 TTL을 설정하고 기다리면 데이터가 사라져야 함"""
store = MemoryStore(redis_memory_store, prefix=redis_memory_store._test_prefix, ttl=2)
session_id = "user_ttl"
store.save(session_id, [{"role": "user", "content": "test"}])
time.sleep(3) # 만료 대기
loaded = store.load(session_id)
assert loaded is None or loaded == [], f"만료 후 빈 값을 반환해야 하는데, 실제 {loaded}"
def test_parallel_isolation(self, redis_memory_store):
"""병렬 격리: 두 테스트가 서로 다른 프리픽스를 사용해 영향을 주지 않음"""
store_a = MemoryStore(redis_memory_store, prefix="a:")
store_b = MemoryStore(redis_memory_store, prefix="b:")
store_a.save("1", [{"role": "x"}])
store_b.save("1", [{"role": "y"}])
assert store_a.load("1") == [{"role": "x"}]
assert store_b.load("1") == [{"role": "y"}]
30분짜리 수동 검증에서 90초 만에 완전 자동화된 회귀 스위트로 전환했다. 이제 메모리 관련 코드를 건드리는 모든 PR은 이 테스트를 통과해야 하며, “LLM 기억 상실” 페이지는 그 이후로 한 번도 울리지 않았다. 실제 Redis, 영리한 네임스페이스 격리, 그리고 pytest fixture이 바로 이를 가능하게 만든 세 축이다. 아직도 모킹이나 공유 인스턴스로 LLM 메모리를 테스트하고 있다면, 이 방식을 한 번 시도해 볼 때다.