Swift의 캐싱 NSCache

Swift의 캐싱 - NSCache

Swift에서도 제공하고있는 NSCache를 이용하여 image등의 다량의 데이터를 훨씬 효율적으로 처리할 수 있는 방법에 대해 정리하겠습니다.

Caching(캐싱) 이란?

컴퓨터 아키텍쳐와, 운영체제에서 이론적인 부분을 많이 다뤘을 것이다. 흔히 컴퓨팅에서 캐시는 일반적으로 일시적인 특징이 있는 데이터 하위 집합을 저장하는 고속 스토리지 계층 이다. 즉 저장 공간이라는 말이다. 캐시를 사용하면 자주 접근 하는 객체를 계산하는 데 비용이이 많이 드는 일 시적인 데이터를 저장 한다. 그렇기 때문에 이런 객체를 사용하면 값을 다시 계산 할 필요가 없기 때문에 성능에 상당히 많은 이점이 있다.

캐시에서의 이슈인 어떻게 객체와 같은 데이터를 효율적으로 접근하게 하는지? 그리고 자주 사용한다는 것에 대해 LRU 나 FIFO, LFU 등의 페이지 교체 알고리즘 이슈가 상당히 많지만 지금은 간단하게 다루고 나중에 CS기초공부할 때 다루도록 하겠습니다.

NSCache

Cocoa는 메모리 관리 문제를 해결하는 동시에 캐시할 항목을 위한 편리한 저장 컨테이너로 NSCache를 제공한다.

NSCache 클래스는 NSDictionary 클래스와 매우 유사하며 둘 다 key-value 쌍을 보유한다. 그러나 NSCache 객체는 “Reactive Cache(반응 캐시)” 이다. 즉, 메모리를 사용할 수 있을때 주어진 데이터를 적극적으로 캐시하고 만약 메모리가 부족하다면 , 다른 애플리케이션을 위한 메모리를 확보하기 위해 일부 요소를 자동으로 삭제한다. 삭제된 항목은 필요할 경우 다시 계산해야하는 추가적인 컴퓨팅 타임이 걸린다.

NSCache는 캐시된 요소의 수를 제한하고 캐시에 있는 모든 요소의 총 비용을 제한하는 두가지 유용한 제한 기능을 제공한다.

  1. countLimit: 캐시된 요소 수 제한
  2. totalCostLimit:캐시에 있는 모든 요소의 총 비용 제한
let cache: NSCache<NSString, UIImage> = NSCache()
cache.countLimit = // 허용하는 key의 개수
cache.totalCostLimit = // cost 합계의 최댓값

countLimit 이 10으로 설정된 캐시에 11번째 항목을 추가하려고 하면 캐시가 자동으로 요소 중 하나를 삭제할 수 있다.

캐시에 항목을 추가할 때 각 key-value와 연관 시킬 비용(cost)를 지정할 수 있다. 캐시된 모든 객체 비용이 totalCostLimit를 넘어가면 캐시는 일부 객체를 자동으로 삭제할 수 있다. 캐시된 모든 개체의 cost에 대한 최대 값을 설정하려면 setTotalCostLimit 메서드를 호출 하면된다. 따라서 totalCost를 totalCostLimit 값 이상으로 객체가 추가될 때 캐시는 임계값 이하로 돌아가기 위해 해당 객체의 일부를 자동으로 제거할 수 있다.

임계값을 맞추기 위해 삭제하는 이 과정은 보장되지 않으므로 특정 행동을 달성하기 위해 비용 값을 임의조작하는 것은 캐시의 성능에 악영향을 미친다. 그때 쓸만한 값이 없으면 0을 입력하거나, 비용을 전달하지 않아도 되는 setObject:forKey 메서드를 호출하면 된다.

NSCache의 메모리 관리 방법

위에서 언급했듯이 캐시를 사용할 때 아무래도 메모리가 한정적이다 보니깐 “어떤 데이터를 버려야할 지”하는 선택의 상황이 온다. 가장 오래된 데이터를 버리면 되는지 아니면 또 어떤 규칙으로 데이터를 버려야 하는지를 알고리즘으로 구현 한 것이 있다.

