What Keys Without a TTL Create
한국어 원문으로 표시합니다.
한 줄 요약
만료는 시각이 되면 즉시 일어나지 않는다. 접근할 때 지우거나(지연 삭제) 백그라운드가 표본을 뽑아 지운다(능동 만료).
왜 이게 필요했나
Redis 인스턴스의 메모리가 계속 자라는데 원인을 못 찾는 상황이 자주 있습니다. 대개 답은 하나입니다 — TTL 을 안 건 키가 섞여 있습니다.
문제는 그것이 눈에 안 띈다는 점입니다. 캐시 코드는 대부분 SETEX 를 쓰는데, 어딘가 한 군데에서 SET 만 쓰고 있거나, SET key val 로 갱신하면서 원래 있던 TTL 을 날려 버립니다. 이 두 번째가 특히 교묘합니다 — SET 은 기존 TTL 을 제거합니다. TTL 을 유지하려면 KEEPTTL 옵션이 필요합니다.
어떻게 동작하나
만료 메커니즘부터 정확히 알아야 합니다. Redis 는 두 가지 방식을 함께 씁니다. 지연 삭제는 키에 접근하는 순간 만료를 확인하고 지웁니다. 능동 만료는 백그라운드에서 TTL 있는 키를 무작위 표본으로 뽑아 만료된 것을 지웁니다. 그래서 만료 시각이 지났는데도 아무도 접근하지 않는 키는 한동안 메모리에 남아 있습니다. DBSIZE 가 예상보다 큰 이유가 이것입니다.
maxmemory 에 도달하면 축출 정책이 작동합니다. 선택지가 여덟 가지인데 실무에서 중요한 건 셋입니다.
noeviction 은 기본값이고, 한도에 닿으면 쓰기를 거부합니다. 캐시로 쓸 때 이 설정이면 어느 날 갑자기 모든 쓰기가 실패합니다.
allkeys-lru 또는 allkeys-lfu 는 모든 키를 축출 대상으로 봅니다. 순수 캐시 용도라면 이쪽입니다. 저자의 권고는 allkeys-lfu 입니다 — 최근성보다 빈도가 캐시 적중률에 더 잘 맞기 때문입니다.
volatile-lru 는 TTL 이 설정된 키만 축출합니다. 캐시와 영구 데이터가 한 인스턴스에 섞여 있을 때 씁니다. 다만 이 설정에서 캐시 키에 TTL 을 빠뜨리면, 그 키는 영원히 축출되지 않으면서 메모리를 먹습니다.
캐시가 무너지는 세 가지 방식
TTL 설계는 이 셋을 막기 위한 것입니다. 이름이 비슷해 헷갈리지만 원인과 대책이 다릅니다.
| 이름 | 무슨 일인가 | 막는 법 |
|---|---|---|
| 스탬피드(stampede) | 인기 키가 만료되는 순간 수천 요청이 동시에 DB 로 | 지터, 잠금, 조기 재계산 |
| 관통(penetration) | 없는 키를 계속 조회 — 캐시에 아무것도 안 남아 매번 DB | 없음도 짧게 캐시, 블룸 필터 |
| 사태(avalanche) | 많은 키가 한꺼번에 만료 또는 캐시 서버 다운 | 지터, TTL 분산, 다층 캐시 |
스탬피드 는 잠금으로 막습니다. 첫 요청만 DB 에 가고 나머지는 기다립니다.
def get_or_load(key, loader, ttl=300):
v = r.get(key)
if v is not None:
return v
# SET NX 로 잠금 하나만 얻는다. 얻지 못한 요청은 잠깐 기다렸다 다시 읽는다.
if r.set(f"lock:{key}", "1", nx=True, ex=10):
try:
v = loader()
r.set(key, v, ex=ttl + random.randint(-30, 30)) # 지터
return v
finally:
r.delete(f"lock:{key}")
time.sleep(0.05)
return r.get(key) or loader() # 그래도 없으면 어쩔 수 없이 직접
더 나은 방법은 조기 재계산 입니다. TTL 이 끝나기 전에 확률적으로 미리 갱신하면 만료 순간 자체가 없어집니다. 남은 TTL 이 짧을수록 갱신 확률을 높이는 방식 (probabilistic early expiration)이 널리 쓰입니다.
관통 은 "없다" 도 캐시합니다. 다만 짧게(30~60초) 걸어야 진짜 데이터가 생겼을 때 금방 반영됩니다.
캐시와 원본이 어긋나는 순간
쓰기가 있으면 무효화가 필요하고, 무효화에는 순서 문제가 있습니다.
❌ 캐시를 먼저 지우고 DB 를 쓴다
1. DEL cache 2. (다른 요청이 옛 값을 읽어 캐시에 다시 넣는다)
3. UPDATE db → 캐시에 옛 값이 영원히 남는다
✅ DB 를 먼저 쓰고 캐시를 지운다 (cache-aside)
1. UPDATE db 2. DEL cache
→ 사이에 읽은 요청은 옛 값을 보지만, 곧 사라진다
그래도 드물게 어긋납니다. 완전히 막으려면 지연 이중 삭제 (쓰고 지운 뒤, 수백 밀리초 후 한 번 더 지우기)나 변경 로그(CDC) 구독을 씁니다. 대부분의 서비스에서는 짧은 TTL 이 더 싼 답입니다 — 어긋나도 TTL 만큼만 어긋납니다.
현장에서 만나는 모습
TTL 에 지터를 넣는 것도 중요합니다. 배포 직후 캐시를 한꺼번에 채우면 TTL 이 정확히 같아서 한 시간 뒤 전부 동시에 만료됩니다. 그 순간이 스탬피드입니다. ttl = base + random(-spread, +spread) 한 줄이면 만료가 시간축에 퍼집니다.
그리고 감사가 필요합니다. SCAN 으로 순회하며 TTL 이 -1 인 키(만료 없음)를 세는 스크립트를 주기적으로 돌리면, 새로 들어온 코드가 TTL 을 빠뜨렸을 때 바로 잡힙니다.
다음 실습에서 할 것
TTL 을 걸고 확인하고, SET 이 TTL 을 날리는 함정을 재현하고, KEEPTTL 로 막고, 축출 정책을 바꿔 실제로 키가 축출되는 것을 확인하고, 마지막에 TTL 없는 키를 찾아내는 감사 스크립트를 만듭니다.