TTL이 만료된 Redis 키는 정확히 언제, 어떤 방식으로 메모리에서 사라질까요?

개념 정의

Redis의 만료된 키는 즉시 메모리에서 삭제되지 않습니다. Lazy Expiration(키 접근 시 삭제)과 Active Expiration(주기적 샘플링 삭제) 두 가지 전략을 조합하여 CPU와 메모리의 균형을 맞춥니다.

왜 두 가지 전략이 필요한가

Lazy만 사용한다면?

접근되지 않는 키는 영원히 메모리에 남습니다.

PLAINTEXT
시각     이벤트
00:00    SET session:1 "data" EX 60  (60초 후 만료)
01:00    session:1 만료 시각 도래
...      아무도 session:1에 접근하지 않음
24:00    session:1이 24시간째 메모리를 차지 중!

Active만 사용한다면?

만료된 키에 접근했을 때 아직 삭제되지 않은 경우 데이터를 반환할 위험이 있습니다(실제로는 Redis가 접근 시점에도 확인하므로 이 문제는 없지만, Active만으로 모든 키를 즉시 정리하려면 너무 많은 CPU를 사용하게 됩니다).

Lazy만으로는 접근되지 않는 키가 메모리에 잔류하고, Active만으로는 모든 만료 키를 즉시 처리하려면 CPU를 과도하게 사용하기 ** 때문에 , ** 따라서 두 전략의 조합이 CPU 사용과 메모리 효율의 균형을 맞춥니다.

Lazy Expiration (수동 삭제)

클라이언트가 키에 접근하면 Redis는 먼저 만료 여부를 확인합니다.

동작 흐름

PLAINTEXT
클라이언트: GET session:12345


        ┌───────────────┐
        │ 키가 존재하는가? │
        └───────┬───────┘
                │ Yes

        ┌───────────────┐
        │ 만료 시각이     │
        │ 설정되어 있는가? │
        └───────┬───────┘
                │ Yes

        ┌───────────────┐     ┌─────────┐
        │ 현재 시각 >    │ Yes │ 키 삭제  │
        │ 만료 시각?     │────→│ nil 반환 │
        └───────┬───────┘     └─────────┘
                │ No

        ┌─────────────┐
        │ 값 반환      │
        └─────────────┘

코드 관점에서의 동작

BASH
# 키 설정
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 주기마다 실행)

PLAINTEXT
activeExpireCycle() {
    반복:
        1. TTL이 설정된 키 중 20개를 무작위 샘플링
        2. 샘플 중 만료된 키를 삭제
        3. 만료된 키의 비율 계산
        4. 만료 비율이 25% 이상이면 → 1번으로 돌아가 반복
           만료 비율이 25% 미만이면 → 종료

    시간 제한: 전체 CPU 시간의 25%를 초과하지 않음
}

구체적인 동작 예시

PLAINTEXT
샘플링 라운드 1:
  20개 키 중 8개 만료 (40%) → 25% 이상이므로 계속

샘플링 라운드 2:
  20개 키 중 6개 만료 (30%) → 25% 이상이므로 계속

샘플링 라운드 3:
  20개 키 중 3개 만료 (15%) → 25% 미만이므로 종료

hz 설정의 영향

BASH
# hz: 초당 serverCron 호출 횟수 (기본: 10)
CONFIG SET hz 10
hz 값serverCron 주기Active Expiration 빈도CPU 사용
11000ms매우 낮음최소
10 (기본)100ms적정적정
10010ms매우 높음높음
500 (최대)2ms극도로 높음매우 높음
BASH
# dynamic-hz 활성화 (기본: yes)
# 연결된 클라이언트가 많으면 hz를 자동으로 높임
CONFIG SET dynamic-hz yes

Active Expiration의 시간 제한

각 라운드에는 시간 제한이 있어서 서비스 응답성에 미치는 영향을 제한합니다.

PLAINTEXT
기본 시간 제한 = 1000ms / hz * 25%
hz=10일 때: 100ms * 25% = 25ms

→ 한 번의 Active Expiration 사이클은 최대 25ms만 실행

만료 정밀도

Redis의 만료 타이머는 밀리초 정밀도 를 가집니다.

BASH
# 밀리초 단위 TTL 설정
SET key "value" PX 1500  # 1.5초

# 밀리초 단위 TTL 확인
PTTL key
# (integer) 1498

# 내부적으로 만료 시각은 Unix 밀리초 타임스탬프로 저장
# expires dict: key → expireTimeMs (int64)

만료 시각의 저장

