[2026] C++ LRU 캐시 알고리즘 완벽 가이드 | Least Recently Used·O(1) 구현

[2026] C++ LRU 캐시 알고리즘 완벽 가이드 | Least Recently Used·O(1) 구현

이 글의 핵심

LRU(Least Recently Used) 캐시 교체 정책을 C++로 구현하는 법. unordered_map과 list로 get·put O(1), splice로 최근 사용 갱신, 용량 초과 시 eviction, 흔한 반복자 실수와 스레드 안전성까지.

LRU 알고리즘이란?

LRU(Least Recently Used)는 고정 크기 캐시가 꽉 찼을 때 가장 오랫동안 “사용되지 않은” 항목을 제거(evict) 하는 규칙입니다.

  • 조회(get) 하거나 갱신(put) 하면 그 키는 “방금 사용됨” → 가장 최근 쪽으로 옮깁니다.
  • 용량을 넘기면 가장 오래된(맨 뒤) 항목을 지웁니다. 운영체제 페이지 캐시, HTTP 프록시, DB 버퍼 풀, Redis의 allkeys-lru 정책, CPU/앱 레벨 객체 캐시 등에서 같은 아이디어가 쓰입니다.

왜 “해시 + 이중 연결 리스트”인가?

연산요구사항단독 unordered_map단독 list
키로 값 찾기O(1) 평균❌ O(n)
임의 키 삭제O(1)
“최근 사용” 순서 유지·맨 앞 이동O(1)✅ (splice)
둘을 함께 쓰면:
  • std::list — 노드 순서 = MRU(맨 앞) … LRU(맨 뒤).
  • std::unordered_map<K, list::iterator> — 키로 리스트 노드 위치를 바로 찾음. 리스트에서 노드를 떼어 맨 앞으로 붙이는 splice 는 포인터만 바꾸므로 O(1) 이고, 해당 키의 map 항목은 같은 반복자를 가리키므로 map을 수정할 필요가 없습니다. 아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
flowchart LR
  subgraph list["std list (앞=MRU, 뒤=LRU)"]
    A[(k3,v3)] --> B[(k1,v1)] --> C[(k2,v2)]
  end
  subgraph map[std unordered_map]
    k3 --> A
    k1 --> B
    k2 --> C
  end

완전한 C++ 구현 (템플릿)

Kstd::hash<K>operator== 가 가능해야 합니다. 값 타입 V는 복사·이동 가능하다고 가정합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <cstddef>
#include <list>
#include <optional>
#include <stdexcept>
#include <unordered_map>
#include <utility>
template <typename K, typename V, typename Hash = std::hash<K>>
class LRUCache {
public:
  explicit LRUCache(std::size_t capacity) : capacity_(capacity) {
    if (capacity_ == 0) {
      throw std::invalid_argument("LRUCache: capacity must be > 0");
    }
  }
  /** 키가 없으면 std::nullopt */
  std::optional<V> get(const K& key) {
    auto it = index_.find(key);
    if (it == index_.end()) return std::nullopt;
    // 최근 사용: 해당 노드를 맨 앞으로
    items_.splice(items_.begin(), items_, it->second);
    return it->second->second;
  }
  void put(const K& key, V value) {
    auto it = index_.find(key);
    if (it != index_.end()) {
      it->second->second = std::move(value);
      items_.splice(items_.begin(), items_, it->second);
      return;
    }
    if (items_.size() >= capacity_) {
      evict_lru();
    }
    items_.emplace_front(key, std::move(value));
    index_[key] = items_.begin();
  }
  std::size_t size() const noexcept { return items_.size(); }
  std::size_t capacity() const noexcept { return capacity_; }
private:
  using Item = std::pair<K, V>;
  using ItemList = std::list<Item>;
  using Iterator = typename ItemList::iterator;
  void evict_lru() {
    if (items_.empty()) return;
    const K& victim = items_.back().first;
    index_.erase(victim);
    items_.pop_back();
  }
  std::size_t capacity_;
  ItemList items_;
  std::unordered_map<K, Iterator, Hash> index_;
};

