컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
ds · 16

LRU 캐시 구현

해시맵캐시LRU

캐시는 작고 빠른 저장소라서 모든 데이터를 담을 수 없고, 가득 차면 무엇을 버릴지 정해야 합니다. 그 기준으로 널리 쓰이는 것이 LRU(Least Recently Used)입니다. 가장 오래 사용되지 않은 항목을 버린다는 뜻으로, 최근에 쓴 데이터는 곧 다시 쓰일 가능성이 높다는 시간 지역성(temporal locality)에 기대는 정책입니다.

LRU 정책을 말로만 설명하면 쉽지만, 조회와 삽입, 제거를 모두 O(1)에 하려면 자료구조를 골라야 합니다. 표준적인 구현은 해시맵과 이중 연결 리스트의 조합입니다. 해시맵은 키로 노드를 O(1)에 찾고, 이중 연결 리스트는 사용 순서를 담아 노드를 O(1)에 떼어 맨 앞으로 옮기거나 맨 뒤를 제거합니다. 이 토픽에서는 두 구조가 함께 움직이는 모습을 단계별로 봅니다.

핵심 개념

LRU 정책

용량이 가득 찼을 때 가장 오래 사용되지 않은 항목을 제거합니다. 기준은 삽입 순서가 아니라 마지막으로 사용한 시점입니다.

핵심 개념

해시맵

키에서 리스트 노드로 가는 길을 만들어, 항목을 찾는 데 리스트를 훑지 않아도 되게 합니다. 평균 O(1)로 조회합니다.

핵심 개념

이중 연결 리스트

앞쪽이 가장 최근, 뒤쪽이 가장 오래된 항목인 순서를 유지합니다. 노드가 앞뒤 포인터를 모두 가져 중간 노드도 O(1)에 떼어 낼 수 있습니다.

핵심 개념

조회 = 맨 앞으로 이동

get으로 항목을 쓰면 그 노드를 떼어 리스트 맨 앞으로 옮깁니다. 값을 갱신하는 put도 사용으로 보고 같은 방식으로 처리합니다.

핵심 개념

용량 초과 시 맨 뒤 제거

새 키를 넣을 때 가득 차 있으면 리스트 맨 뒤 노드를 제거하고 해시맵에서도 그 키를 지웁니다. 둘 중 하나만 지우면 불일치가 생깁니다.

핵심 개념

더미 노드

값이 없는 head와 tail 노드를 양 끝에 두면, 리스트가 비었거나 노드가 하나일 때의 예외 처리 없이 같은 코드로 삽입과 삭제를 합니다.

get과 put이 모두 평균 O(1)입니다 — 해시맵 조회, 리스트에서 노드 떼기, 맨 앞 삽입, 맨 뒤 제거가 각각 O(1)이기 때문입니다. 해시 충돌이 심하면 해시맵 쪽이 느려질 수 있어 "평균"입니다.
단일 구조로는 O(1)이 안 됩니다 — 배열이나 단일 연결 리스트만으로는 찾기나 순서 변경 중 하나가 O(n)입니다. 일반적인 해시맵은 사용 순서를 알지 못하고 리스트는 찾기가 느려서 둘을 합칩니다.
공간은 O(용량)입니다 — 항목마다 노드 하나와 해시맵 항목 하나만 가지며, 용량을 넘기지 않으므로 메모리가 일정하게 유지됩니다.
FIFO와 다릅니다 — FIFO는 먼저 들어온 항목을 버리지만 LRU는 오래 쓰지 않은 항목을 버립니다. 자주 조회되는 오래된 항목은 LRU에서 살아남습니다.
이 시각화에서 확인할 것 — "구조" 탭에서 해시맵과 리스트가 함께 채워지는 모양을, "조회" 탭에서 조회한 노드가 맨 앞으로 이동하는 과정을, "제거" 탭에서 가득 찼을 때 맨 뒤(빨간색)가 제거되는 과정을 STEP으로 확인하세요.

LRU의 배경

LRU는 가상 메모리의 페이지 교체 알고리즘으로 일찍부터 연구되었습니다. 1966년 벨레이디(Belady)는 가상 기억 장치 컴퓨터의 여러 교체 알고리즘을 다룬 논문을 발표했고, 이 연구에서 앞으로 가장 늦게 쓰일 페이지를 버리는 최적 알고리즘이 비교 기준으로 쓰입니다. 미래를 알 수 없으므로 과거의 사용 이력으로 미래를 짐작하는 LRU가 실용적인 대안이 되었고, 지금은 운영체제의 페이지 교체, 데이터베이스 버퍼 풀, 애플리케이션 캐시에서 LRU 또는 그 변형이 쓰입니다.

숫자로 확인하기

조회 — 용량 3인 캐시에 키 1, 2, 3을 넣으면 리스트는 3, 2, 1 순서입니다. get(1)을 하면 노드 1이 맨 앞으로 이동해 순서가 1, 3, 2가 됩니다. 노드 하나를 떼어 붙이는 일이라 항목 수와 무관하게 O(1)입니다.
제거 — 그 상태에서 put(4, D)를 하면 맨 뒤인 키 2가 제거되고 순서가 4, 1, 3이 됩니다. 삽입 순서(FIFO)였다면 먼저 들어온 키 1이 제거됐을 것입니다.
파이썬 확인 — maxsize=2인 lru_cache로 sq(1), sq(2), sq(1), sq(3)을 호출하면 적중 1번, 미스 3번이고 항목은 2개만 남습니다.
자바 확인 — 접근 순서 LinkedHashMap에 용량 3으로 1, 2, 3을 넣고 1을 조회한 뒤 4를 넣으면 키 순서가 3, 1, 4이고 2는 사라집니다.

