키 만료와 메모리 회수 — Lazy vs Active Expiration
TTL이 만료된 Redis 키는 정확히 언제, 어떤 방식으로 메모리에서 사라질까요?
개념 정의
Redis에서 키에 TTL(EXPIRE)을 설정하면, 만료 시점이 지난 키는 더 이상 유효하지 않습니다. 하지만 만료 즉시 메모리에서 삭제되는 것은 아닙니다. Redis는 두 가지 전략을 조합하여 만료 키를 처리합니다.
- Lazy Expiration (수동 삭제): 키에 접근할 때 만료 여부를 확인하고 삭제
- Active Expiration (능동 삭제): 주기적으로 만료 키를 샘플링하여 삭제
왜 두 가지 전략이 필요한가
Lazy만 사용한다면?
접근되지 않는 키는 영원히 메모리에 남습니다.
시각 이벤트
00:00 SET session:1 "data" EX 60 (60초 후 만료)
01:00 session:1 만료 시각 도래
... 아무도 session:1에 접근하지 않음
24:00 session:1이 24시간째 메모리를 차지 중!
Active만 사용한다면?
만료된 키에 접근했을 때 아직 삭제되지 않은 경우 데이터를 반환할 위험이 있습니다(실제로는 Redis가 접근 시점에도 확인하므로 이 문제는 없지만, Active만으로 모든 키를 즉시 정리하려면 너무 많은 CPU를 사용하게 됩니다).
결론적으로 두 전략의 조합이 CPU 사용과 메모리 효율의 균형을 맞춥니다.
Lazy Expiration (수동 삭제)
클라이언트가 키에 접근하면 Redis는 먼저 만료 여부를 확인합니다.
동작 흐름
클라이언트: GET session:12345
│
▼
┌───────────────┐
│ 키가 존재하는가? │
└───────┬───────┘
│ Yes
▼
┌───────────────┐
│ 만료 시각이 │
│ 설정되어 있는가? │
└───────┬───────┘
│ Yes
▼
┌───────────────┐ ┌─────────┐
│ 현재 시각 > │ Yes │ 키 삭제 │
│ 만료 시각? │────→│ nil 반환 │
└───────┬───────┘ └─────────┘
│ No
▼
┌─────────────┐
│ 값 반환 │
└─────────────┘
코드 관점에서의 동작
# 키 설정
SET cache:user:1 "Alice" EX 10
# 10초 후 - 아직 메모리에 존재하지만 논리적으로 만료
# 이 시점에서 DBSIZE는 여전히 이 키를 포함할 수 있음
# 접근 시 삭제 발생
GET cache:user:1
# (nil) ← 접근 시점에 만료 확인 → 삭제 → nil 반환
# EXISTS도 Lazy Expiration을 트리거
EXISTS cache:user:1
# (integer) 0
Lazy Expiration의 특성
- CPU 비용: 거의 없음 (접근 시 O(1) 확인)
- 메모리 비용: 높을 수 있음 (접근 없는 키는 메모리 잔류)
- 정확도: 접근된 키는 100% 정확하게 만료 처리
Active Expiration (능동 삭제)
Redis의 serverCron 함수가 주기적으로 만료 키를 정리합니다.
알고리즘 (매 hz 주기마다 실행)
activeExpireCycle() {
반복:
1. TTL이 설정된 키 중 20개를 무작위 샘플링
2. 샘플 중 만료된 키를 삭제
3. 만료된 키의 비율 계산
4. 만료 비율이 25% 이상이면 → 1번으로 돌아가 반복
만료 비율이 25% 미만이면 → 종료
시간 제한: 전체 CPU 시간의 25%를 초과하지 않음
}
구체적인 동작 예시
샘플링 라운드 1:
20개 키 중 8개 만료 (40%) → 25% 이상이므로 계속
샘플링 라운드 2:
20개 키 중 6개 만료 (30%) → 25% 이상이므로 계속
샘플링 라운드 3:
20개 키 중 3개 만료 (15%) → 25% 미만이므로 종료
hz 설정의 영향
# hz: 초당 serverCron 호출 횟수 (기본: 10)
CONFIG SET hz 10
| hz 값 | serverCron 주기 | Active Expiration 빈도 | CPU 사용 |
|---|---|---|---|
| 1 | 1000ms | 매우 낮음 | 최소 |
| 10 (기본) | 100ms | 적정 | 적정 |
| 100 | 10ms | 매우 높음 | 높음 |
| 500 (최대) | 2ms | 극도로 높음 | 매우 높음 |
# dynamic-hz 활성화 (기본: yes)
# 연결된 클라이언트가 많으면 hz를 자동으로 높임
CONFIG SET dynamic-hz yes
Active Expiration의 시간 제한
각 라운드에는 시간 제한이 있어서 서비스 응답성에 미치는 영향을 제한합니다.
기본 시간 제한 = 1000ms / hz * 25%
hz=10일 때: 100ms * 25% = 25ms
→ 한 번의 Active Expiration 사이클은 최대 25ms만 실행
만료 정밀도
Redis의 만료 타이머는 밀리초 정밀도를 가집니다.
# 밀리초 단위 TTL 설정
SET key "value" PX 1500 # 1.5초
# 밀리초 단위 TTL 확인
PTTL key
# (integer) 1498
# 내부적으로 만료 시각은 Unix 밀리초 타임스탬프로 저장
# expires dict: key → expireTimeMs (int64)
만료 시각의 저장
Redis는 내부적으로 만료 시각을 **절대 타임스탬프(밀리초)**로 저장합니다.
main dict: key → value
expires dict: key → 만료시각(ms timestamp)
이 설계 때문에 다음과 같은 특성이 있습니다.
# 서버 시각이 바뀌면 만료에 영향을 줄 수 있음
# NTP 동기화로 시각이 앞으로 점프하면 키가 일시에 만료될 수 있음
Replica에서의 만료 처리
┌──────────┐ ┌──────────┐
│ Master │──── DEL 전파 ────→ │ Replica │
│ │ │ │
│ 만료 감지 │ │ 자체적으로 │
│ → 삭제 │ │ 삭제 안 함 │
└──────────┘ └──────────┘
Replica의 만료 처리 규칙입니다.
- Replica는 자체적으로 키를 만료시키지 않습니다
- Master에서 만료가 감지되면
DEL명령을 생성하여 Replica에 전파합니다 - Replica에서 만료된 키에
GET요청이 오면, 논리적으로 만료된 것으로 판단하여 nil을 반환합니다 (3.2+)
주의: 복제 지연 시 불일치
시나리오:
t=0: Master에서 SET key "val" EX 5
t=5: Master에서 key 만료, DEL 전파
t=5: 복제 지연으로 Replica에 DEL이 아직 도착하지 않음
t=5: Replica에서 GET key → 논리적으로 nil 반환 (3.2+)
Redis 3.2 이전에는 Replica가 만료된 키의 값을 그대로 반환하는 문제가 있었습니다.
만료 키 비율 제어
만료 키가 전체의 25% 이상이면 Active Expiration이 공격적으로 동작하여 CPU를 소모합니다.
모니터링
# INFO stats에서 만료 관련 통계 확인
INFO stats
# expired_keys: 만료되어 삭제된 총 키 수
# expired_stale_perc: 만료 키 비율 (대략적)
# expired_time_cap_reached_count: 시간 제한에 도달한 횟수
만료 키가 많이 쌓이는 상황
문제: 동일한 TTL로 대량의 키를 동시에 생성
→ 동일 시각에 대량 만료 발생
→ Active Expiration이 시간 제한에 걸려 처리 못 함
→ 만료 키가 메모리를 계속 점유
해결: TTL에 랜덤 지터(jitter) 추가
import redis
import random
r = redis.Redis()
# 나쁜 예: 모든 캐시가 동시에 만료
for i in range(100000):
r.set(f'cache:{i}', f'value{i}', ex=3600)
# 좋은 예: 만료 시각을 분산
for i in range(100000):
jitter = random.randint(0, 600) # 0~10분 랜덤 추가
r.set(f'cache:{i}', f'value{i}', ex=3600 + jitter)
이 패턴은 캐시 스탬피드(Cache Stampede) 방지에도 효과적입니다.
만료와 영속성(RDB/AOF)의 관계
RDB
RDB 저장 시: 만료된 키는 RDB 파일에 포함하지 않음
RDB 로드 시:
- Master: 만료된 키를 건너뜀
- Replica: 만료된 키도 로드 (Master의 DEL 전파에 의존)
AOF
AOF 기록 시: 만료 발생 → DEL 명령어를 AOF에 추가
AOF rewrite 시: 만료된 키는 새 AOF에 포함하지 않음
OBJECT 명령어로 만료 디버깅
# 키의 유휴 시간 확인 (마지막 접근 이후 초)
OBJECT IDLETIME mykey
# 키의 만료 시각 확인 (Unix timestamp, 7.0+)
EXPIRETIME mykey
PEXPIRETIME mykey # 밀리초 단위
# TTL 확인
TTL mykey
PTTL mykey
정리
Redis의 키 만료 메커니즘을 이해하면 메모리 관리 전략을 세울 수 있습니다.
- Lazy Expiration: 키 접근 시 만료 확인 — CPU 효율적이지만 접근 없는 키는 잔류
- Active Expiration: 주기적 샘플링으로 만료 키 정리 — 25% 임계값 기반 반복
- hz 설정으로 Active Expiration 빈도를 조절할 수 있습니다 (기본: 10)
- 대량의 키가 동시에 만료되지 않도록 TTL에 랜덤 지터를 추가하는 것이 좋습니다
- Replica는 자체적으로 만료시키지 않고, Master의 DEL 전파에 의존합니다
INFO stats의expired_keys,expired_stale_perc로 만료 상태를 모니터링할 수 있습니다