일반적으로 메모리 관리기법에는 페이지 교체 알고리즘을 사용한다.

페이지 교체란?

메모리를 관리하는 운영체제에서 , 페이지 부재가 발생하여 새로운 페이지를 할당하기 위해 새로운 메모리를 할당해주어야한다. 하지만 물리적 메모리에 빈 프레임이 존재하지 않는 경우 할당된 페이지 중 하나를 교체해야하는데 이를 선택하는 작업을 뜻한다.

그리고 어떤 프레임에 있는 페이지(구)를 새롭게 들어오는 페이지(신) 과 교체할 지를 결정하는 방법이 몇가지 알고리즘으로 제공된다.

  • FIFO(First-In-First-Out): 큐의 구조와 동일한것으로 메모리에 가장 먼저 올라온 페이지를 우선적으로 선택한다.
  • LRU(Least Recently Used): 가장 오래 전에 참조가 이루어진 페이지를 선택한다. 즉 가장 오랫동안 사용되지않은 페이지를 뜻한다.
  • LFU(Least Frequently Used): 각 페이지들이 얼마나 많이 사용되었는지 가중치를 확인하고 가장 많이 호출된 횟수가 적은 페이지를 선택하여 교체된다.
  • Random: 어느 페이지도 교체될수 있게 랜덤하게 선택한다. 최악의 경우 추가되고 바로 뒤에 호출될 페이지 까지 교체 될 수 있다.

하지만 NSCache 는 기존에 제공하는 페이지 교체 알고리즘을 사용하지 않고 메모리 관리에 자동 축출 정책(Auto-Eviction Policies) 캐싱 메커니즘을 사용한다.

NSCache 구현

NSCache 는 기본적으로 연결리스트로 데이터를 캐싱한다. 이에 대한 명확한 설명인 기재 되어있지 않지만 아마도 연결리스트의 장점인 재정렬과 추가 삭제의 이점 때문이 아닐까 생각이 든다. 캐시를 배열로 구현할 경우 데이터가 캐시 도중에 삽입 되거나 삭제될때 뒷 부분을 전체적으로 옮겨야하는 추가적인 작업이 발생하므로 연결리스트로 구현한거 같다.

그리고 NSCache는 별도로 Dictionary를 두어 데이터의 접근도 해쉬의 기법처럼 O(1)에 수행할 수 있도록 제공한다. 연결리스트로 구현했을때의 가장 큰 단점은 탐색이기 때문에 이를 보완하기 위해 이렇게 key-value 형태의 Dictionary를 제공하는듯 하다.

open class NSCache<KeyType, ObjectType> : NSObject where KeyType : AnyObject, ObjectType : AnyObject {
  
    open var name: String
  
    unowned(unsafe) open var delegate: NSCacheDelegate?
    
    open var totalCostLimit: Int // limits are imprecise/not strict

    open var countLimit: Int // limits are imprecise/not strict

    open var evictsObjectsWithDiscardedContent: Bool
}

구현의 참고는 swift-corelibs-foundation 를 참고하여 작성하였습니다.

NSCacheEntry
private class NSCacheEntry<KeyType : AnyObject, ObjectType : AnyObject> {
    var key: KeyType
    var value: ObjectType
    var cost: Int
    var prevByCost: NSCacheEntry?
    var nextByCost: NSCacheEntry?
    init(key: KeyType, value: ObjectType, cost: Int) {
        self.key = key
        self.value = value
        self.cost = cost
    }
}

NSCache 클래스에 저장되는 실제 엔트리다. 구현을 위에서 언급했듯이 Dicationaty 형태와 동일하게 프로퍼티로 key-value 를 갖고 cost 값이 있고 각 엔트리 들을 연결리스트로 참조하기 위해 prevByCost(이전), nextByCost (다음) 으로 이중 연결리스트로 연결 될 수 있게 프로퍼티를 선언했다.

NSCache

각 주요 프로퍼티에 대해서 분석해보자

open class NSCache<KeyType : AnyObject, ObjectType : AnyObject> : NSObject {
    
