에듀테크 프로젝트 대시보드
LRU 캐시
캐시 용량은 항상 유한하다. 꽉 찼을 때 "가장 오래 안 쓴 항목"부터 내보내는 전략이 LRU다. 이걸 get·put 둘 다 O(1)에 해내려면 자료구조 하나로는 부족하다 — 해시맵과 이중 연결 리스트가 서로의 약점을 메워주는 합작품이 필요하다.
웹 서버가 DB 조회 결과를 메모리에 캐싱한다고 하자. 캐시 용량이 3개뿐인데 계속 새 키가 들어온다면? 가장 최근에 쓴 것들만 남기고, 가장 오래 안 쓴 것부터 버리는 게 합리적이다 — 최근 쓴 데이터가 곧 다시 쓰일 확률이 높다는 경험칙(시간 지역성, temporal locality) 때문이다. 조건은 두 가지: get(key)로 값을 꺼내면서 "방금 썼다"고 갱신하고, put(key, value)로 새로 넣거나 갱신하고, 용량 초과 시 가장 오래 안 쓴 항목을 자동으로 내쫓는다. 그리고 이 모든 게 데이터 개수와 무관하게 빨라야(O(1)) 실용적이다.
후보 자료구조를 하나씩만 써 보면 바로 벽에 부딪힌다.
키로 값을 찾는 건 평균 O(1)로 완벽하다. 하지만 "지금 가장 오래 안 쓴 키가 뭐지?"를 알아내려면 사용 시각을 전부 훑어야 한다. 게다가 해시맵 자체는 삽입 순서·접근 순서를 보장하지 않는다(구현에 따라 다름).
→ 축출 대상을 O(1)에 못 찾는다
맨 앞을 "최근 사용", 맨 뒤를 "오래됨"으로 유지하는 건 쉽다. 노드를 이미 손에 쥐고 있으면 앞으로 옮기는 것도 O(1). 하지만 특정 키의 노드를 찾으려면 처음부터 next를 따라가는 O(n) 순회가 필요하다.
→ 키로 노드를 O(1)에 못 찾는다
둘을 같이 쓰면 서로의 구멍이 정확히 메워진다.
해시맵 — 키 → 리스트 노드 참조를 저장. "이 키 어딨어?"에 O(1)로 답한다.
이중 연결 리스트 — 노드를 사용 순서대로 유지. 맨 앞(head)=가장 최근 사용(MRU), 맨 뒤(tail)=가장 오래 안 씀(LRU). prev/next 포인터 덕에 노드를 이미 알면 임의 위치에서 O(1) 제거·삽입이 가능하다.
접근할 때마다 — get이든 put이든, 해시맵으로 노드를 O(1)에 찾은 뒤 그 노드를 리스트 맨 앞으로 옮긴다(O(1), 이미 위치를 아니까). 용량 초과 시 tail(LRU) 노드를 떼어내고 해시맵에서도 그 키를 지운다 — 둘 다 O(1).
용량을 정하고 put(key, value)·get(key)를 실행해 보자. 위쪽은 이중 연결 리스트(맨 왼쪽 = MRU, 맨 오른쪽 = LRU), 아래쪽은 해시맵이다. 점선 화살표가 "이 키는 지금 이 노드를 가리킨다"를 보여준다.
파이썬에서는 collections.OrderedDict가 "삽입 순서를 기억하는 딕셔너리"라 move_to_end와 popitem(last=False)만으로 LRU를 몇 줄에 구현할 수 있다.
from collections import OrderedDict class LRUCache: def __init__(self, capacity: int): self.capacity = capacity self.cache = OrderedDict() # 삽입/접근 순서를 기억하는 dict def get(self, key) -> int: if key not in self.cache: return -1 # 미스 self.cache.move_to_end(key) # 방금 썼으니 맨 뒤(=MRU)로 return self.cache[key] def put(self, key, value): if key in self.cache: self.cache.move_to_end(key) # 기존 키도 접근 취급 self.cache[key] = value if len(self.cache) > self.capacity: self.cache.popitem(last=False) # 맨 앞 = LRU 축출
OrderedDict 사용을 금지하고 해시맵 + 이중 연결 리스트를 직접 구현하라고 요구하는 경우가 많다. 더미(dummy) head/tail 노드 두 개를 만들어 두면 "빈 리스트"·"노드 1개"같은 경계 조건을 따로 처리하지 않아도 되어 코드가 훨씬 깔끔해진다 — _remove(node)로 떼어내고 _add_front(node)로 head 바로 뒤에 붙이는 두 헬퍼만 있으면 get/put이 몇 줄로 끝난다.
캐싱 전략 — 캐싱 전략(LRU/LFU 비교, 적중률)에서 LRU를 다른 축출 정책과 비교한다.
연결 리스트 — 연결 리스트에서 싱글/더블/원형 구조와 포인터 조작을 먼저 익히면 좋다.
해시테이블 — 해시테이블에서 해싱과 충돌 처리를 다룬다.