Search

260722_2307_[REDIS]_TTL 없는 캐시 키가 수개월간 누적되면??

출처

TTL 없는 캐시 키가 수개월간 누적되면 Redis maxmemory에 도달하고, allkeys-lru 정책에 의해 세션 같은 중요한 키가 조용히 삭제됩니다.
어느 날부터 로그인한 사용자가 간헐적으로 로그아웃되는 버그 발생하고 있었습니다.
특정 사용자의 세션 키를 조회하면 nil 반환 → 세션 서버에 문제 발생되고 있었던거죠. 예를 들어 아래와 같은 상황이였습니다.
GET session:user:12345 # (nil) ← 살아있어야 하는 세션인데 없어짐
Bash
복사

원인 파악을 해봅시다 :)

레디스 메모리 사용량을 확인해 보니 maxmemory에 도달해 있었습니다.
상품 정보, 검색 결과를 TTL 없이 캐싱하고 있었으며,
상품이 10만 개, 검색 키워드 조합이 수십만 개 → 매일 키가 누적되고 있었고,
캐시 키와 세션 키가 하나의 Redis 인스턴스에서 메모리를 두고 경쟁하고 있었던 것이였습니다.
아래는 예시 상황입니다 :)
[캐시 키들] product:1, product:2, ... search:keyword1, search:keyword2, ... [세션 키들] session:user:12345, session:user:67890, ... → 모두 같은 Redis 인스턴스에 TTL 없이 쌓임 → maxmemory 도달
Plain Text
복사

왜 세션이 지워졌을까요??

