출시·고도화 중
LRU 캐시 안내서 · 1/6
캐시는 가져오거나 계산하는 데 비용이 큰 데이터의 사본을 더 작고 빠른 곳에 보관해 두었다가, 같은 데이터를 다시 요청받으면 빠르게 돌려주는 장치입니다. 빠른 저장 공간은 작기 때문에 캐시는 결국 가득 차고, 무엇을 버릴지 정해야 합니다. 이때 쓰는 규칙을 교체 정책(eviction policy)이라고 하며, LRU(Least Recently Used, 가장 오래전에 사용한 항목 제거)는 가장 널리 가르치고 쓰는 정책입니다. 이 장에서는 용어를 정리하고, LRU를 다른 정책과 비교하고, 어떤 상황에 맞는지 살펴봅니다.
캐시는 언제나 더 느린 무언가의 앞에 놓입니다. CPU 캐시는 메인 메모리 앞에, 운영체제의 페이지 캐시는 디스크 앞에, Redis는 데이터베이스 앞에, CDN은 원본 서버 앞에 있습니다. 조회한 데이터가 캐시에 있으면 적중(hit), 느린 원본까지 가야 하면 실패(miss) 라고 합니다. 전체 조회 가운데 적중한 비율이 적중률(hit ratio) 이며, 캐시를 둘 가치가 있는지는 이 숫자로 판단합니다.
가장 단순한 캐시는 메모이제이션(memoization)에 쓰는 딕셔너리입니다. 아무것도 버리지 않으므로 가능한 키의 수가 적을 때만 안전합니다.
import time
slow_calls = 0
def slow_square(n):
global slow_calls
slow_calls += 1
time.sleep(0.01) # 네트워크 호출이라고 가정합니다
return n * n
memo = {}
def cached_square(n):
if n in memo: # 적중
return memo[n]
memo[n] = slow_square(n) # 실패: 계산하고 기억합니다
return memo[n]
for n in [3, 4, 3, 3, 4, 5]:
cached_square(n)
print(slow_calls) # 실패 3번, 적중 3번실제 캐시에는 용량(capacity) 이 있습니다. 항목 수일 수도, 바이트 수나 메모리 페이지 수일 수도 있습니다. 캐시가 가득 찬 상태에서 새 항목이 들어오면 기존 항목 하나를 제거(evict) 해야 합니다. 좋은 정책은 곧 다시 쓰일 가능성이 가장 낮은 항목을 고릅니다. 미래는 알 수 없으므로 모든 정책은 과거를 근거로 한 추측입니다.
| 정책 | 제거 대상 | 장점 | 약점 |
|---|---|---|---|
| FIFO | 가장 먼저 들어온 항목 | 구현이 아주 쉽습니다 | 얼마나 자주, 최근에 쓰였는지 무시합니다 |
| LRU | 가장 오랫동안 쓰이지 않은 항목 | 바뀌는 인기 항목을 따라갑니다 | 큰 순차 읽기 한 번에 캐시가 비워질 수 있습니다 |
| LFU | 사용 횟수가 가장 적은 항목 | 오래 인기 있는 항목을 지킵니다 | 인기가 식은 항목이 오래 남습니다 |
| TTL | 정해진 수명이 지난 항목 | 데이터가 낡는 정도를 제한합니다 | 그 자체로는 용량 정책이 아닙니다 |
| Random | 무작위 항목 | 관리 정보가 필요 없습니다 | 적중률을 예측하기 어렵습니다 |
TTL(Time To Live)은 다른 정책과 다른 질문에 답합니다. "가득 찼을 때 무엇을 버릴까"가 아니라 "데이터를 얼마 동안 믿을 수 있을까"입니다. 실제 시스템은 TTL을 LRU나 LFU와 함께 쓰는 경우가 많습니다.
FIFO 캐시는 순서 있는 딕셔너리로 쉽게 만들 수 있습니다. 삽입 순서가 곧 제거 순서이고, 조회해도 순서가 바뀌지 않습니다.
from collections import OrderedDict
class FIFOCache:
def __init__(self, capacity):
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
return self.data.get(key) # 읽어도 순서는 그대로입니다
def put(self, key, value):
if key not in self.data and len(self.data) >= self.capacity:
self.data.popitem(last=False) # 가장 먼저 넣은 항목 제거
self.data[key] = value
fifo = FIFOCache(2)
fifo.put("a", 1); fifo.put("b", 2)
fifo.get("a")
fifo.put("c", 3) # 방금 읽었는데도 "a"가 제거됩니다
print(list(fifo.data)) # ['b', 'c']LRU는 시간 지역성(temporal locality), 즉 최근에 쓴 데이터는 곧 다시 쓰일 가능성이 높다는 성질에 기대어 있습니다. 항목을 최근 사용 순서(recency) 로 정렬해 두고, 조회나 저장에 성공할 때마다 그 항목을 "가장 최근" 쪽으로 옮깁니다. 공간이 필요하면 "가장 오래전" 쪽 끝의 항목을 제거합니다. FIFO와의 차이는 읽기가 항목을 새로 고친다는 점 하나뿐이지만, 이 차이 덕분에 캐시가 프로그램이나 사용자, 서비스의 현재 작업 집합을 따라가게 됩니다.
Python은 LRU 캐시를 데코레이터로 제공하므로, 정책이 동작하는 모습을 가장 빨리 확인할 수 있습니다.
from functools import lru_cache
@lru_cache(maxsize=2)
def load(key):
print("loading", key)
return key.upper()
load("a"); load("b")
load("a") # 적중: "a"가 가장 최근이 됩니다
load("c") # 가득 참: 가장 오래전에 쓴 "b" 제거
load("b") # 다시 실패: "loading b" 출력
print(load.cache_info()) # CacheInfo(hits=1, misses=4, maxsize=2, currsize=2)LRU는 접근이 움직이는 인기 키 집합에 몰릴 때 잘 맞습니다. 사용자 세션, 최근 본 페이지, 최근 연 파일이 그런 예입니다. 반대로 두 가지 상황에서는 약합니다. 첫째, 캐시보다 큰 순차 스캔은 모든 항목을 밀어내고 다시 쓰이지 않을 항목만 남깁니다. 둘째, 오랫동안 꾸준히 인기 있지만 일회성 키의 흐름보다 덜 자주 접근되는 키가 있다면 LFU나 2Q, ARC, W-TinyLFU 같은 혼합 정책이 더 잘 지켜 줍니다. 그래서 실무 시스템은 교과서 그대로의 LRU보다 LRU 변형을 쓰는 경우가 많습니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.