iOS

[iOS]NSCache에 대해 알아보자!

felix-mr 2021. 8. 13. 18:36

 

안녕하세요🙇‍♂️

 

이번 게시글은 NSCache에 대해 알아보려고 합니다.

 

Alamofire나 KingFisher 같이 캐싱을 제공해주는 좋은 오픈소스들이 있긴하지만!

NSCache를 통해 직접 구현을 해야될 때도 있고, NSCache에 대해 이해를 하고 있으면

오픈소스를 사용할 때 더 쉽고 편하게 사용할 수 있겠죠?

 

자자! 그럼 공식문서를 확인해보면서 시작해보겠습니다.👏

 

문서에 있는 내용은 제가 읽으면서 좀 더 읽기 쉽도록 의역했기 때문에

혹시 잘못 의역했다고 생각하시는 문구에 대해서는 댓글로 지적 부탁드립니다❗️

 

 

NSCache

Key-Value쌍을 임시로 저장하는 데 사용하는 Mutable한 Collection입니다.

이 Key-Value쌍은 메모리가 부족할 때 제거됩니다.

 

class NSCache<KeyType, ObjectType> : NSObject where KeyType : AnyObject, ObjectType : AnyObject

 

Cache 객체는 일반적인 Mutable한 Collectione들과 다른 점이 있습니다.

  • NSCache 클래스는 캐시가 시스템 메모리를 너무 많이 사용하지 않도록 하는 다양한 자동 제거 정책(Auto-Eviction Policies)을 통합하고 있습니다. 다른 응용 프로그램에서 메모리가 필요한 경우 이러한 정책은 캐시에서 일부 항목을 제거하여 메모리 사용 공간을 최소화합니다.
  • 캐시는 Thread-Safe하기 때문에 이를 직접 다루지 않더라도 여러 스레드에서 캐시의 항목을 추가, 제거 및 쿼리할 수 있습니다.
  • NSMutableDictionary 개체와 달리 캐시는 캐시에 저장된 키를 복사하지 않습니다.

일반적으로 NSCache 객체를 사용하여 생성 비용이 많이 드는 데이터를 임시로 저장합니다. 캐싱된 객체(데이터)를 재사용하면 해당 값을 다시 계산할 필요가 없기 때문에 성능상의 이점을 얻을 수 있습니다. 그러나 캐싱된 객체는 응용 프로그램에 중요하지 않거나 메모리가 부족한 경우 삭제할 수 있습니다. 삭제된 후에 해당 데이터가 필요할 때는 값을 다시 계산해야 합니다.

 

사용하지 않을 때 버릴 수 있는(Discardable) 하위 구성 요소가 있는 객체는 NSDiscardableContent 프로토콜을 채택하여 캐시 제거 동작을 개선할 수 있습니다. 기본적으로 캐시의 NSDiscardableContent 개체는 콘텐츠가 삭제되면 자동으로 제거되지만 이 자동 제거 정책은 변경될 수 있습니다. NSDiscardableContent 객체가 캐시에 있으면 캐시는 제거 프로세스시 해당 객체에 대해 discardContentIfPossible()을 호출합니다.

 

 

 

 

여기까지가 NSCache 공식문서의 내용입니다!

 

모든 공식문서들이 그렇지만 역시 이해하기가 힘들죠! 깔깔!

하나씩 차근차근 알아봅시다!

 

 

먼저,

NSCache는 자동 제거 정책(Auto-Eviction Policies)을 통합해준다고 합니다.

개발자가 직접 removeObject(forKey:)를 호출하지 않더라도 내부적으로 자동 제거를 해준다는 것 같습니다.

 

그럼 개발자가 자동 제거 정책(Auto-Eviction Policies)에 설정할 수 있는 옵션들은 무엇이 있을까요?

 

countLimit

캐시가 보유하는 최대 object 수입니다.

 

기본값은 0입니다. 만약 이 값이 0일 경우 count 값에 제한이 없습니다.

 

이것은 엄격한 제한이 아닙니다. 캐시가 countLimit을 초과하면 캐시의 구현 세부 정보에 따라 캐시된 object가 즉시 또는 나중에 제거되거나 아예 제거되지 않을 수 있습니다.

 

