LRU Cache — 개념, 제거 알고리즘 및 작동 방식

저자: IT Sectr 게시일: 2026-06-12 읽는 시간: 8 분

LRU Cache(Least Recently Used Cache)는 캐시 크기가 한계에 도달했을 때 가장 오랫동안 사용되지 않은 요소를 제거하는 캐싱 알고리즘입니다. 읽기 또는 쓰기 시마다 요소는 큐의 앞쪽으로 이동하며, 오버플로우 발생 시 끝의 요소가 제거됩니다. Android Developers 문서(2026)에 따르면, Android의 LruCache는 access-order 모드의 LinkedHashMap을 사용하며 get 및 put 작업에 O(1) 복잡도를 제공합니다.

핵심 사항

  • LRU Cache — “가장 최근에 사용되지 않은” 원칙에 따라 요소를 제거하는 캐싱 알고리즘
  • 복잡도 get 및 put 작업의 O(1)은 HashMap + Doubly Linked List로 구현 시 달성됨
  • Access-order — 각 액세스 시 요소가 앞쪽으로 이동하며, 제거는 끝에서 발생
  • 적용 — 이미지, 네트워크 요청, 계산 결과 및 데이터베이스 데이터 캐싱
  • Android LruCache — android.util 패키지의 즉시 사용 가능한 구현, maxSize 지원 및 스레드 안전

LRU Cache란?

LRU Cache(Least Recently Used Cache)는 제한된 수의 요소를 저장하고 가장 적게 액세스된 요소를 자동으로 제거하는 고정 크기 데이터 구조입니다. 애플리케이션이 요소를 요청하면 해당 요소는 캐시의 “새로운” 부분으로 이동하고, 오랫동안 사용되지 않은 요소는 끝으로 밀려나 한계에 도달하면 제거됩니다.

“Least Recently Used”라는 이름은 제거 정책을 설명합니다: 저장된 모든 요소 중 가장 오랫동안 사용되지 않은 요소가 제거됩니다. 이는 참조의 지역성(locality of reference) 가정에 기반합니다 — 최근에 요청된 데이터는 다시 필요할 가능성이 매우 높습니다. 이것이 LRU가 대부분의 애플리케이션에 가장 효과적인 캐싱 전략 중 하나로 간주되는 이유입니다.

클래식 LRU Cache 구현에는 두 가지 데이터 구조가 필요합니다: 키로 모든 요소에 O(1) 액세스하기 위한 해시 테이블과 사용 순서를 추적하기 위한 이중 연결 리스트입니다. 해시 테이블은 리스트 노드에 대한 참조를 저장하고, 리스트는 가장 새로운 요소(헤드)에서 가장 오래된 요소(테일)까지 순서를 유지합니다.

LRU Cache 기본 작업

get(key) 작업은 키가 해시 테이블에 존재하는지 확인합니다. 요소가 발견되면 리스트의 헤드로 이동(가장 최신이 됨)하고 해당 값이 반환됩니다. 발견되지 않으면 null이 반환되거나 예외가 발생합니다. put(key, value) 작업은 새 요소를 삽입합니다: 키가 이미 존재하면 값이 업데이트되고 요소가 헤드로 이동합니다. 캐시가 가득 찬 경우, 삽입 전에 테일 요소가 제거됩니다. 모든 작업은 상수 시간 O(1)으로 실행됩니다.

LRU Cache 작동 방식

LRU Cache 알고리즘은 두 가지 원칙에 기반합니다: 시간 순서 액세스 카운팅과 오버플로우 시 제거 메커니즘입니다. 각 요소는 이중 연결 리스트 노드에 저장되며, 이러한 노드에 대한 포인터는 해시 테이블에 유지됩니다. 각 액세스 시 요소는 현재 위치에서 분리되어 리스트의 헤드에 삽입됩니다.

캐시 크기가 최대값(maxSize)에 도달하고 새 요소 삽입 요청이 들어오면, 알고리즘은 이중 연결 리스트의 테일 요소를 제거합니다 — 이것이 가장 최근에 사용되지 않은 요소입니다. 제거 후 새 요소를 위한 공간이 확보되며, 헤드에 삽입됩니다. 해시 테이블은 그에 따라 업데이트됩니다: 이전 키가 제거되고 새 키가 추가됩니다.

LRU의 특징은 순환 반복이 있는 액세스 패턴에 대한 민감성입니다. 애플리케이션이 캐시 크기보다 큰 데이터 세트를 주기적으로 액세스하는 경우, LRU는 스래싱(thrashing) — 각 새 요청이 이전 요청을 제거하는 빈번한 요소 교체 — 현상을 겪을 수 있습니다. 이러한 시나리오에서는 LFU(Least Frequently Used) 또는 적응형 알고리즘이 더 효과적일 수 있습니다.

캐시 크기 및 메트릭

LRU Cache 크기 선택은 메모리 소비와 히트율(성공적 액세스 비율) 간의 절충입니다. 모바일 애플리케이션의 일반적인 값: 이미지 캐시에 사용 가능한 메모리의 10~20%, 네트워크 응답 캐시에 50~200개 항목. 히트율 80~95%는 캐시가 메모리 비용을 정당화하는 좋은 지표로 간주됩니다. 모니터링을 위해 Android의 LruCache 구현에서 사용 가능한 hitCount 및 missCount 카운터가 사용됩니다.

