IT 개발

Redis RPUSH + LTRIM으로 LLM 대화 이력 관리하기 — 별도 삭제 로직 없이 최근 5턴만 유지하는 슬라이딩 윈도우

itsdev 2026. 8. 30. 02:28

WithBuddy 연재 10편이자 시즌 3의 마지막 글이다. LLM 대화가 길어질수록 프롬프트에 포함되는 히스토리와 토큰 비용도 함께 증가한다. WithBuddy에서는 Redis List의 RPUSH + LTRIM을 이용해 최근 5턴만 유지하고, EXPIRE로 오래된 세션까지 자동 정리했다. 별도의 이력 정리 배치나 삭제 대상 탐색 없이 대화 맥락을 일정한 크기로 제한한 방법을 정리한다.

이 글에서 다루는 내용

  • 전체 대화 이력을 매번 LLM에 전달하면 비용이 커지는 이유
  • RPUSH + LTRIM으로 최근 N개의 메시지만 유지하는 원리
  • 별도의 조회·삭제 로직보다 Redis List를 활용한 이유
  • TTL 30분으로 유휴 세션을 자동 정리하는 방법
  • 턴 기반 슬라이딩 윈도우의 한계와 보완 방법

Redis RPUSH + LTRIM으로 LLM 대화 이력 관리하기


문제 — 대화가 길어질수록 LLM에 보내는 데이터도 늘어난다

8편에서 살펴본 RAG 파이프라인은 답변을 생성할 때 현재 질문뿐 아니라 이전 대화 맥락인 conversationHistory도 함께 사용한다.

예를 들어 다음과 같은 대화가 있다고 하자.

사용자: 비품 신청 한도는 얼마인가요?
AI: 비품은 최대 30만 원까지 신청할 수 있습니다.

사용자: 그럼 그 이상이면 어떻게 해야 하나요?

두 번째 질문의

그 이상

이 무엇을 의미하는지 알려면 바로 앞의 대화가 필요하다.

따라서 자연스러운 멀티턴 대화를 위해서는 일정 수준의 대화 이력을 LLM에 함께 전달해야 한다.

문제는 이 이력을 제한 없이 계속 쌓을 때 발생한다.

20턴을 대화했다면 다음 요청에는 이전 20턴이 들어가고, 30턴까지 이어지면 이전 30턴이 다시 프롬프트에 포함된다.

1턴  → 짧은 History
10턴 → 더 긴 History
20턴 → 더 긴 History
30턴 → 더 긴 History

LLM API는 입력 토큰 역시 비용과 처리량에 영향을 미치기 때문에, 전체 대화를 매 요청마다 다시 전달하면 요청당 입력 토큰 수가 대화 길이에 따라 계속 증가한다.

대화 전체의 누적 비용 관점에서는 문제가 더 커진다. 매 턴마다 지금까지의 모든 대화를 반복해서 다시 전달하기 때문이다.

하지만 오래된 대화가 항상 현재 질문에 필요한 것도 아니다.

30분 전에 연차 규정을 이야기했더라도 지금 사용자가 비품 신청 절차를 묻는다면 최근 몇 턴의 맥락만으로 충분할 수 있다.

그래서 WithBuddy에서는 대화 이력에 상한을 두기로 했다.

최근 5턴만 유지한다.

사용자와 AI가 한 번씩 대화한 것을 1턴으로 본다면 최대 10개의 메시지를 저장하는 구조다.

1 Turn
= User Message
+ Assistant Message

5 Turns
= 최대 10 Messages

10개를 넘으면 가장 오래된 메시지부터 제거한다.


직접 개수를 관리하지 않고 자료구조에 맡긴다

최근 10개의 메시지만 유지하려면 애플리케이션 코드에서 직접 관리할 수도 있다.

예를 들어 다음과 같은 방식이다.

메시지 저장
   ↓
현재 메시지 개수 조회
   ↓
10개 초과 여부 판단
   ↓
오래된 메시지 조회
   ↓
삭제

하지만 이렇게 구현하면 메시지를 저장할 때마다 애플리케이션이

  • 현재 크기를 확인하고
  • 삭제 대상을 계산하고
  • 오래된 데이터를 제거하는

로직까지 직접 담당해야 한다.

굳이 그렇게 할 필요는 없었다.

Redis에는 이미 순서가 있는 데이터를 저장하는 List 자료구조가 있고, 특정 범위만 남기는 LTRIM 명령도 제공한다.

