LRU 캐시 구현
캐시는 작고 빠른 저장소라서 모든 데이터를 담을 수 없고, 가득 차면 무엇을 버릴지 정해야 합니다. 그 기준으로 널리 쓰이는 것이 LRU(Least Recently Used)입니다. 가장 오래 사용되지 않은 항목을 버린다는 뜻으로, 최근에 쓴 데이터는 곧 다시 쓰일 가능성이 높다는 시간 지역성(temporal locality)에 기대는 정책입니다.
LRU 정책을 말로만 설명하면 쉽지만, 조회와 삽입, 제거를 모두 O(1)에 하려면 자료구조를 골라야 합니다. 표준적인 구현은 해시맵과 이중 연결 리스트의 조합입니다. 해시맵은 키로 노드를 O(1)에 찾고, 이중 연결 리스트는 사용 순서를 담아 노드를 O(1)에 떼어 맨 앞으로 옮기거나 맨 뒤를 제거합니다. 이 토픽에서는 두 구조가 함께 움직이는 모습을 단계별로 봅니다.
핵심 개념
LRU 정책
용량이 가득 찼을 때 가장 오래 사용되지 않은 항목을 제거합니다. 기준은 삽입 순서가 아니라 마지막으로 사용한 시점입니다.
핵심 개념
해시맵
키에서 리스트 노드로 가는 길을 만들어, 항목을 찾는 데 리스트를 훑지 않아도 되게 합니다. 평균 O(1)로 조회합니다.
핵심 개념
이중 연결 리스트
앞쪽이 가장 최근, 뒤쪽이 가장 오래된 항목인 순서를 유지합니다. 노드가 앞뒤 포인터를 모두 가져 중간 노드도 O(1)에 떼어 낼 수 있습니다.
핵심 개념
조회 = 맨 앞으로 이동
get으로 항목을 쓰면 그 노드를 떼어 리스트 맨 앞으로 옮깁니다. 값을 갱신하는 put도 사용으로 보고 같은 방식으로 처리합니다.
핵심 개념
용량 초과 시 맨 뒤 제거
새 키를 넣을 때 가득 차 있으면 리스트 맨 뒤 노드를 제거하고 해시맵에서도 그 키를 지웁니다. 둘 중 하나만 지우면 불일치가 생깁니다.
핵심 개념
더미 노드
값이 없는 head와 tail 노드를 양 끝에 두면, 리스트가 비었거나 노드가 하나일 때의 예외 처리 없이 같은 코드로 삽입과 삭제를 합니다.
LRU의 배경
LRU는 가상 메모리의 페이지 교체 알고리즘으로 일찍부터 연구되었습니다. 1966년 벨레이디(Belady)는 가상 기억 장치 컴퓨터의 여러 교체 알고리즘을 다룬 논문을 발표했고, 이 연구에서 앞으로 가장 늦게 쓰일 페이지를 버리는 최적 알고리즘이 비교 기준으로 쓰입니다. 미래를 알 수 없으므로 과거의 사용 이력으로 미래를 짐작하는 LRU가 실용적인 대안이 되었고, 지금은 운영체제의 페이지 교체, 데이터베이스 버퍼 풀, 애플리케이션 캐시에서 LRU 또는 그 변형이 쓰입니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
LRU 캐시를 O(1)로 구현하려면?
해시맵은 키로 노드를 찾고 이중 연결 리스트는 사용 순서를 유지합니다. get은 노드를 맨 앞으로 옮기고, put은 가득 찼을 때 맨 뒤 노드를 리스트와 해시맵에서 지운 뒤 맨 앞에 넣습니다.
면접 질문
왜 이중 연결 리스트인가요?
해시맵으로 찾은 중간 노드를 O(1)에 떼어 내려면 앞 노드의 포인터를 고쳐야 하므로 앞 노드를 바로 알아야 합니다. 이전 포인터를 가진 이중 연결 리스트가 이를 가장 단순하게 가능하게 합니다.
면접 질문
LRU와 LFU, FIFO의 차이는?
FIFO는 들어온 순서, LRU는 마지막 사용 시점, LFU(Least Frequently Used)는 사용 횟수가 기준입니다. LRU는 최근성, LFU는 빈도를 중시합니다.
면접 질문
멀티스레드에서는 어떻게 하나요?
get도 리스트를 바꾸므로 읽기 연산이어도 동기화가 필요합니다. 전체에 락을 거는 방법, 샤딩처럼 락 범위를 줄이는 방법, 직접 만들기보다 동시성을 지원하는 캐시 라이브러리를 쓰는 방법이 있습니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
Python — 해시맵과 이중 연결 리스트로 직접 구현
class Node: def __init__(self, key=0, val=0): self.key, self.val = key, val self.prev = self.next = None class LRUCache: def __init__(self, capacity): self.cap = capacity self.map = {} # key -> Node self.head, self.tail = Node(), Node() # 더미 노드로 경계 처리를 없앤다 self.head.next, self.tail.prev = self.tail, self.head def _remove(self, node): node.prev.next, node.next.prev = node.next, node.prev def _add_front(self, node): node.prev, node.next = self.head, self.head.next self.head.next.prev = node self.head.next = node def get(self, key): if key not in self.map: return None node = self.map[key] self._remove(node) self._add_front(node) # 사용했으니 맨 앞으로 return node.val def put(self, key, val): if key in self.map: node = self.map[key] node.val = val self._remove(node) self._add_front(node) return if len(self.map) == self.cap: lru = self.tail.prev # 맨 뒤 = 가장 오래 쓰지 않은 노드 self._remove(lru) del self.map[lru.key] node = Node(key, val) self.map[key] = node self._add_front(node) def keys(self): # 최근 → 오래된 순서 out, n = [], self.head.next while n is not self.tail: out.append(n.key); n = n.next return out c = LRUCache(3) c.put(1, 'A'); c.put(2, 'B'); c.put(3, 'C') print(c.keys()) # [3, 2, 1] print(c.get(1), c.keys()) # A [1, 3, 2] print(c.get(9)) # None c.put(4, 'D') print(c.keys(), 2 in c.map) # [4, 1, 3] False c.put(3, 'E') print(c.keys(), c.get(3)) # [3, 4, 1] E
map은 키로 노드를 O(1)에 찾게 해 주고, 이중 연결 리스트는 노드를 O(1)에 떼어 맨 앞에 붙이게 해 줍니다. head와 tail은 값이 없는 더미 노드라서 리스트가 비어 있거나 노드가 하나일 때도 앞뒤 포인터를 같은 식으로 고칠 수 있습니다. get은 노드를 찾아 맨 앞으로 옮기고 값을 돌려줍니다. put은 이미 있는 키면 값을 바꾸고 맨 앞으로 옮기고, 새 키인데 가득 찼으면 tail 바로 앞 노드(가장 오래 쓰지 않은 항목)를 리스트와 map에서 지운 뒤 새 노드를 맨 앞에 넣습니다. 시각화의 세 탭이 보여 주는 순서와 같습니다. 1, 2, 3을 넣고 1을 조회하면 순서가 1, 3, 2가 되고, 키 4를 넣을 때 맨 뒤인 2가 제거됩니다. 가장 먼저 들어온 1이 아니라 가장 오래 쓰지 않은 2가 제거되는 것이 FIFO와 다른 점입니다.
Python — OrderedDict와 functools.lru_cache
from collections import OrderedDict class LRU2: def __init__(self, cap): self.cap, self.d = cap, OrderedDict() def get(self, k): if k not in self.d: return None self.d.move_to_end(k) # 사용한 키를 맨 뒤(최근)로 return self.d[k] def put(self, k, v): self.d[k] = v self.d.move_to_end(k) if len(self.d) > self.cap: self.d.popitem(last=False) # 맨 앞(가장 오래된) 항목 제거 c2 = LRU2(3) for k, v in [(1, 'A'), (2, 'B'), (3, 'C')]: c2.put(k, v) c2.get(1) c2.put(4, 'D') print(list(c2.d)) # [3, 1, 4] (오래된 → 최근) from functools import lru_cache @lru_cache(maxsize=2) def sq(n): return n * n sq(1); sq(2); sq(1); sq(3) print(sq.cache_info()) # CacheInfo(hits=1, misses=3, maxsize=2, currsize=2)
파이썬의 OrderedDict는 키의 순서를 기억하고 move_to_end와 popitem(last=False)를 O(1)에 지원해서, 위 코드처럼 해시맵과 순서 리스트를 한 객체로 대신합니다. 여기서는 뒤쪽이 최근이고 앞쪽이 가장 오래된 쪽입니다. 시각화와 방향만 반대이고 같은 동작입니다. 표준 라이브러리의 functools.lru_cache는 함수 호출 결과를 같은 정책으로 캐시하는 데코레이터입니다. maxsize=2로 sq(1), sq(2), sq(1), sq(3)을 호출하면 두 번째 sq(1)만 적중(hit)하고 나머지 3번은 미스이며, 세 번째 서로 다른 값이 들어올 때 가장 오래 쓰지 않은 sq(2)가 밀려납니다.
Java — LinkedHashMap으로 LRU 캐시
import java.util.LinkedHashMap; import java.util.Map; class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int capacity; LRUCache(int capacity) { super(16, 0.75f, true); // true: 접근 순서(access-order)로 유지 this.capacity = capacity; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; // 넘치면 가장 오래된 항목을 제거 } } LRUCache<Integer, String> cache = new LRUCache<>(3); cache.put(1, "A"); cache.put(2, "B"); cache.put(3, "C"); cache.get(1); cache.put(4, "D"); System.out.println(cache.keySet()); // [3, 1, 4] System.out.println(cache.containsKey(2)); // false
자바의 LinkedHashMap은 해시 테이블과 이중 연결 리스트를 함께 가진 맵이고, 생성자의 세 번째 인자를 true로 주면 항목을 조회하거나 넣을 때마다 그 항목을 순서의 맨 끝으로 옮기는 접근 순서 모드가 됩니다. 앞의 직접 구현이 하는 일을 라이브러리가 대신하는 셈입니다. put이 새 항목을 넣은 직후 호출되는 removeEldestEntry가 true를 돌려주면 가장 오래된 항목이 제거되므로, size가 capacity를 넘는지만 검사하면 용량 제한이 완성됩니다. 1, 2, 3을 넣고 1을 조회한 뒤 4를 넣으면 2가 제거되어 키 순서가 3, 1, 4가 됩니다. 이 클래스는 스레드에 안전하지 않습니다. 여러 스레드가 함께 쓰려면 바깥에서 동기화하거나 Caffeine 같은 동시성 캐시 라이브러리를 씁니다.
Q1
LRU 캐시를 조회·삽입·제거 모두 O(1)에 구현하는 대표적인 자료구조 조합은?
Q2
LRU 캐시에서 항목을 조회(get)했을 때 해야 할 일은?
Q3
LRU 캐시가 용량을 초과했을 때 제거되는 항목은?