Redis 캐시 전략 비교 — LRU·LFU·allkeys로 maxmemory 다루기

목차

(error) OOM command not allowed when used memory > 'maxmemory'.
used_memory_human:511.42M
maxmemory_human:512.00M
maxmemory_policy:noeviction
evicted_keys:0

여기서 시작이다. Redis 캐시 전략 비교를 진지하게 보게 된 계기는 이 에러 한 줄이었다. Redis 7.2.4를 띄워둔 스테이징 인스턴스에 세션과 캐시를 같이 쌓아 올리다가 SET이 거부됐다. 메모리가 꽉 찼다는 신호다.

프론트엔드만 만지던 시절에는 Redis가 마법처럼 알아서 오래된 캐시를 비워 주는 줄 알았다. 백엔드로 넘어온 지 2년. 직접 운영해 보니 Redis는 명시적으로 시키지 않으면 아무것도 안 버린다. maxmemory-policy 기본값이 noeviction이기 때문이다. 메모리가 꽉 차면 새 데이터를 그냥 거부한다.

원인은 두 가지였다. 첫째, EXPIRE를 안 걸어둔 키가 절반 가까이 됐다. 둘째, 캐시 키와 영속 키를 같은 인스턴스에 같이 박았다. 정책을 손대지 않으면 Redis는 둘을 구별하지 않는다.

오늘 정리한 것 — maxmemory-policy 8가지

예를 들어, Redis 7 기준으로 maxmemory-policy는 8가지다. 두 축으로 나뉜다고 보면 외우기 쉽다.

첫 번째 축은 어떤 키를 제거 후보로 볼 건지다. allkeys-는 전체 키가 후보고, volatile-은 TTL이 걸린 키만 후보다. 두 번째 축은 후보 중에서 어떤 알고리즘으로 고를 건지다. lru, lfu, random, ttl 네 가지가 있다. 여기에 noeviction 하나가 따로 있다.

정책 후보 키 선정 방식 적합한 워크로드
noeviction 안 버림 영속 저장소
allkeys-lru 전체 가장 오래 안 쓴 키 균일한 캐시
allkeys-lfu 전체 가장 적게 쓴 키 핫 키 분명한 캐시
allkeys-random 전체 무작위 균일 분포 캐시
volatile-lru TTL 있는 키 가장 오래 안 쓴 키 캐시+영속 혼합
volatile-lfu TTL 있는 키 가장 적게 쓴 키 캐시+영속 혼합
volatile-random TTL 있는 키 무작위 캐시+영속 혼합
volatile-ttl TTL 있는 키 만료 임박한 키 짧은 TTL 캐시

그런데, 원리는 단순한데 막상 골라야 할 때 헷갈린다. 두 축을 각각 떼서 보면 결정이 빨라진다.

LRU와 LFU의 진짜 차이

실제로, 처음에는 LRU만 알고 있었다. 가장 오래 접근하지 않은 키를 버린다는 게 직관적이라 다들 LRU를 쓴다고 생각했다. 막상 운영하다 보니 LRU가 늘 답은 아니었다.

문제 상황은 이랬다. 새벽 배치가 한 번 돌면 임시 키가 수만 개 만들어진다. 이 키들은 한 번씩만 쓰이고 버려진다. LRU 입장에서는 이놈들이 "최근에 접근한 키"다. 정작 사용자가 자주 두드리는 핫 키는 시간이 지났다는 이유로 밀려나기 시작한다.

LFU는 접근 빈도를 본다. Redis 4.0부터 들어간 정책이고, 내부적으로 Morris counter라는 확률적 카운터로 빈도를 추적한다 (출처: Redis Eviction 공식 문서, 2026년 5월 기준). 자주 쓰는 키는 카운트가 쌓이고, 드물게 쓰이는 키는 시간이 지나면서 카운트가 줄어든다.

물론, 체감 차이는 분명했다. 같은 워크로드에서 정책만 바꿨을 때 캐시 적중률이 다음과 같이 움직였다.

정책 적중률 (체감) 비고
allkeys-lru 78% 안팎 새벽 배치 직후 급락
allkeys-lfu 88% 안팎 안정화까지 2~3일
allkeys-random 71% 안팎 가장 들쭉날쭉

수치는 한 서비스의 체감 결과이고 일반화는 어렵다. 본인 워크로드로 직접 측정하는 게 맞다.