그래서 데이터 정리 로직을 애플리케이션에서 직접 구현하기보다 Redis List의 특성을 이용했다.


RPUSH + LTRIM — 자료구조로 관리하는 슬라이딩 윈도우

Redis List를 사용하면 최근 메시지만 유지하는 구조를 간단하게 만들 수 있다.

RPUSH session:{sessionId}:history {message}
LTRIM session:{sessionId}:history -10 -1
EXPIRE session:{sessionId}:history 1800

RPUSH는 새로운 메시지를 리스트의 오른쪽 끝에 추가하고, LTRIM -10 -1은 그중 가장 최근 10개만 남긴다. 메시지가 10개를 넘어가면 가장 오래된 항목부터 자연스럽게 제거된다.

예를 들어 현재 10개의 메시지가 저장된 상태에서 새로운 메시지가 들어오면 잠시 11개가 된다.

[M1][M2][M3][M4][M5][M6][M7][M8][M9][M10]
                                              ↓
                                            M11

RPUSH 이후에는 다음과 같다.

[M1][M2][M3][M4][M5][M6][M7][M8][M9][M10][M11]

여기서 LTRIM -10 -1을 실행하면 가장 오래된 M1이 제거되고 최근 10개만 남는다.

[M2][M3][M4][M5][M6][M7][M8][M9][M10][M11]

다음 메시지가 들어오면 같은 과정이 반복된다.

즉 애플리케이션에서 메시지 개수를 확인하고 오래된 항목을 찾아 별도로 삭제할 필요가 없다. 새로운 메시지는 오른쪽에 추가하고, Redis에는 항상 최근 범위만 남도록 잘라내는 구조다.

창이 한 칸씩 이동하면서 최신 데이터만 유지되는 모습과 비슷해 Sliding Window 방식으로 볼 수 있다.

M1 ~ M10
    ↓ M11
M2 ~ M11
    ↓ M12
M3 ~ M12
    ↓ M13
M4 ~ M13

대화가 계속 이어져도 Redis List의 크기는 최대 10개 안팎으로 유지되고, LLM에 전달할 최근 대화 범위도 일정하게 제한할 수 있다.


왜 Redis List가 이 문제에 잘 맞았나

이번 문제의 핵심은 어떤 키를 찾아 삭제하는 것이 아니다.

세션 키는 이미 알고 있다.

session:{sessionId}:history

필요한 것은 이 List 안에서

어느 범위까지 유지할 것인가

를 결정하는 것이다.

Redis List는 순서를 유지하므로 새 메시지를 한쪽 끝에 추가하고, 반대쪽의 오래된 범위를 자연스럽게 잘라낼 수 있다.

새 메시지 추가
→ RPUSH

최근 범위 유지
→ LTRIM

애플리케이션이 직접

현재 개수 조회
→ 초과 여부 판단
→ 삭제 대상 계산
→ 삭제

를 수행하는 것보다 훨씬 단순하다.

자료구조를 목적에 맞게 선택하면 애플리케이션 코드에서 관리해야 할 상태도 줄어든다.


LTRIM도 비용이 없는 명령은 아니다

LTRIM 역시 오래된 요소를 실제로 제거하므로 비용이 전혀 없는 명령은 아니다.

리스트에 수십만 개의 요소가 쌓인 뒤 대부분을 한 번에 잘라낸다면 제거해야 할 데이터도 많아진다.

하지만 WithBuddy에서는 그런 방식으로 사용하지 않았다.

매 메시지가 들어올 때마다 바로 다음 과정을 수행한다.

RPUSH 1개
↓
LTRIM 최근 10개

정상 상태라면 리스트는 항상 10개 안팎이다.

10개
 ↓ RPUSH
11개
 ↓ LTRIM
10개

즉 거대한 리스트를 만들어둔 뒤 한꺼번에 정리하는 것이 아니라, 작은 고정 크기를 계속 유지하는 방식이다.

이런 사용 패턴에서는 처리량과 메모리 사용량도 비교적 예측하기 쉽다.


TTL 30분 — 끝난 대화는 Redis가 자동으로 정리한다

최근 10개의 메시지만 유지해도 또 하나의 문제가 남는다.

사용자가 더 이상 대화하지 않는 세션이다.

사용자는 일반적으로 챗봇에게

이제 대화를 종료합니다.

라고 알려주지 않는다.

그냥 브라우저를 닫거나 다른 페이지로 이동한다.

별도 정책이 없다면 다음과 같은 세션 키들이 Redis에 계속 남을 수 있다.