자주 하는 오해

조회만 하면 순서가 안 바뀐다 — LRU에서는 조회도 사용입니다. get할 때 노드를 맨 앞으로 옮기지 않으면 자주 조회되는 항목이 먼저 제거되는 버그가 생깁니다.
해시맵만으로 충분하다 — 해시맵은 가장 오래된 항목이 무엇인지 모릅니다. 순서를 유지하는 이중 연결 리스트가 있어야 제거 대상을 O(1)에 찾습니다.
단일 연결 리스트로도 된다 — 해시맵으로 중간 노드를 찾아도 단일 연결 리스트는 이전 노드를 몰라서 떼어 내려면 앞에서부터 찾아야 합니다. 이전 노드를 따로 기록하는 우회도 있지만, 이전 포인터가 있는 이중 연결 리스트가 가장 단순합니다.
LRU가 항상 최적이다 — 한 번 훑고 지나가는 대량 스캔 같은 접근 패턴에서는 오히려 쓸모 있는 항목을 밀어낼 수 있습니다. 그래서 실제 시스템은 LRU를 변형하거나 근사해서 씁니다.

면접에서 이렇게 물어요

면접 질문

LRU 캐시를 O(1)로 구현하려면?

해시맵은 키로 노드를 찾고 이중 연결 리스트는 사용 순서를 유지합니다. get은 노드를 맨 앞으로 옮기고, put은 가득 찼을 때 맨 뒤 노드를 리스트와 해시맵에서 지운 뒤 맨 앞에 넣습니다.

면접 질문

왜 이중 연결 리스트인가요?

해시맵으로 찾은 중간 노드를 O(1)에 떼어 내려면 앞 노드의 포인터를 고쳐야 하므로 앞 노드를 바로 알아야 합니다. 이전 포인터를 가진 이중 연결 리스트가 이를 가장 단순하게 가능하게 합니다.

면접 질문

LRU와 LFU, FIFO의 차이는?

FIFO는 들어온 순서, LRU는 마지막 사용 시점, LFU(Least Frequently Used)는 사용 횟수가 기준입니다. LRU는 최근성, LFU는 빈도를 중시합니다.

면접 질문

멀티스레드에서는 어떻게 하나요?

get도 리스트를 바꾸므로 읽기 연산이어도 동기화가 필요합니다. 전체에 락을 거는 방법, 샤딩처럼 락 범위를 줄이는 방법, 직접 만들기보다 동시성을 지원하는 캐시 라이브러리를 쓰는 방법이 있습니다.

실제 시스템에서는

운영체제 페이지 교체 — 메모리가 부족할 때 쫓아낼 페이지를 고르는 정책으로 LRU와 그 근사가 쓰입니다. 하드웨어가 페이지 접근 때 켜 주는 접근 비트(참조 비트)를 이용해 LRU를 근사하는 방식(예: 클럭 알고리즘)을 많은 시스템이 씁니다.
Redis — maxmemory 정책에 allkeys-lru, volatile-lru가 있으며, 정확한 LRU 대신 표본을 뽑아 근사하는 방식으로 구현됩니다.
데이터베이스 버퍼 풀 — MySQL InnoDB의 버퍼 풀은 LRU 리스트를 변형해 쓰며, 한 번 읽고 마는 스캔이 캐시를 덜 밀어내도록 새 페이지를 리스트 중간 지점에 삽입합니다.
언어 표준 라이브러리 — 파이썬의 functools.lru_cache, 자바의 LinkedHashMap 접근 순서 모드가 LRU를 쉽게 쓰게 해 줍니다.

함께 보면 좋은 토픽

해시 테이블 — LRU가 키 조회에 쓰는 해시맵의 원리를 봅니다.
배열과 연결 리스트 — 이중 연결 리스트의 삽입과 삭제를 봅니다.
가상 메모리 — LRU가 페이지 교체 정책으로 쓰이는 곳을 봅니다.
캐시 메모리 — 시간 지역성과 캐시 교체를 하드웨어 관점에서 봅니다.

참고 자료

논문 — Belady, L. A., "A study of replacement algorithms for a virtual-storage computer", IBM Systems Journal 5(2), 1966
공식 문서 — Redis Key eviction
공식 문서 — MySQL InnoDB Buffer Pool
공식 문서 — Python functools.lru_cache
공식 문서 — Java LinkedHashMap

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)에 구현하는 대표적인 자료구조 조합은?

빠른 조회와 빠른 순서 변경이 모두 필요합니다.
해시맵은 키로 노드를 O(1)에 찾고, 이중 연결 리스트는 노드를 O(1)에 맨 앞으로 옮기거나 맨 뒤에서 제거할 수 있어 둘의 조합으로 LRU를 O(1)에 구현합니다.

Q2

LRU 캐시에서 항목을 조회(get)했을 때 해야 할 일은?

"최근에 사용됨"을 표시해야 합니다.
LRU는 가장 오래 사용되지 않은 항목을 제거하는 정책이므로, 조회한 항목은 "방금 사용됨"으로 표시하기 위해 리스트의 맨 앞으로 옮깁니다.

Q3

LRU 캐시가 용량을 초과했을 때 제거되는 항목은?

Least Recently Used, 즉 "가장 덜 최근에 쓴 것"입니다.
리스트 맨 뒤에는 가장 오랫동안 사용되지 않은 항목이 있으므로 용량 초과 시 그 노드를 제거하고 해시맵에서도 지웁니다. 삽입 순서가 아니라 사용 순서 기준이라는 점이 FIFO와 다릅니다.