totalCostLimit

캐시가 object 제거를 시작하기 전에 보유할 수 있는 최대 총 비용입니다.

 

기본값은 0입니다. 만약 이 값이 0일경우 totalCost 값에 제한이 없습니다.

 

캐시에 object를 추가할 때 object의 바이트 단위 크기와 같은 object에 대해 지정된 비용을 전달할 수 있습니다. object를 캐시에 추가하면 캐시의 총 비용이 totalCostLimit보다 높아지면 캐시가 총 비용이 totalCostLimit 아래로 떨어질 때까지 개체를 자동으로 제거할 수 있습니다. 캐시가 object를 제거하는 순서는 보장되지 않습니다.

 

이것은 엄격한 제한이 아니며 캐시가 totalCostLimit을 초과하면 캐시의 구현 세부 정보에 따라 캐시의 object가 즉시 또는 나중에 제거되거나 아예 제거되지 않을 수 있습니다.

 

 

우리가 할 수 있는 것은 countLimt과 totalCostLimit을 설정할 수 있을뿐....

게다가

캐시의 object가 즉시 또는 나중에 제거되거나 아예 제거되지 않을 수 있습니다.

 

예????

왜요....

왜 제거되지 않을 수도 있어요.....

🤪

 

 

음.... NSCache가 구현된 코드를 확인해봅시다.

 

open class NSCache<KeyType: AnyObject, ValueType: 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

  ... 생략 ...
}

 

일단 Key-Value 쌍으로 캐싱된 데이터를 관리할 수 있는 _entries가 존재합니다.

 

그런데 _head가 있네요? 연결리스트 형태로 또 뭔가를 관리해주는 것 같습니다.

 

그리고 우리가 접근할 수 있는 프로퍼티들을 확인할 수 있습니다. 물론 limits are imprecise/not strict 라고 명시를 친절하게 해주셨네요.. ㅎㅎ

 

 

먼저 주요 메서드를 확인하기 전에

NSCache 내부에 private하게 정의된 insert(_:) 메서드를 확인할 필요가 있습니다.

요 메서드는 setObject 될 때 해당 object들을 위에서 확인한 연결리스트에 cost 크기로 정렬해줍니다.

연결리스트인 _head에 정렬할 때 head에 가까울 수록 더 작은 형태로 오름차순 정렬해줍니다!

 

 

그럼 Swift 오픈소스에 정의된 setObject를 확인해봅시다.

여기는 코드가 좀 길기 때문에 중요 포인트만 집중해서 볼께요.

더 궁금하신 분들은 직접 NSCache 에서 코드를 확인해보세요 😅

 

func setObject(_ obj: ObjectType, forKey key: KeyType, cost g: Int) {  
  
  ... 생략 ...
  
  
  // 1: totalCostLimit을 통한 삭제
  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)
                
      _totalCost -= entry.cost
      purgeAmount -= entry.cost
                
      remove(entry) // _head will be changed to next entry in remove(_:)
      _entries[NSCacheKey(entry.key)] = nil
    } else {
      break
    }
  }
  
  
  // 2: 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
    }
  }
   
   ... 생략 ...
}

 

중요 포인트만 뽑았다고 생각했는데 좀 기네요.. ㅎㅎ 그래도 중요한 부분이니까 가져왔습니다!

 

1번을 한번 볼까요?

setObject를 할 때 totalCostLimit을 확인하고 제한을 넘어을 때 object를 삭제해주는 코드입니다.

근데 _head object를 지워주네요?

 

오오오옹!! 그렇답니다.

 

 

 

자 그럼 2번을 볼까요?

 

2번은 countLimit을 확인하고 제한을 넘어갔을 때 object를 삭제해주는 코드입니다.

여기서도 마찬가지로 cost 값을 비교하고 가장 작은 cost를 갖는 _head의 object를 삭제해줍니다.

 

결론적으로

 

캐싱된 object중에 cost가 가장 작은 object를 먼저 지워준다는 말이 됩니다.

 

예에에에에에에👏👏👏👏👏

 

세상 사람들!!!

여러 메모리 페이징 알고리즘이 있지만 NSCache는 작은 것을 먼저 삭제해주는 알고리즘을 사용한답니다!!

 