또한, LFU에는 튜닝 파라미터가 두 개 있다. lfu-log-factor는 카운터가 얼마나 천천히 올라갈지, lfu-decay-time은 카운터가 얼마나 빨리 감쇠할지를 정한다. 기본값은 각각 10과 1분이다. 트래픽이 트렌디하게 바뀌는 서비스라면 decay-time을 줄여서 옛 인기를 빨리 잊게 만드는 게 낫다.

allkeys와 volatile 중 뭘 골라야 할까

이게 처음에 가장 헤맸다. volatile-lru로 설정해 놓고 EXPIRE를 안 걸었더니 후보 키가 없어서 결국 OOM이 떨어졌다. 로그를 봐도 evicted_keys가 0이라 한참 의심했다.

used_memory_rss:478.21M
maxmemory:512M
maxmemory_policy:volatile-lru
evicted_keys:0
expired_keys:0

메모리는 꽉 찼는데 아무것도 안 쫓겨났다. volatile 정책인데 TTL 걸린 키가 없으니까 후보가 없었던 거다. 이건 공식 문서에도 적혀 있는데, 처음엔 그게 뭘 뜻하는지 잘 와닿지 않는다.

그래서 결정 흐름을 단순하게 잡았다.

  • 캐시 전용 인스턴스 → allkeys-lfu (핫 키 분명) 또는 allkeys-lru (균일)
  • 캐시와 영속 데이터 혼합 → volatile-lru, TTL 누락 모니터링 필수
  • 영속 전용 (메시지 큐, 작업 큐 등) → noeviction, maxmemory를 넉넉하게

영속과 캐시를 한 인스턴스에 섞는 건 가능하면 피하라는 게 일반적인 권장이다. 인스턴스를 두 개로 나누면 정책도 단순해지고 장애 영향도 분리된다 (개인적으로 이게 가장 효과적이었다).

직접 돌려본 코드

실제로, 테스트는 docker로 띄운 Redis 7.2.4에서 했다. maxmemory를 작게 잡아 놓고 정책별로 어떻게 동작하는지 보는 게 가장 빠르다.

# 4MB로 제한하고 정책을 LFU로 띄운다
docker run -d --name redis-test -p 6379:6379 redis:7.2.4 \
  redis-server \
  --maxmemory 4mb \
  --maxmemory-policy allkeys-lfu \
  --lfu-log-factor 10 \
  --lfu-decay-time 1

Python으로 키를 채워 본다.

import redis
import random

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# 1만 개 키를 빠르게 채운다 (각각 약 100바이트)
for i in range(10000):
    r.set(f"key:{i}", "x" * 100)

# 핫 키 100개를 자주 두드린다 — LFU 카운터를 키운다
hot_keys = [f"key:{i}" for i in range(100)]
for _ in range(50000):
    r.get(random.choice(hot_keys))

# 메모리 상태 확인
mem = r.info('memory')
stats = r.info('stats')
print(f"used_memory_human: {mem['used_memory_human']}")
print(f"evicted_keys: {stats['evicted_keys']}")
print(f"keyspace_hits: {stats['keyspace_hits']}")
print(f"keyspace_misses: {stats['keyspace_misses']}")

위 코드로 정책만 바꿔가며 돌려 보면 evicted_keys 수치 자체는 크게 차이 안 난다. 진짜 차이는 핫 키 적중률에서 나온다.

# 핫 키 적중률 측정
hits = 0
total = 10000
for _ in range(total):
    key = random.choice(hot_keys)
    if r.get(key) is not None:
        hits += 1
print(f"hot_key_hit_rate: {hits / total:.2%}")

LRU에서는 핫 키 적중률이 60%대 초반, LFU에서는 90%대 초반으로 갔다. 4MB라는 작은 메모리에서 1만 개 키를 다루는 비현실적 환경이긴 한데, 핫 키 보호라는 LFU의 성질은 명확히 드러난다.

maxmemory는 얼마로 잡을까

이게 또 모호하다. AWS ElastiCache 권장은 가용 메모리의 50~75% 사이를 maxmemory로 잡는 거다 (출처: AWS ElastiCache Best Practices, 2026년 5월 확인 기준). RDB 스냅샷이나 AOF 재작성, 클라이언트 버퍼 같은 게 maxmemory 외부에서 메모리를 쓰기 때문이다.

직접 운영하는 인스턴스라면 더 빡빡하게 잡아도 된다. 다만 fork 시점에 Copy-on-Write로 메모리가 일시적으로 두 배까지 부풀 수 있다는 건 늘 기억해야 한다.

