Theme:

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

개념 정의

Redis에서 키에 TTL(EXPIRE)을 설정하면, 만료 시점이 지난 키는 더 이상 유효하지 않습니다. 하지만 만료 즉시 메모리에서 삭제되는 것은 아닙니다. Redis는 두 가지 전략을 조합하여 만료 키를 처리합니다.

  • Lazy Expiration (수동 삭제): 키에 접근할 때 만료 여부를 확인하고 삭제
  • Active Expiration (능동 삭제): 주기적으로 만료 키를 샘플링하여 삭제

왜 두 가지 전략이 필요한가

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를 사용하게 됩니다).

결론적으로 두 전략의 조합이 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

정리

Redis의 키 만료 메커니즘을 이해하면 메모리 관리 전략을 세울 수 있습니다.

  • Lazy Expiration: 키 접근 시 만료 확인 — CPU 효율적이지만 접근 없는 키는 잔류
  • Active Expiration: 주기적 샘플링으로 만료 키 정리 — 25% 임계값 기반 반복
  • hz 설정으로 Active Expiration 빈도를 조절할 수 있습니다 (기본: 10)
  • 대량의 키가 동시에 만료되지 않도록 TTL에 랜덤 지터를 추가하는 것이 좋습니다
  • Replica는 자체적으로 만료시키지 않고, Master의 DEL 전파에 의존합니다
  • INFO statsexpired_keys, expired_stale_perc로 만료 상태를 모니터링할 수 있습니다
댓글 로딩 중...