session:A:history
session:B:history
session:C:history
session:D:history
...

각 키가 최대 10개의 메시지만 가지고 있더라도 세션 수가 계속 늘어나면 Redis 메모리를 점유한다.

그래서 각 대화 이력 키에 30분 TTL을 설정했다.

EXPIRE session:{sessionId}:history 1800

새 메시지가 들어올 때마다 TTL도 다시 30분으로 갱신한다.

사용자가 계속 대화하고 있다면 다음과 같이 동작한다.

Message
 ↓
TTL 30분

10분 후 Message
 ↓
다시 TTL 30분

5분 후 Message
 ↓
다시 TTL 30분

반대로 마지막 메시지 이후 30분 동안 아무 활동이 없다면 Redis가 해당 세션을 자동으로 제거한다.

Last Message
     ↓
30분 동안 활동 없음
     ↓
TTL Expire
     ↓
Session History 제거

별도의 세션 청소 배치나 스케줄러를 만들 필요가 없다.

9편에서 activeVersion + TTL로 구버전 캐시를 자연스럽게 정리한 것과 비슷한 접근이다.

명시적인 정리 작업을 계속 실행하기보다, 데이터가 필요 없어지는 조건을 TTL에 맡긴 것이다.


RPUSH + LTRIM + EXPIRE는 하나의 작업처럼 다루는 것이 좋다

여기서 한 단계 더 생각할 부분이 있다.

다음 명령을 애플리케이션에서 각각 따로 실행한다고 해보자.

RPUSH ...
LTRIM ...
EXPIRE ...

모두 정상적으로 실행되면 문제가 없다.

하지만 중간에 네트워크 오류나 애플리케이션 장애가 발생하면 일부 명령만 실행될 가능성이 있다.

예를 들어

RPUSH 성공
LTRIM 성공
EXPIRE 실패

하면 대화 이력 자체는 정상적으로 저장됐지만 해당 세션의 TTL이 갱신되지 않을 수 있다.

또 동일한 세션에 여러 요청이 동시에 들어오는 상황도 고려해야 한다.

따라서 실제 구현에서는 이 세 연산을 하나의 논리적인 작업으로 취급하는 편이 안전하다.

Redis Transaction을 사용하면 다음과 같이 묶을 수 있다.

MULTI
RPUSH session:{sessionId}:history {message}
LTRIM session:{sessionId}:history -10 -1
EXPIRE session:{sessionId}:history 1800
EXEC

또는 Lua Script를 이용해 서버 측에서 한 번에 처리할 수도 있다.

핵심은

메시지 추가
+
최근 범위 유지
+
TTL 갱신

을 서로 떨어진 작업으로 생각하지 않고 하나의 대화 이력 갱신 연산으로 취급하는 것이다.


보존 정책 정리

WithBuddy에서 사용한 대화 이력 정책은 다음과 같다.

항목정책

저장소 Redis List
Key session:{sessionId}:history
최대 보존 최근 5턴
메시지 기준 최대 10개
추가 RPUSH
범위 제한 LTRIM -10 -1
TTL 30분
TTL 갱신 새 메시지 저장 시
유휴 세션 정리 Redis Expire

전체 흐름은 단순하다.

새 Message
    ↓
RPUSH
    ↓
LTRIM -10 -1
    ↓
EXPIRE 1800

이 세 가지 연산으로 대화 이력의

  • 추가
  • 크기 제한
  • 유휴 세션 정리

를 처리한다.


목적은 Redis 메모리보다 LLM Context의 상한을 만드는 것이다

대화 이력을 제한한 가장 큰 이유는 Redis 메모리를 줄이기 위해서만은 아니다.

더 중요한 목적은 LLM에 전달되는 Conversation History의 크기를 제한하는 것이다.

제한이 없다면 대화가 길어질수록 다음 문제가 반복된다.

대화 증가
    ↓
History 증가
    ↓
Prompt 증가
    ↓
Input Token 증가
    ↓
비용 및 처리량 부담 증가

최근 5턴으로 제한하면 사용자가 10턴을 대화하든 100턴을 대화하든 LLM에 전달되는 대화 이력의 개수는 일정 범위를 넘지 않는다.

10턴 대화  → 최근 5턴
30턴 대화  → 최근 5턴
100턴 대화 → 최근 5턴

따라서 대화 길이에 따라 Conversation History가 끝없이 커지는 문제를 막을 수 있다.

다만 여기서도 표현은 정확해야 한다.