가용 메모리 maxmemory 권장 주된 이유
1GB 700MB 스냅샷 fork 여유
4GB 3GB RDB+클라이언트 버퍼
16GB 12GB AOF 재작성 여유

체감상 RDB를 끄고 AOF만 쓰는 환경이면 더 빡빡하게 80%까지 잡아도 큰 문제는 없었다. 반대로 RDB save가 자주 일어나는 환경에서 70%로 잡았다가 fork 시점에 OS 레벨에서 OOM-killer가 떴던 적이 있다 (이건 정말 식겁했다).

운영 중에 챙겨본 것들

설정만 바꾸고 끝이 아니다. 정책이 의도대로 동작하는지 보려면 몇 개 지표를 봐야 한다.

# 메모리 상태
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human|maxmemory_policy"

# 제거 통계
redis-cli INFO stats | grep -E "evicted_keys|expired_keys|keyspace_hits|keyspace_misses"

# 키 분포
redis-cli --bigkeys

evicted_keys가 너무 빠르게 증가하면 maxmemory가 작거나 캐시 키 자체가 너무 많은 거다. keyspace_hits / (keyspace_hits + keyspace_misses)로 적중률을 계산해서 70% 미만이면 정책을 의심해 본다.

예를 들어, LFU로 운영할 때 추가로 보면 좋은 건 OBJECT FREQ 명령이다.

# 특정 키의 LFU 카운터 확인 (maxmemory-policy가 LFU 계열일 때만)
redis-cli OBJECT FREQ session:user:1234

핫 키의 카운터가 충분히 쌓였는지, 별로 안 쓰이는데 카운터만 높은 키가 있는지 한 번씩 확인하면 정책 효과를 체감할 수 있다.

TTL 누락을 잡는 법

volatile- 정책을 쓴다면 TTL 안 걸린 키가 얼마나 있는지가 핵심이다. 모든 키를 SCAN으로 훑으면서 TTL을 확인하는 스크립트를 한 번씩 돌리는 게 가장 확실하다.

import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

no_ttl = 0
with_ttl = 0
for key in r.scan_iter(count=1000):
    ttl = r.ttl(key)
    if ttl == -1:  # TTL 없음
        no_ttl += 1
    else:
        with_ttl += 1

print(f"no_ttl: {no_ttl}, with_ttl: {with_ttl}")
print(f"no_ttl_ratio: {no_ttl / (no_ttl + with_ttl):.2%}")

예를 들어, 운영 환경에서 SCAN을 쓸 때는 count를 작게 잡고 천천히 도는 걸 권한다. KEYS는 절대 쓰지 마라. 블로킹이 길어지면 다른 명령이 다 밀린다.

메모

예를 들어, 오늘 알게 된 거 짧게 정리한다.

  1. noeviction이 기본값이다. 캐시로 쓸 거면 반드시 바꿔야 한다.
  2. volatile- 계열은 TTL 안 걸린 키가 많으면 OOM이 그대로 떨어진다.
  3. LFU는 Redis 4.0부터, 핫 키가 분명한 워크로드에서 LRU보다 적중률이 높다.
  4. maxmemory는 가용 메모리의 70% 안팎이 안전선이다. fork 메모리를 잊지 마라.
  5. OBJECT FREQ로 LFU 카운터를 직접 볼 수 있다.

설정 자체는 한 줄이다.

redis-cli CONFIG SET maxmemory-policy allkeys-lfu
redis-cli CONFIG REWRITE  # redis.conf에도 반영

런타임 변경은 즉시 적용된다. 다만 LFU 카운터는 처음에 0부터 시작하기 때문에 정책을 막 바꾼 직후에는 새 키가 더 많이 쫓겨날 수 있다. 며칠 운영하면 안정화된다 (이건 공식 문서에는 잘 안 적혀 있고, GitHub 이슈 redis/redis#5839 인근 논의에서 확인했다).

예를 들어, 당장 할 액션은 세 개다. 첫째, redis-cli CONFIG GET maxmemory-policy로 현재 정책을 확인한다. 둘째, noeviction이면 워크로드에 맞춰 LRU나 LFU로 바꾼다. 셋째, evicted_keys와 핫 키 적중률을 며칠 동안 비교한다.

개인적으로는 일반적인 캐시 워크로드에서는 allkeys-lfu가 가장 무난한 선택 같다.

관련 글