    private var _entries = Dictionary<NSCacheKey, NSCacheEntry<KeyType, ObjectType>>()
    private let _lock = NSLock()
    private var _totalCost = 0
    private var _head: NSCacheEntry<KeyType, ObjectType>?
    
    open var name: String = ""
    open var totalCostLimit: Int = 0 // limits are imprecise/not strict
    open var countLimit: Int = 0 // limits are imprecise/not strict
    open var evictsObjectsWithDiscardedContent: Bool = false
		...
  • _entries : NSCacheKeyNSCacheEntrykey-value 쌍으로 이루어진 Dicationary이다. 딕셔너리 타입이 기 때문에 앞에서 언급했듯이 O(1) 타임만에 탐색이 가능하다.
  • _lock : _entries 에 접근할때마다 이 프로퍼티를 사용하여 동시에 접근 할 수 없도록 잠그고, 관련 작업이 끝난 후에 잠금을 해제한다. 마치 뮤텍스(mutex) 의 lock 과 동일하게 작동 되며 이는 동시에 같은 엔트리에 접근하여 값을 변경 시켜 버리면 안되기 때문이다.(Gaki-운영체제: 프로세스 동기화)
  • _totalCost : 모든 엔트리의 cost 총합이다. 새로운 객체가 추가될 때마다 갱신이 되고 totalCostLimit를 초과하는지 확인하는데 사용된다.
  • name: NSCache를 식별하는데 사용할 String
  • countLimit: 캐시된 요소 수 제한
  • totalCostLimit:캐시에 있는 모든 요소의 총 비용 제한
  • evictsObjectsWithDiscardedContent : NSCache는 시스템에서 메모리를 너무 많이 사용하지 않도록 디자인 되어있다. 그래서 캐싱된 데이터를 자동으로 지우는 다양한 정책을 사용하고 있고, 캐싱된 데이터가 너무 많은 메모리를 사용하면 시스탬은 캐싵된 데이터를 삭제한다.

기본적으로 NSCache 의 기능은 삽입, 삭제, 설정 으로 크게 3가지로 분류되어 구현이 되어있다.

삽입 : Insert(_:)
private func insert(_ newEntry: NSCacheEntry<KeyType, ObjectType>) {
  // 1. _head 가 비어있다면 맨앞에 새로 추가된 entry 삽입
        guard var currentElement = _head else {
            // The cache is empty
            newEntry.prevByCost = nil
            newEntry.nextByCost = nil
            
            _head = newEntry
            return
        }
        // 2. _head보다 cost가 작거나 같은 것이 들어올 경우 _head 앞에 삽입
        guard newEntry.cost > currentElement.cost else {
            // Insert entry at the head
            newEntry.prevByCost = nil
            newEntry.nextByCost = currentElement
            currentElement.prevByCost = newEntry
            
            _head = newEntry
            return
        }
        // 3. 순회를 통해 삽입 될 곳의 위치를 찾는다.
        while let nextByCost = currentElement.nextByCost, nextByCost.cost < newEntry.cost {
            currentElement = nextByCost
        }
        
        // Insert entry between currentElement and nextElement
  			// 순서가 중요
        let nextElement = currentElement.nextByCost
        
        currentElement.nextByCost = newEntry
        newEntry.prevByCost = currentElement
        
        newEntry.nextByCost = nextElement
        nextElement?.prevByCost = newEntry
    }

삽입은 크게 3가지 단계로 구분해서 나눌수 있다. 연결리스트의 삽입 과 동일하게 코드를 작성 해주면 된다

