출처
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)가 쌓이지 않도록 저장과 만료 설정을 원자적으로 처리한다.