LRU Cache 구현: HashMap + Doubly Linked List

표준 LRU Cache 구현은 해시 테이블과 이중 연결 리스트의 조합을 사용합니다. 해시 테이블은 키로 모든 노드에 O(1) 액세스를 제공하고, 이중 연결 리스트는 O(1)로 노드를 헤드로 이동하고 테일에서 제거할 수 있게 합니다. 중요한 점은 리스트가 이중 연결되어 있다는 것입니다: 이는 모든 요소를 반복하지 않고 리스트 중간에서 노드를 분리할 수 있게 합니다.

kotlin
class LruCache<K, V>(
    private val maxSize: Int
) {
    private val map = mutableMapOf<K, Node<V>>()
    private val head = Node<V>(null)
    private val tail = Node<V>(null)

    init {
        head.next = tail
        tail.prev = head
    }

    fun get(key: K): V? {
        val node = map[key] ?: return null
        removeNode(node)
        addToHead(node)
        return node.value
    }

    fun put(key: K, value: V) {
        map[key]?.let { node ->
            removeNode(node)
            node.value = value
            addToHead(node)
            return
        }
        if (map.size >= maxSize) {
            tail.prev?.let { toRemove ->
                removeNode(toRemove)
                removeKeyByValue(toRemove)
            }
        }
        val newNode = Node(value)
        addToHead(newNode)
    }
}

구현에서 각 노드(Node)는 값과 이전 및 다음 노드에 대한 참조를 저장합니다. 센티널 노드 head와 tail은 경계 케이스를 단순화합니다 — 삽입 및 제거 시 null을 확인할 필요가 없습니다. get 메서드는 찾은 노드를 헤드로 이동하고, put은 오버플로우 시 테일 요소를 제거합니다. 별도의 메서드 removeKeyByValue는 노드 참조로 해시 테이블에서 키를 찾아 제거합니다.

Android의 내장 LruCache 구현

Android SDK는 android.util 패키지에 즉시 사용 가능한 LruCache 클래스를 제공하며, access-order 모드에서 LinkedHashMap을 사용하여 LRU 알고리즘을 구현합니다. 이 클래스는 스레드 안전하며, hit/miss 카운팅을 지원하고, 요소 제거 시 리소스 정리를 위한 entryRemoved 콜백을 제공합니다. 캐시 크기는 임의 단위(바이트, 요소 수)로 설정됩니다 — sizeOf 메서드를 재정의하기만 하면 됩니다.

LRU Cache vs FIFO 및 LIFO

세 알고리즘 — LRU, FIFO 및 LIFO — 모두 동일한 문제를 해결합니다: 오버플로우 시 요소를 제거하여 메모리 소비를 제한합니다. 그러나 희생자 선택에 근본적으로 다른 기준을 사용하며, 이는 다양한 시나리오에서의 효과를 결정합니다.

매개변수LRUFIFOLIFO
제거 기준가장 최근에 사용되지 않음먼저 추가됨마지막에 추가됨
데이터 구조HashMap + 이중 연결 리스트큐(Queue)스택(Stack)
복잡도 get/putO(1)O(1)O(1)
패턴 저항성높음중간낮음
일반적 사용이미지 및 데이터 캐시스트림 버퍼링실행 취소(undo)

FIFO는 액세스 빈도와 관계없이 삽입 시간 기준으로 가장 오래된 요소를 제거합니다. 이는 오래된 요소가 여전히 관련 있는 경우 비효율적일 수 있습니다. LRU는 액세스 패턴을 고려하여 이 단점을 피합니다. LIFO는 가장 최근에 추가된 요소를 제거합니다 — 실행 취소 시나리오에는 유용하지만, 새 데이터가 이전 데이터보다 더 필요한 경우가 많기 때문에 캐싱에는 부적합합니다. LRU는 대부분의 애플리케이션에서 구현 복잡성과 히트율 간의 최적 균형으로 간주됩니다.

LRU Cache 코드 예제

다운로드한 이미지를 캐싱하기 위해 Android SDK의 내장 LruCache 클래스 사용을 살펴보겠습니다. 예제는 애플리케이션의 사용 가능한 메모리의 1/8로 캐시를 초기화하는 방법을 보여주며, 이는 이미지 캐싱에 대한 Google의 표준 권장 사항입니다.

kotlin
import android.util.LruCache

class ImageCache(context: Context) {
    private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
    private val cacheSize = maxMemory / 8

    private val lruCache = object : LruCache<String, Bitmap>(cacheSize) {
        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            return bitmap.rowBytes * bitmap.height / 1024
        }
    }

    fun getBitmap(key: String): Bitmap? {
        return lruCache.get(key)
    }

    fun putBitmap(key: String, bitmap: Bitmap) {
        lruCache.put(key, bitmap)
    }
}