  1. _head 가 비어있다면 맨앞에 새로 추가된 newEntry 삽입
  2. _head 보다 cost가 작거나 같은 것이 들어올 경우 _head 앞에 삽입, 즉 cost의 오름차순으로 유지하며 삽입
  3. 순회 도중에 삽입 되는 경우, 해당 위치를 찾고 newEntry를 삽입 한다.
삽입 과정 수기 정리

image

삭제: remove(_:)
    private func remove(_ removeEntry: NSCacheEntry<KeyType, ObjectType>) {
        let oldPrev = removeEntry.prevByCost
        let oldNext = removeEntry.nextByCost
        
        oldPrev?.nextByCost = oldNext
        oldNext?.prevByCost = oldPrev
        // 삭제할 entry가 head 인 경우 next의 값을 연결 시킨다.
        if removeEntry === _head {
            _head = oldNext
        }
    }

삭제의 경우 삭제하고 싶은 엔트리 removeEntry를 인자로 받아 단순하게 이전과 다음 엔트리를 연결 시키면된다. 이때 removeEntry의 경우 ARC가 참조가 더이상 일어 나지 않으므로 메모리에서 해제 하기 때문에 free에 관한 이슈는 생각할 필요없다.

image

설정: setObject(_:)

새롭게 객체를 추가할때, totalCost 또는 countLimit를 초과했다면 특정 엔트리를 축출한 후 추가 작업을 수행하도록 구현이 되어있다.

open func setObject(_ obj: ObjectType, forKey key: KeyType, cost g: Int) {
        let g = max(g, 0)
        let keyRef = NSCacheKey(key)
        
        _lock.lock()
        
        let costDiff: Int
        // 추가된 객체와 같은 키를 갖는 엔트리를 찾는다.
        if let entry = _entries[keyRef] {
          // 같은 엔트리를 우선 찾고 cost의 차이를 계산
            costDiff = g - entry.cost
          // 추가된 객체의 cost로 업데이트
            entry.cost = g
            // 추가된 객체로 업데이트
            entry.value = obj
            // 만약 cost의 차이가 있다면(같지 않다면) cost가 변경된 기존의 entry를 삭제하고
            // 올바른 위치로 갈수 있게 재 삽입
            if costDiff != 0 {
                remove(entry)
                insert(entry)
            }
        } else {
          // 기존에 같은 키를 갖는 엔트리가 없는 경우 새롭게 NSCacheEntry 생성 후 삽입
            let entry = NSCacheEntry(key: key, value: obj, cost: g)
            _entries[keyRef] = entry
            insert(entry)
            
            costDiff = g
        }
        
        _totalCost += costDiff
       // cost 제한이 있다면, 새로 추가된 엔트리의 cost를 포함한 totalCost에서 totalLimitCost를 뺀 			// 값을 purgeAmount에 할당한다. 제한이 없다면 0을 할당한다.
        var purgeAmount = (totalCostLimit > 0) ? (_totalCost - totalCostLimit) : 0
  		// totalCost ≤ totalLimitCost 일때 까지 
        while purgeAmount > 0 {
          // 엔트리 축출 작업은 _head 엔트리 부터 제거한다.
            if let entry = _head {
                delegate?.cache(unsafeDowncast(self, to:NSCache<AnyObject, AnyObject>.self), willEvictObject: entry.value)
                
                _totalCost -= entry.cost
                purgeAmount -= entry.cost
                
                remove(entry) // _head will be changed to next entry in remove(_:)
                _entries[NSCacheKey(entry.key)] = nil
            } else {
                break
            }
        }
        // countLimit 에도 동일한 작업을 수행
        var purgeCount = (countLimit > 0) ? (_entries.count - countLimit) : 0
        while purgeCount > 0 {
            if let entry = _head {
                delegate?.cache(unsafeDowncast(self, to:NSCache<AnyObject, AnyObject>.self), willEvictObject: entry.value)
                
                _totalCost -= entry.cost
                purgeCount -= 1
                
                remove(entry) // _head will be changed to next entry in remove(_:)
                _entries[NSCacheKey(entry.key)] = nil
            } else {
                break
            }
        }
        
        _lock.unlock()
    }

설정의 핵심은 전체 비용(totalCost) 에 대한 totalCosttotalLimitCost 인 상황 까지 While문을 돌게 된다 즉 임계치가 해당 범위안에 도달할 때까지 반복적으로 수행하고 축출 즉 제거는 cost가 가장 낮은 순위인 _head 부터 수행한다. 마찬가지로 countLimit 에도 똑같이 수행하여 임계치에 도달하도록 한다.


Reference





© 2020. by Gaki

Powered by gaki