최근 5턴으로 제한했다고 해서 입력 토큰 수가 완전히 고정되는 것은 아니다.

각 메시지의 길이가 다르기 때문이다.

짧은 5턴과 긴 5턴의 토큰 수는 크게 다를 수 있다.

하지만 적어도 대화 턴 수 증가에 따라 History가 계속 누적되는 구조는 차단할 수 있다.


한계 1 — 오래된 맥락은 사라진다

Sliding Window 방식의 가장 분명한 단점이다.

최근 5턴만 남기기 때문에 오래된 대화는 자연스럽게 범위 밖으로 밀려난다.

예를 들어 15턴 전에 사용자가 이렇게 말했다고 해보자.

저는 개발팀입니다.

그 뒤 한참 대화를 이어간 다음 이렇게 물을 수 있다.

제 부서에서 신청할 수 있는 장비가 뭐죠?

개발팀이라는 정보가 이미 최근 5턴 밖으로 밀려났다면 대화 이력만으로는 현재 사용자의 부서를 알 수 없다.

그래서 WithBuddy에서는 대화 이력에 반드시 기억해야 하는 사용자 속성을 맡기지 않았다.

예를 들어 다음과 같은 값이다.

companyCode
department
formType
employeeId

이런 정보는 Conversation History와 별도로 현재 요청의 Context에서 전달한다.

Request Context
 ├─ companyCode
 ├─ department
 ├─ formType
 └─ ...

Conversation History
 └─ 최근 대화 흐름

역할을 분리한 것이다.

잊어도 되는 대화 흐름은 Sliding Window에 두고, 잊으면 안 되는 상태는 구조화된 Context로 관리했다.


한계 2 — 턴 수와 토큰 수는 다르다

두 번째 한계는 메시지 길이다.

다음 두 대화는 모두 5턴이지만 실제 토큰 수는 전혀 다를 수 있다.

A

사용자: 네.
AI: 확인했습니다.

B

사용자: 긴 사내 규정의 여러 조건을 포함한 질문
AI: 여러 문단으로 구성된 긴 답변

은 같은 5턴이라도 입력 크기가 크게 다르다.

따라서

최근 5턴

이라는 기준은 정확한 토큰 예산이라기보다 단순하고 예측하기 쉬운 제한 방식에 가깝다.

WithBuddy 규모에서는 이 정도로 충분했지만, 토큰 비용을 더 엄격하게 제어해야 한다면 턴 수가 아니라 실제 토큰 수를 기준으로 잘라내는 구조가 필요하다.

예를 들면 다음과 같다.

Conversation History
      ↓
Token Count 계산
      ↓
Budget 초과?
  ├─ NO → 그대로 사용
  └─ YES
       ↓
오래된 Message부터 제거

이렇게 하면 모델별 Context Window와 서비스별 토큰 예산에 맞춰 더 정교하게 제어할 수 있다.


한계 3 — 오래된 대화가 더 중요할 수도 있다

최근 대화라고 항상 중요한 것은 아니고, 오래된 대화라고 항상 중요하지 않은 것도 아니다.

예를 들어 사용자가 대화 초반에

이번 출장지는 일본입니다.

라고 말하고 여러 대화를 거친 뒤

그럼 비자 관련해서는 어떻게 해야 하나요?

라고 질문할 수 있다.

단순한 최근 5턴 방식에서는 출장지가 일본이라는 중요한 정보가 이미 사라졌을 수 있다.

이 문제까지 해결하려면 Sliding Window 외에 장기 기억 계층이 필요하다.

예를 들면 다음과 같이 역할을 나눌 수 있다.

최근 대화
→ Sliding Window

오래됐지만 중요한 정보
→ Summary Memory

사용자 속성
→ Structured Context

과거 대화에서 다시 찾아야 하는 정보
→ Vector Retrieval

따라서 Sliding Window는 모든 메모리 문제를 해결하는 구조라기보다 최근 대화 흐름을 저렴하게 유지하는 단기 메모리 전략이라고 보는 것이 정확하다.


더 긴 대화가 필요하다면 요약 기반 메모리로 확장할 수 있다

대화가 훨씬 길어지는 서비스라면 다음과 같이 확장할 수 있다.

Old Messages
     ↓
Summary
     ↓
Long-term Context
        +
Recent 5 Turns
        ↓
LLM Prompt

예를 들어 최근 5턴을 넘어간 오래된 대화를 그대로 버리는 대신 핵심 정보만 요약해서 유지할 수 있다.

