C#으로 구현한 최소한의 LRU(Least Recently Used) 캐시. 모든 핵심 연산이 O(1)이다.
정석적인 hashmap + 이중 연결 리스트 조합이다.
LinkedList<(TKey, TElement)> timeline— 앞쪽이 MRU(most recently used), 뒤쪽이 LRUDictionary<TKey, LinkedListNode<...>> index— 키에서 노드로 O(1) 조회
조회하면 해당 노드를 리스트 맨 앞으로 옮기고, 용량이 넘치면 맨 뒤 노드를 축출한다.
축출할 때 떼어낸 LinkedListNode 인스턴스를 버리지 않고 값만 덮어써서 재사용한다.
while (this.timeline.Count >= this.MaxCount)
{
node = this.timeline.Last!; // 캐시 크기가 넘치면, 기존의 node를 재활용한다.
this.timeline.RemoveLast();
this.index.Remove(node.Value.Key);
}
if (node != null)
{
node.Value = (key, element);
}
else
{
node = new LinkedListNode<(TKey, TElement)>((key, element));
}흔한 예제 구현은 AddFirst((key, value))로 매번 새 노드를 할당하지만, 여기서는 캐시가 만원인 정상 상태에서 노드 할당이 0이라 GC 압력이 없다.
Program.cs의 마지막 테스트가 GC.GetAllocatedBytesForCurrentThread()로 이를 실측한다. 가득 찬 캐시에 1000회 삽입했을 때 할당량은 정확히 0바이트다.
LinkedList.Remove 계열이 노드의 List 참조를 끊어주기 때문에, 떼어낸 노드를 AddFirst(node)로 다시 붙이는 것이 유효하다.
대부분의 구현은 덮어쓰기만 하는 Put 하나를 제공하지만, 여기서는 두 가지로 나눴다. 둘 다 timeline(최근 사용 순서)은 갱신하며, 반환값은 "새로 삽입되었는가"이다.
| 메서드 | 키가 없을 때 | 키가 이미 있을 때 |
|---|---|---|
Insert |
삽입하고 true |
값을 유지하고 false |
Upsert |
삽입하고 true |
값을 덮어쓰고 false |
var cache = new LruCache<int, string>(maxCount: 100);
cache.Insert(1, "a"); // true (신규)
cache.Insert(1, "b"); // false (값은 "a" 유지)
cache.Upsert(1, "c"); // false (값이 "c"로 변경)
if (cache.TryGetValue(1, out var v)) // 조회 성공 시 MRU로 이동
{
// ...
}
cache.Remove(1);
int count = cache.Count; // 현재 보관 개수
int max = cache.MaxCount; // 최대 용량
var values = cache.Values; // MRU -> LRU 순서IEqualityComparer<TKey>를 받는 생성자 오버로드도 있다.
Program.cs가 외부 테스트 프레임워크 없이 도는 자체 검증 러너다. 파일을 한 디렉터리에 받고 실행하면 된다.
dotnet run -c Release각 검증 항목이 PASS/FAIL로 출력되고, 하나라도 실패하면 종료 코드 1을 돌려준다. 검증 범위는 다음과 같다.
- 용량 유지와 LRU 순서 축출
TryGetValue의 touch 효과 (조회한 항목이 축출을 피하는지)Insert(값 유지) 대Upsert(값 덮어쓰기)Remove, 없는 키 조회/삭제, 삭제한 키의 재삽입IEqualityComparer<TKey>오버로드- 노드 재활용 시 할당 0바이트
net8.0을 타깃하지만 RollForward를 걸어둬서 상위 런타임만 있어도 실행된다.
아래 참고 링크의 GFG 원문은 세 가지 접근(배열 / 해시 + 힙 / 해시맵 + 이중 연결 리스트)을 소개하고 마지막을 최적해로 제시한다. 이 구현은 그 최적해와 자료구조와 복잡도가 동일하다. 조회와 삽입 모두 O(1), 공간은 O(capacity). 갈리는 지점은 네 가지다.
원문은 축출할 때 tail 노드를 "제거"하고 새 항목을 head에 "삽입"한다고만 기술한다. 이 구현은 축출한 LinkedListNode 인스턴스를 재사용하므로 캐시가 만원인 정상 상태에서 할당이 0이다. Program.cs가 이를 실측한다.
원문의 put은 중복 키면 값을 덮어쓴다. 이 구현은 둘로 나뉘고, Insert는 원문 put과 동작이 다르다.
| 키가 없을 때 | 키가 이미 있을 때 | |
|---|---|---|
원문 put |
삽입 | 값을 덮어씀 |
Insert |
삽입 | 값을 유지 |
Upsert |
삽입 | 값을 덮어씀 (원문 put과 동일) |
값 갱신을 기대하고 Insert를 쓰면 조용히 실패하므로 주의할 것.
원문 get은 키를 찾지 못하면 -1을 돌려준다. 값이 정수일 때만 통하는 관례다. 이 구현은 bool 반환 + out 파라미터의 TryGetValue 패턴이라 값 타입에 제약이 없다.
원문은 이중 연결 리스트를 직접 구현한다. 여기서는 .NET의 LinkedList<T>를 쓴다. Remove(node)가 O(1)이라 복잡도는 같고, 노드가 리스트에서 떨어질 때 내부 List 참조가 끊기는 덕분에 위의 노드 재활용이 성립한다.
정리하면 알고리즘은 원문과 같고, 할당 최적화와 API 설계에서 갈린다.
실전 캐시 라이브러리(BitFaster.Caching 등)를 대체하려는 코드가 아니다. 다음이 없다.
- 스레드 안전성. 읽기처럼 보이는
TryGetValue도 리스트를 재정렬하므로, 동시 접근하려면 외부 락이 필요하다. - 축출 콜백. 캐시 아웃되는 요소를 알 방법이 없어서
IDisposable값을 담으면 정리할 수 없다. - TTL / 만료. 용량 기반 축출만 한다.
Clear(),Keys,ContainsKey.MaxCount검증. 0 이하로 생성하면 첫 삽입에서 예외가 난다.
또한 Values는 지연 평가되는 IEnumerable이라, 순회 도중 TryGetValue를 호출하면 컬렉션이 변경되어 예외가 발생한다. 순회 중 조회가 필요하면 먼저 ToArray()로 스냅샷을 뜰 것.
네임스페이스는 LruCacheSample로 두었다. 가져다 쓸 때는 프로젝트에 맞게 바꾸면 된다.