sizeOf 메서드는 cacheSize가 지정된 동일한 단위로 요소 크기를 반환합니다. 여기서는 킬로바이트 단위의 Bitmap 크기가 사용됩니다(rowBytes × height / 1024). 모든 요소의 sizeOf 합계가 cacheSize를 초과하면 LruCache는 자동으로 가장 최근에 사용되지 않은 Bitmap을 제거합니다. entryRemoved 콜백은 bitmap.recycle() 호출에 사용할 수 있습니다 — 제거 전 메모리를 해제합니다.

Swift에서 LRU Cache 구현

iOS에는 내장 LRU Cache 클래스가 없지만, NSCache(유사하지만 문서화되지 않은 제거 정책 사용) 또는 Dictionary + 이중 연결 리스트를 사용한 사용자 정의 구현을 통해 쉽게 구현할 수 있습니다. 아래에 설명되어 있습니다.

swift
class LRUCache<Key: Hashable, Value> {
    private let maxSize: Int
    private var dict = [Key: Node<Value>]()
    private var head: Node<Value>?
    private var tail: Node<Value>?

    init(maxSize: Int) {
        self.maxSize = maxSize
    }

    func get(key: Key) -> Value? {
        guard let node = dict[key] else { return nil }
        moveToHead(node)
        return node.value
    }

    func put(key: Key, value: Value) {
        if let node = dict[key] {
            node.value = value
            moveToHead(node)
            return
        }
        if dict.count >= maxSize {
            tail.map { removeNode($0) }
        }
        let node = Node(value: value)
        dict[key] = node
        addToHead(node)
    }
}

이 Swift 구현에서 Node는 value, next 및 prev 필드가 있는 내부 클래스입니다. moveToHead 메서드는 노드를 현재 위치에서 분리하여 리스트의 헤드에 삽입합니다. 오버플로우 시 테일 — 가장 최근에 사용되지 않은 요소 — 가 제거됩니다. 프로덕션 환경에서는 NSLock 또는 DispatchQueue를 통한 스레드 안전성 추가가 권장됩니다.

자주 묻는 질문

LRU Cache와 단순 HashMap의 차이점은?

HashMap에는 크기 제한 메커니즘이 없습니다 — 메모리가 소진될 때까지 무한히 성장합니다. LRU Cache는 한계에 도달하면 제거 정책(가장 최근에 사용되지 않은 요소 제거)을 추가하며, 이는 제한된 리소스를 가진 모바일 애플리케이션에서 OutOfMemoryError를 방지하는 데 필요합니다.

이미지에 대한 LRU Cache 크기는 어떻게 선택하나요?

Google은 이미지 캐시에 사용 가능한 메모리의 1/8(Runtime.maxMemory() / 8)을 할당할 것을 권장합니다. 무거운 그래픽을 사용하는 애플리케이션의 경우 최대 1/4까지 허용됩니다. 느리지만 저렴한 스토리지 덕분에 2~5배 더 많은 데이터를 저장할 수 있는 디스크 캐시(DiskLruCache)도 고려하세요.

LRU와 LFU Cache의 차이점은?

LRU는 가장 오랫동안 사용되지 않은 요소(마지막 액세스 시간 기준)를 제거합니다. LFU는 가장 적게 사용된 요소(액세스 빈도 기준)를 제거합니다. LFU는 불균등한 액세스 빈도 시나리오에 더 적합하지만 구현이 더 복잡하고 카운터 저장을 위해 더 많은 메모리를 소비합니다.

iOS의 NSCache는 LRU 정책을 지원하나요?

NSCache는 제거 정책을 문서화하지 않지만, 실제로는 일부 LFU 요소와 함께 LRU에 가까운 하이브리드 방식을 사용합니다. NSCache는 메모리가 부족할 때 자동으로 객체를 제거하고 비용 기반 우선순위를 지원합니다. 그러나 보장된 LRU 동작을 위해서는 사용자 정의 구현이 권장됩니다.

LRU Cache 맥락에서 스래싱이란?

스래싱(Thrashing)은 캐시가 실제 이점 없이 지속적으로 요소를 제거하고 로드하는 상태입니다. 애플리케이션의 작업 데이터 세트가 캐시 크기보다 크고 데이터 액세스가 순환적일 때 발생합니다. 해결책으로는 캐시 크기 증가, LFU 사용, 또는 적응형 ARC(Adaptive Replacement Cache) 알고리즘 적용이 있습니다.

요약

  • LRU Cache — 오버플로우 시 가장 최근에 사용되지 않은 요소를 제거하는 캐싱 알고리즘
  • 복잡도 O(1)의 get 및 put은 HashMap과 이중 연결 리스트의 조합으로 달성
  • Access-order — 각 요청이 요소를 앞으로 이동, 제거는 리스트 끝에서 발생
  • 지역성 원칙 — 최근에 요청된 데이터는 다시 필요할 가능성이 매우 높음
  • 히트율 80~95%는 대부분의 캐싱 시나리오에서 좋은 지표로 간주
  • LruCache in Android — hit/miss 카운팅 및 콜백을 갖춘 즉시 사용 가능한 스레드 안전 구현
  • 사용 모바일 애플리케이션에서 이미지, 네트워크 데이터 및 계산 결과 캐싱에 LRU 사용

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기