Redis는 메모리가 가득 차면 maxmemory-policy 설정에 따라 키를 강제로 삭제(eviction)합니다. 기본 설정이 allkeys-lru인 경우, TTL 유무와 상관없이 전체 키 중 가장 오랫동안 사용되지 않은 키부터 삭제하는게 기본 값입니다 :(
세션 키는 특정 사용자가 아주 가끔 접근하므로, 캐시 키들보다 "최근에 사용된 시간"이 더 오래되어 먼저 삭제 대상이 되었던 것 입니다.
즉 이런 상황입니다 :(
메모리 꽉 참 → allkeys-lru 발동 → "가장 오래 안 쓴 키" 뽑아서 삭제 → 뜸하게 접근하는 세션 키가 타겟이 됨 → 로그아웃 버그 발생
Plain Text
복사

여기서 우리가 꼭 알아가야할 핵심 개념!!: TTL (Time To Live)

TTL은 키에 부여하는 유통기한입니다! 설정한 시간이 지나면 Redis가 알아서 해당 키를 삭제해주는 것입니다.
아래는 예시입니다!
# TTL 없이 저장 (영구 보관, 위험!) SET product:1 '{"name":"상품명"}' # TTL 설정 (1시간 후 자동 삭제, 권장) SET product:1 '{"name":"상품명"}' EX 3600 # TTL 확인 TTL product:1 # -1 → TTL 없음 (무한 보관) ← 문제의 키 # -2 → 키 자체가 없음 (만료되거나 처음부터 없음) # 양수 → 남은 시간(초)
Bash
복사
중요 원칙: 영구 보관이 필요한 데이터라면 Redis가 아닌 일반 DB를 사용하는 것이 맞다.

자주 하는 실수들입니다!

실수 1. TTL을 아예 안 설정한다

가장 흔하고 치명적인 실수. TTL 없는 키는 영구 보관되어 메모리를 조금씩 갉아먹는 상태입니다!
# 절대 이렇게 하지 말 것 SET cache:product:1 '...' # TTL 없음 → 좀비 키 # 항상 만료 시간 설정 SET cache:product:1 '...' EX 3600
Bash
복사

실수 2. 세션과 캐시를 같은 Redis 인스턴스에 저장한다.

세션은 TTL이 있어도 중요한 데이터입니다! 캐시와 같은 인스턴스를 쓰면 캐시 키가 메모리를 꽉 채울 때 세션이 희생될 수 있기에 잘 관리해야합니다!

실수 3. 모든 키에 동일한 TTL을 설정한다

모든 캐시가 동시에 만료되면 DB에 대량 요청이 한꺼번에 몰리는 Cache Stampede가 발생하니 주의해야합니다!
# 나쁜 예 - 모두 정확히 60초 SET key1 '...' EX 60 SET key2 '...' EX 60 # 좋은 예 - TTL에 랜덤 편차(Jitter) 추가 SET key1 '...' EX 57 # 60 ± random(5) SET key2 '...' EX 63
Bash
복사

해결 방법

1. 모든 키에 TTL 설정

저장 시 반드시 EX(초) 또는 PX(밀리초) 옵션을 붙이면 됩니다 :)
SET cache:product:1 '...' EX 3600 # 1시간 SET session:user:123 '...' EX 86400 # 24시간
Bash
복사

2. maxmemory-policy 설정

redis.conf 또는 런타임으로 메모리 정책을 명시적으로 설정하면 됩니다!
# redis.conf maxmemory 4gb maxmemory-policy allkeys-lru # 캐시 서버 용도일 때 권장
Bash
복사
정책
동작
적합한 용도
noeviction
메모리 꽉 차면 쓰기 거부(OOM 에러)
데이터 손실 절대 불가
allkeys-lru
전체 키 중 가장 오래 안 쓴 것 삭제
일반 캐시 서버 (권장)
volatile-lru
TTL 있는 키 중 가장 오래 안 쓴 것 삭제
중요 키와 캐시 혼용 시
volatile-ttl
만료가 가장 임박한 키 삭제
TTL 관리를 직접 하는 경우
allkeys-lfu
전체 키 중 사용 빈도 가장 낮은 것 삭제
핫/콜드 데이터 구분이 명확할 때

3. 좀비 키(TTL -1인 키) 청소

이미 쌓인 키는 SCAN으로 조회 후 TTL -1인 것들을 지워야합니다. KEYS *는 서버를 블로킹하므로 절대 금지 해야합니다!!!
# KEYS * → 금지! (서버 블로킹) # SCAN → 안전하게 순회 SCAN 0 MATCH cache:* COUNT 100
Bash
복사
Spring 환경에서 스케줄러로 자동 청소하는 예시:
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시 public void cleanupZombieKeys() { ScanOptions options = ScanOptions.scanOptions() .match("cache:*").count(100L).build(); redisTemplate.execute((RedisConnection conn) -> { try (Cursor<byte[]> cursor = conn.scan(options)) { while (cursor.hasNext()) { String key = new String(cursor.next()); Long ttl = redisTemplate.getExpire(key); if (ttl != null && ttl == -1L) { redisTemplate.delete(key); } } } return null; }); }
Java
복사

4. 세션과 캐시 인스턴스 분리

세션처럼 중요한 데이터는 별도의 Redis 인스턴스로 분리하는 것이 근본적인 해결책이니 꼭 명심하시길 바랍니다!
[캐시용 Redis] ← 상품 캐시, 검색 결과 캐시 (allkeys-lru) [세션용 Redis] ← 사용자 세션 (noeviction or volatile-lru)
Plain Text
복사

TTL과 Eviction의 차이

둘 다 메모리를 관리하지만 동작 원리가 다릅니다 !!!!
TTL 만료 (Expiration)
메모리 정책 (Eviction)
발동 조건
설정한 시간이 지났을 때
maxmemory에 도달했을 때
대상
TTL이 설정된 키만
정책에 따라 결정
시점
Lazy(접근 시) + Active(주기적 스캔)
새 쓰기 시도 직전
TTL 만료 ≠ 즉시 메모리 해제. Redis는 만료된 키를 "접근 시 확인"(Lazy) + "주기적 샘플링"(Active) 두 가지 방식으로 처리한다.

정리해봅시다!

1.
캐시 키에는 무조건 TTL을 설정한다. 영구 보관이 필요하면 Redis가 아닌 RDB를 사용한다.
2.
세션과 캐시는 Redis 인스턴스를 분리하는 것이 원칙이다.
3.
KEYS *는 프로덕션에서 금지, 항상 SCAN을 사용한다.
4.
maxmemory-policy를 명시적으로 설정하고, 캐시 서버라면 allkeys-lru가 일반적으로 권장된다.
5.
좀비 키(TTL -1)가 쌓이지 않도록 저장과 만료 설정을 원자적으로 처리한다.

이상!