HCN DEV

NSCache

  • NSCache는 Cocoa에서 사용할 수 있는 캐싱을 위한 클래스입니다. NSCacheNSDictionary처럼 key-value 형태로 되어 있습니다.
  • NSCache는 메모리가 충분할 때는 주어진 모든 데이터를 캐싱합니다. 반면, 가용 메모리가 적을 때는 다른 앱을 위해 캐싱된 데이터를 자동으로 버립니다.

이렇게 버려진 데이터는 필요할 때 다시 캐싱해야 합니다. 바꿔말하면, 캐싱된 데이터가 없어도 앱이 적절히 동작(i.e. 다시 데이터 캐싱)할 수 있도록 구조를 설계해야 합니다.

NSCache Property

  • countLimit - NSCache는 캐싱하는 데이터의 개수를 제한할 수 있습니다. 만약 countLimit이 10으로 설정되어 있는데, 11개의 데이터를 NSCache에 넣게 되면 1개는 자동으로 버립니다.
  • totalCostLimit - NSCache는 객체를 추가할 때 cost(Int)를 함께 설정할 수 있습니다. 이 때, totalCostLimit은 cost들의 총합의 최댓값입니다. 즉, NSCache에 추가된 데이터들의 cost가 totalCostLimit에 도달하거나 넘게 되면 NSCache는 데이터를 버립니다.
let cache: NSCache<NSString, UIImage> = NSCache()
cache.countLimit = 허용하는 key의 개수
cache.totalCostLimit = cost 합계의 최댓값
  • evictsObjectsWithDiscardedContent - NSCache는 시스템에서 메모리를 너무 많이 사용하지 않도록 디자인되어 있습니다. 그래서 캐싱된 데이터를 자동으로 지우는 다양한 정책을 사용하고 있고, 캐싱된 데이터가 너무 많은 메모리를 사용하면 시스템은 캐싱된 데이터를 삭제합니다.

The NSCache class incorporates various auto-eviction policies, which ensure that a cache doesn’t use too much of the system’s memory. If memory is needed by other applications, these policies remove some items from the cache, minimizing its memory footprint.

이 부분은 좀 더 알아보고 구체적인 서술을 하고자 합니다.

NSCache 구현

NSCache가 어떻게 구현되어 있는지는 애플에서 공개한 Foundation - NSCache를 살펴보면 파악할 수 있습니다.

NSCache는 기본적으로 연결리스트로 데이터를 캐싱합니다. 연결리스트로 데이터를 캐싱하는 이유에 대해서 명확히 서술되어 있지는 않지만, 캐시는 중간에 있는 데이터 추가, 삭제가 빈번하기 때문에 이를 효율적으로 하기 위해(배열로 해당 작업을 수행시 데이터를 앞으로 당기거나, 뒤로 데이터를 모두 밀어야 하는 작업이 추가적으로 발생합니다.) 연결리스트로 구현된 것이라고 생각해볼 수 있습니다.

한편, NSCache는 별도로 Dictionary를 두어 데이터의 접근도 O(1)에 수행할 수 있도록 제공하고 있습니다.

// NSCache
open class NSCache<KeyType : AnyObject, ObjectType : AnyObject> : NSObject {

    private var _entries = Dictionary<NSCacheKey, NSCacheEntry<KeyType, ObjectType>>()
    private var _head: NSCacheEntry<KeyType, ObjectType>?
}

NSCache 데이터 교체 알고리즘

일반적으로 캐시는 캐싱된 데이터를 버리는 방식에 있어서 용도에 따라 서로 다른 페이지 교체 알고리즘을 사용합니다. 흔히, FIFO, LRU, LFU 등의 메모리 페이지 교체 알고리즘들이 사용되는데, NSCache는 이와는 조금 다른 방식으로 데이터를 취하고 버립니다.

NSCache에는 개별 데이터마다 cost 값을 부여할 수 있습니다. NSCache는 이 cost값의 오름차순으로 데이터를 추가할 때 정렬을 수행합니다. 실제 코드를 살펴보면 다음과 같습니다.

private func insert(_ entry: NSCacheEntry<KeyType, ObjectType>) {
    guard var currentElement = _head else {
        // The cache is empty
        entry.prevByCost = nil
        entry.nextByCost = nil

        _head = entry
        return
    }

    guard entry.cost > currentElement.cost else {
        // Insert entry at the head
        entry.prevByCost = nil
        entry.nextByCost = currentElement
        currentElement.prevByCost = entry

        _head = entry
        return
    }

    while let nextByCost = currentElement.nextByCost, nextByCost.cost < entry.cost {
        currentElement = nextByCost
    }

    // Insert entry between currentElement and nextElement
    let nextElement = currentElement.nextByCost

    currentElement.nextByCost = entry
    entry.prevByCost = currentElement

    entry.nextByCost = nextElement
    nextElement?.prevByCost = entry
}

위의 코드를 보면 NSCache에 데이터를 추가할 때, cost값을 기준으로 정렬을 수행하는 것을 확인할 수 있습니다. 또한 삭제의 경우, 새로운 값을 추가할 때, 값이 작은 데이터들을 순차적으로 제거하여 totalCostLimit을 유지하는 방식을 활용하고 있습니다.

// open func setObject(_ obj: ObjectType, forKey key: KeyType, cost g: Int)의 일부 발췌
// CostLimit에 기반하여 데이터 제거(Purging)
// purgeAmount = 줄여야 하는 cost의 총량
var purgeAmount = (totalCostLimit > 0) ? (_totalCost - totalCostLimit) : 0
while purgeAmount > 0 {
    if let entry = _head {
        delegate?.cache(unsafeDowncast(self, to:NSCache<AnyObject, AnyObject>.self), willEvictObject: entry.value)

        // 작은(head쪽) cost의 데이터들부터 제거해나감
        _totalCost -= entry.cost
        purgeAmount -= entry.cost

        remove(entry) // _head will be changed to next entry in remove(_:)
        _entries[NSCacheKey(entry.key)] = nil
    } else {
        break
    }
}

데이터 추가 삭제를 cost 중심으로 설명하였는데, count를 통해서도 해당 작업을 수행합니다. 이 부분은 여기서 따로 서술하지 않습니다.(cost를 중심으로 데이터를 추가 삭제하는 것과 크게 다르지 않습니다.)

전체 코드는 Foundation - NSCache에서 확인하실 수 있으니, 이를 참고하시면 좋을 것 같습니다.


참고 자료