사용 예시

아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <iostream>
#include <string>
int main() {
  LRUCache<int, std::string> cache(2);
  cache.put(1, "a");
  cache.put(2, "b");
  cache.get(1);           // 1이 최근 사용
  cache.put(3, "c");      // 용량 2 → LRU인 키 2 제거
  std::cout << cache.get(2).has_value() << '\n';  // 0 (없음)
  std::cout << cache.get(1).value() << '\n';      // a
  std::cout << cache.get(3).value() << '\n';      // c
}

동작을 단계로 따라가기 (put)

  1. 키가 이미 있으면 → 값만 갱신하고 splice로 맨 앞.
  2. 없고 자리가 있으면 → emplace_frontindex_[key] = begin().
  3. 없고 꽉 찼으면 → 맨 뒤(LRU) 키를 index_items_에서 제거 → 새 항목을 앞에 삽입. get 은 값을 찾으면 반드시 splice로 맨 앞으로 옮겨야 “최근 사용”이 맞습니다.

시간 복잡도

  • get / put (기존 키) — 해시 탐색 평균 O(1), splice O(1).
  • put (신규 키, eviction) — LRU 하나 pop_back + map erase O(1) 평균. 해시 충돌이 많아지면 unordered_map 이 한 버킷에 몰려 최악에 가까워질 수 있으므로, 키가 많으면 reserve(capacity_) 로 재할당을 줄이는 것이 좋습니다.
// 생성자에서
index_.reserve(capacity_ * 2);  // 부하율 여유

일상 비유로 이해하기: 시간 복잡도는 책에서 원하는 페이지를 찾는 방법으로 비유할 수 있습니다. O(1)은 목차를 보고 바로 찾기, O(log n)은 이진 탐색처럼 중간을 펼쳐 앞뒤 판단하기, O(n)은 첫 페이지부터 하나씩 넘기기, O(n²)은 모든 페이지 조합을 확인하기입니다.

흔한 실수

1. splice 없이 “순서만” 바꾸려 함

맵만 갱신하고 리스트 순서를 안 바꾸면 LRU 판별이 틀어집니다. get/put 모두에서 최근 사용이면 맨 앞으로 옮겨야 합니다.

2. 잘못된 반복자 저장

list가 재할당으로 노드 주소가 바뀌는 컨테이너는 아니지만, 해당 노드를 erase한 뒤 그 반복자를 map에 남기면 UB입니다. eviction 시에는 맨 뒤 노드의 키로 map만 먼저 지우고 pop_back 순서를 지키세요.

3. 용량 0

위 구현은 생성자에서 예외를 던집니다. “0이면 캐시 비활성” 같은 정책이면 별도 분기가 필요합니다.

4. 스레드 안전성

위 클래스는 스레드 안전하지 않습니다. 여러 스레드에서 쓰려면 std::mutexget/put 전체를 감싸거나, 샤딩된 캐시 등을 검토하세요.

다른 정책과 한 줄 비교

정책제거 대상구현 난이도(개략)
LRU가장 오래 사용 안 함해시 + 리스트 O(1)
FIFO먼저 들어온 것큐 + 맵 가능
LFU참조 횟수 최소힙·추가 맵 등으로 무거워지기 쉬움
실무에서는 LRU 또는 TTL + LRU 조합이 많습니다.

정리

  • LRU는 최근성을 기준으로 캐시를 비우는 대표 정책입니다.
  • C++에서는 std::list + std::unordered_map<키, list::iterator> 가 정석 패턴입니다.
  • splice 로 MRU 갱신, 맨 뒤 제거로 LRU eviction 을 O(1)에 가깝게 유지합니다. 더 넓은 맥락(커스텀 자료구조 시리즈, greedy와의 관계)은 relatedPosts에 연결된 글을 참고하면 좋습니다.

관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3