네 그렇답니다! ㅎㅎ..

 

 

근데 여기서 조금 의아한 점이 있습니다.

저는 일반적으로 setObject(_:forKey:) 메서드를 통해서 캐싱을 해왔는데

 

  1. 그럼 object의 cost는 0으로 설정 될 것이고
  2. totalCostLimit를 설정을 해도 비교할 수가 없을 것이고
  3. 저 cost를 비교해서 삭제하는 로직을 쓸모가 없다?
  4. 결국 totalCostLimit을 설정해도 아무 효과가 없다?

 

라고 추측을 해볼 수 있습니다.

 

어질어질 하네요.🤪

 

그럼 여기서 setObject(_:forKey:cost:) 공식문서를 살펴봅시다.

 

cost값은 캐시에 있는 모든 object의 비용을 포함하는 합계를 계산하는 데 사용됩니다.

 

그쵸? _totalCost 값으로 합계를 계산하는거 확인했죠?

 

 

메모리가 제한되거나 캐시의 totalCost가 totalCostLimit를 초과하는 경우 캐시는 일부 object를 제거하기 위해 제거 프로세스를 시작할 수 있습니다.

 

그쵸? 이것도 우리 같이 확인했죠? setObject할 때 totalCostLimit를 초과했을 때, _head에 있는 값을 삭제해주는거 봤죠?

 

 

그러나 이 제거 프로세스는 보장된 순서가 아닙니다. 결과적으로 특정 동작을 달성하기 위해 cost 값을 조작하려고 하면 결과가 프로그램에 해로울 수 있습니다. 일반적으로 명백한 비용은 object의 크기(바이트)입니다. 해당 정보를 쉽게 사용할 수 없는 경우 이를 계산하는 데 어려움이 없어야 합니다. 그렇게 하면 캐시 사용 비용이 증가하기 때문입니다.

 

오옹? 그러면 캐싱할 object의 cost를 구하는데 어려움이 없다면?

totalCostLimit을 설정했을 경우 cost를 전달해주는게 맞겠죠??

cost를 전달해주지 않으면 totalCostLimit을 설정해준 이유가 없어질테니까요..

자동 삭제 될 때 그냥 먼저 캐싱된 object가 삭제되겠죠??..

 

 

결론적으로,

 

totalCostLimit을 설정해줬다면, setObject(_:forKey:cost:) 메서드를 통해 cost값을 전달해주자!

입니다.

 

이부분은 공식문서와 코드를 바탕으로 한 제 뇌피셜입니다.

혹시 틀린 부분이 있다면 꼭 지적 부탁드립니다😭

 

 

Conclusion

  • countLimit와 totalCostLimit으로 NSCache의 자동 삭제 정책 옵션을 설정할 수 있다!
  • NSCache는 연결리스트에 cost의 오름차순으로 object를 정렬한다!
  • NSCache는 메모리가 부족하거나 설정한 Limit 옵션을 초과한 경우 cost가 가장 작은 object부터 삭제해준다!
  • totalCostLimit를 설정해준 경우 setObject(_:forKey:cost:)를 사용하자! cost를 설정하지 않으면 그냥 먼저 캐싱된 object가 삭제된다! (뇌피셜🤪)

 

 

오늘 게시글은 여기까지입니다.

 

NSCache는 블랙박스 형태로 제공되기 때문에 혹시나 제 추측이 틀렸을 수도 있고,

 

캐시의 object가 즉시 또는 나중에 제거되거나 아예 제거되지 않을 수 있습니다.

이 부분에 대해서도 명확히 알아내질 못했습니다 😭 더 알아봐야겠습니다.

 

내부적으로 cost를 조정하는 다른 방법이 존재할 수도 있습니다.

혹시나 부족하거나 틀린내용에 대해서는 꼭! 댓글로 의견 부탁드립니다.🙇‍♂️

 

 

이번 게시글을 작성하면서 NSDiscardableContent와 NSPurgeableData가 뭔지 궁금해졌는데요.

이 부분은 나중에 다른 게시글로 찾아뵙도록 할께요. 공부할것들이 많아요. 행복해요. 전 괜찮아요.🤥

 

 

 

그럼 다음 게시글에서 만납시다! 바위!👋