이전 대화 요약:
- 사용자는 개발팀 소속이다.
- MacBook 장비 신청을 문의했다.
- 승인 담당자는 IT 자산팀이다.

최근 5턴:
...

이 방식은 전체 원문을 계속 전달하지 않으면서도 장기 맥락 일부를 유지할 수 있다는 장점이 있다.

다만 요약 역시 새로운 비용과 트레이드오프를 만든다.

요약을 생성하기 위한 추가 LLM 호출 비용이 발생할 수 있고, 요약 과정에서 중요한 정보가 누락되거나 왜곡될 가능성도 있다.

WithBuddy에서는 당시 서비스의 대화 길이와 비용을 고려해 우선 최근 5턴 Sliding Window라는 단순한 구조를 선택했다.


시즌 3에서 만든 AI 백엔드 구조

시즌 3에서는 지금까지 단순히 AI 서버라고 부르던 내부 구조를 단계별로 살펴봤다.

8편에서는 LangGraph를 이용해 질문이 다음 흐름을 거치도록 RAG 파이프라인을 구성했다.

SCOPE
→ Retrieve
→ Generate
→ Guardrail

9편에서는

Tenant Collection
+
formVersion

을 이용해 멀티테넌트 검색 공간과 캐시 무효화 문제를 다뤘다.

그리고 이번 10편에서는

RPUSH
+
LTRIM
+
EXPIRE

를 이용해 대화 이력의 크기와 수명을 제한했다.

세 편을 연결하면 다음과 같은 구조가 된다.

                  User Question
                       ↓
                  SCOPE Router
                       ↓
                 ChromaDB Search
                       ↓
              Tenant Collection
                       ↓
               formVersion Cache
                       ↓
              Conversation History
                       ↓
            Redis Sliding Window
                       ↓
                    Claude
                       ↓
                  Guardrail
                       ↓
                    Answer

정리 — 최근 맥락만 남기고, 나머지는 자료구조에 맡긴다

이번 작업의 핵심은 복잡한 알고리즘이 아니다.

Redis List의 특성에 맞는 명령을 조합한 것이다.

RPUSH
→ 새로운 메시지 추가

LTRIM
→ 최근 10개만 유지

EXPIRE
→ 30분 동안 사용하지 않은 세션 제거

이를 통해 대화가 계속 길어져도 Conversation History의 크기는 일정 범위 안에서 유지된다.

애플리케이션에서 메시지 개수를 확인하고, 오래된 메시지를 찾아 별도로 삭제하거나, 유휴 세션을 주기적으로 정리하는 배치 작업도 필요하지 않다.

그리고 중요한 사용자 정보는 Conversation History에만 의존하지 않고 companyCode, department, formType 같은 구조화된 Context로 별도 관리했다.

결국 이 설계의 핵심은

기억해야 하는 모든 것을 Conversation History 하나에 넣지 않는 것

이었다.

최근 대화 흐름은 Sliding Window가 맡고, 반드시 유지해야 하는 사용자 상태는 Request Context가 맡는다.

더 긴 기억이 필요하다면 이후 Summary Memory나 Vector Retrieval을 추가할 수 있다.

시즌 3의 세 편을 관통한 것도 비슷한 원칙이다.

테넌트 검색
→ Collection 구조로 제한

캐시 무효화
→ Version으로 전환

대화 이력
→ List 범위로 제한

AI 서비스라고 해도 결국 안정적인 시스템을 만드는 원리는 크게 다르지 않았다.

런타임에서 계속 조건을 검사하고 데이터를 정리하기보다, 자료구조와 키 설계가 원하는 상태를 자연스럽게 유지하도록 만드는 것이다.

다음 시즌 4에서는 지금까지의 성공적인 설계보다 실제 운영 과정에서 실패했던 사례를 다룬다.

Anthropic 529 Overloaded 오류, Grafana 오탐, 배포 과정에서 발생한 장애 등 실제 문제를 어떻게 진단했고, 어떤 대응은 효과가 있었으며 어떤 판단은 다시 수정해야 했는지를 통합 회고로 정리한다.


이 글은 실제 WithBuddy AI Form Assistant의 conversationHistory 관리 정책을 바탕으로 작성했다. 최근 5턴과 TTL 30분은 당시 서비스 요구사항에 맞춰 선택한 값이며, 실제 서비스에서는 평균 메시지 길이, 토큰 예산, 세션 특성에 따라 조정할 수 있다.

728x90
반응형
이 포스팅은 제휴마케팅 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.