Redis는 내부적으로 만료 시각을 절대 타임스탬프(밀리초) 로 저장합니다.

PLAINTEXT
main dict:    key → value
expires dict: key → 만료시각(ms timestamp)

이 설계 때문에 다음과 같은 특성이 있습니다.

BASH
# 서버 시각이 바뀌면 만료에 영향을 줄 수 있음
# NTP 동기화로 시각이 앞으로 점프하면 키가 일시에 만료될 수 있음

Replica에서의 만료 처리

PLAINTEXT
┌──────────┐                    ┌──────────┐
│  Master  │──── DEL 전파 ────→ │ Replica  │
│          │                    │          │
│ 만료 감지 │                    │ 자체적으로 │
│ → 삭제   │                    │ 삭제 안 함 │
└──────────┘                    └──────────┘

Replica의 만료 처리 규칙입니다.

  1. Replica는 자체적으로 키를 만료시키지 않습니다
  2. Master에서 만료가 감지되면 DEL 명령을 생성하여 Replica에 전파합니다
  3. Replica에서 만료된 키에 GET 요청이 오면, 논리적으로 만료된 것으로 판단하여 nil을 반환합니다 (3.2+)

주의: 복제 지연 시 불일치

PLAINTEXT
시나리오:
  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를 소모합니다.

모니터링

BASH
# INFO stats에서 만료 관련 통계 확인
INFO stats

# expired_keys: 만료되어 삭제된 총 키 수
# expired_stale_perc: 만료 키 비율 (대략적)
# expired_time_cap_reached_count: 시간 제한에 도달한 횟수

만료 키가 많이 쌓이는 상황

PLAINTEXT
문제: 동일한 TTL로 대량의 키를 동시에 생성
  → 동일 시각에 대량 만료 발생
  → Active Expiration이 시간 제한에 걸려 처리 못 함
  → 만료 키가 메모리를 계속 점유

해결: TTL에 랜덤 지터(jitter) 추가
PYTHON
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

PLAINTEXT
RDB 저장 시: 만료된 키는 RDB 파일에 포함하지 않음
RDB 로드 시:
  - Master: 만료된 키를 건너뜀
  - Replica: 만료된 키도 로드 (Master의 DEL 전파에 의존)

AOF

PLAINTEXT
AOF 기록 시: 만료 발생 → DEL 명령어를 AOF에 추가
AOF rewrite 시: 만료된 키는 새 AOF에 포함하지 않음

OBJECT 명령어로 만료 디버깅

BASH
# 키의 유휴 시간 확인 (마지막 접근 이후 초)
OBJECT IDLETIME mykey

# 키의 만료 시각 확인 (Unix timestamp, 7.0+)
EXPIRETIME mykey
PEXPIRETIME mykey  # 밀리초 단위

# TTL 확인
TTL mykey
PTTL mykey

함정/Pitfall

1. 같은 TTL로 대량의 키를 생성하면 동시 만료 폭탄이 된다

10만 개의 키를 EX 3600으로 동시 생성하면, 1시간 뒤 동시에 만료됩니다. Active Expiration이 시간 제한에 걸려 한 번에 처리하지 못하면 만료 키가 메모리를 계속 차지합니다. 반드시 TTL에 랜덤 지터 를 추가해야 합니다.

2. NTP 시각 점프가 대량 만료를 유발할 수 있다

Redis는 만료 시각을 절대 타임스탬프(밀리초)로 저장합니다. NTP 동기화로 서버 시각이 앞으로 점프하면, 아직 만료되지 않아야 할 키들이 일시에 만료될 수 있습니다.

3. Replica에서 만료된 키의 값이 순간적으로 보일 수 있다

Master에서 만료가 감지되면 DEL을 Replica에 전파하지만, 복제 지연이 있으면 그 사이에 Replica에서 만료된 키의 값이 조회될 수 있습니다. Redis 3.2+에서는 논리적으로 nil을 반환하여 이 문제를 완화했지만, 구버전에서는 주의가 필요합니다.

정리

항목핵심 내용
Lazy Expiration키 접근 시 만료 확인, CPU 효율적이지만 미접근 키 잔류
Active Expiration주기적 샘플링, 만료 비율 25% 미만이면 종료
hz 설정Active Expiration 빈도 조절 (기본 10, 높이면 CPU 증가)
TTL 지터대량 동시 만료 방지를 위해 랜덤 지터 추가
Replica자체 만료 안 함, Master의 DEL 전파에 의존
모니터링INFO stats의 expired_keys, expired_stale_perc
댓글 로딩 중...