[Swift] NSCache 사용해보기
⛔️ NSCache에 대해 개인적으로 공부한 것을 정리한 글입니다. 최대한 올바른 내용만 적기위해 노력하고 있지만 틀린내용이 있을 수 있습니다. 그렇기 때문에 글을 읽으실때 비판적으로 읽어주세요.
틀린내용에 대한 피드백은 메일로 보내주시면 감사하겠습니다🙏🏻
1️⃣ NSCache
캐시의 종류는 여러가지 입니다. 그중에서 앱, 웹에서의 캐시는 크게 두가지로 메모리캐시와 디스크캐시로 나눌 수 있습니다. 웹에서는 디스크캐시는 하드디스크에 접근하는시간을 개선하기위해 RAM에 저장, 메모리캐시는 RAM에 접근하는 시간을 개선하기위해 CPU에 내장된 캐시메모리에 저장한다고 합니다. 앱도 웹과 용어만 다를뿐 디스크캐시, 메모리캐시 각각의 성능레벨면에서의 개념은 비슷할 것이라고 생각합니다.
결정적으로 캐시의 종류가 여러가지인 이유는 기술적인 한계때문에 처리속도와 용량은 반비례하기 때문이라 생각합니다. 그렇기 때문에 목적에 맞게 캐시를 사용하는 것이 중요합니다.
이번 포스트에서 다룰 NSCache는 메모리캐시입니다. 가장 큰 특징은 처리속도가 빠르고, 용량이 적고, 앱이 종료(혹은 백그라운드상태)되면 모든 캐시데이터가 삭제된다는 것입니다.
2️⃣ NSCache 사용하기
NSCache를 적용하기전 스크롤뷰 모습
iOS의 ScrollView의 셀들은 재사용되는 특성이 있습니다. 만약 캐시를 사용하지 않는다으면 다음과 같이 셀이 재사용될때마다 이미지를 네트워크로부터 새롭게 요청하게 됩니다. 특히 
NSCacheManager 구현하기
NSCache를 이용한 캐시매니저를 싱글턴클래스로 만들었습니다. NSCache는 싱글턴으로 만들어 모든 캐시 데이터를 한객체에서 처리하도록 만들어야 합니다. 기능은 필요한 부분인 Create, Read 구현했습니다.
class NSCacheManager {
static let shared = NSCacheManager()
private init() { }
private let storage = NSCache<NSString, UIImage>()
func cachedImage(urlString: String) -> UIImage? {
let cachedKey = NSString(string: urlString)
if let cachedImage = storage.object(forKey: cachedKey) {
return cachedImage
}
return nil
}
func setObject(image: UIImage, urlString: String) {
let forKey = NSString(string: urlString)
self.storage.setObject(image, forKey: forKey)
}
}
이제 다음과 같이 네트워크매니저(NetworkManager)에 캐시를 적용시킬 수 있습니다.

NSCache를 적용후 스크롤뷰 모습
다음과 같이 셀이 재사용되더라도 새롭게 네트워크로부터 요청하지 않습니다. (셀이 재사용되는 모습을 보여주기 위해 sleep()메서드를 이용해서 딜레이를 주었습니다. 실제로는 캐시로 이미지를 로드하는 속도가 네트워크에서 로드하는 것 보다 훨씬 빠릅니다.)

3️⃣ NSCache vs Dictionary
위에서 NSCache매니저를 구현하면서 느낀점은 NSCache와 Dictionary를 사용하는 방식이 비슷하다는 것 입니다. 다음은 Read, Create부분을 NSCache와 Dictionary로 구현하여 비교한 모습입니다.
< NSCache >

< Dictionary >

실제로 NSCache의 구현모습을 보면 Dictionary를 이용하고 있습니다. 
Dictionary를 사용하기는 하지만 value값으로 NSCacheEntry타입을 가지고 있습니다. NSCacheEntry는 다음과 같이 연결리스트로 구현되어 있는데 선입선출(FIFO)로 데이터를 관리하기위함입니다. 
연결리스트가 사용되기는 하지만 큰틀로 봤을때 데이터가 딕셔너리형태로 저장되어 있습니다. 하지만 NSCache에 관한 문서를 살펴보면 다음과 같이 일반적인 collections와 3가지면에서 다르다고 합니다. 
- 시스템 메모리를 많이 사용하지 않게하고 다른곳에서 메모리를 사용할경우 캐시에서 아이템을 제거하여 관리해준다
- 쓰레드 안전(Thread Safety)하다
- NSMutableDictionary와 다르게 캐시의 키객체를 복사하지않아 메모리적으로 안전하다
캐시에 키객체를 복사하지않아 메모리적으로 안전하다?
다음글(링크: 스텍오버플로우 사이트)을 참고하면 캐시에서 키값을 복사하여 저장하지않는 것을 알 수 있습니다.
키값을 NSCacheKey로 감싸서 저장하는데 내부구현을 살펴보면 self.value = value로 참조형태로 저장함을 알 수 있습니다. 다들 알다시피 =를 클래스(AnyObject)는 참조형태, struct와 같은 값타입은 복사하여 저장됩니다. NSCache는 키와 값으로 AnyObject(클래스타입)만 받고 있습니다. 결론적으로, 복사하지않고 참조형태로 저장하고 있음을 알 수 있습니다. 
쓰레드 안전(Thread Safety)하다?
NSCache는 문서에 나와 있듯이 쓰레드 안전하다고 합니다. 즉, 여러쓰레드에서 NSCache를 사용해도 안전해야 됩니다. 이것을 확인하기 위해 Dictionary를 대조군으로 놓고 실험을 해봤습니다.
먼저, NSCache와 Dictionary에서 공통으로 사용할 데이터타입 다음과 같습니다.
class Storage {
var value:Int = 0
}
이제 저장된 Storage개체의 value값을 1씩증가시키는 메서드를 아래와 같이 NSCache, Dictionary타입으로 각각 만들어줍니다.
명확한 실험을 위해 usleep(200000)을 넣어 0.2초의 딜레이를 주었습니다.
func addCacheValue() {
usleep(200000)
guard let cacheValue = self.cache.object(forKey: NSString(string:"cache")) else {
print("Cache fail!")
return
}
cacheValue.value += 1
self.cache.setObject(cacheValue, forKey: NSString(string:"cache"))
print("cache: ", cacheValue.value)
}
func addDicValue() {
usleep(200000)
guard let dicValue = self.dictionary["dic"] else {
print("dictionary fail!")
return
}
dicValue.value += 1
self.dictionary.updateValue(dicValue, forKey: "dic")
print("dictionary: ", dicValue.value)
}
쓰레드 두곳에서 동시에 메서드를 호출하는 버튼액션메서드를 다음과 같이 만듭니다.
@IBAction func addCacheValue(_ sender: Any) {
DispatchQueue.global().async {
self.addCacheValue()
}
DispatchQueue.global().async {
self.addCacheValue()
}
}
@IBAction func addDicValue(_ sender: Any) {
DispatchQueue.global().async {
self.addDicValue()
}
DispatchQueue.global().async {
self.addDicValue()
}
}
Dictionary는 다음의 짤을 보면 알듯이
< Dictionary 크래시1 >

< Dictionary 크래시2 >

반면, NSCache는

Dictionary를 쓰레드 안전하게 만들기(NSLock사용)
NSLock을 이용하면 Dictionary도 쓰레드 안전하게 다룰 수 있습니다.
//NSLock개체 프로퍼티 선언
private let lock = NSLock()
func addDicValue() {
usleep(200000)
self.lock.lock() // lock호출
guard let dicValue = self.dictionary["dic"] else {
print("dictionary fail!")
return
}
dicValue.value += 1
self.dictionary.updateValue(dicValue, forKey: "dic")
print("dictionary: ", dicValue.value)
self.lock.unlock() // unlock호출
}
이렇게 NSLock로 관리해주니 Dictionary에서도 크래쉬가 일어나지 않습니다. 
실제로 NSCache구현을 살펴보면 NSLock을 사용하여 관리하고 있습니다.

캐시에서 아이템을 제거하여 관리해준다?
위에서 살펴봤듯이 NSCache는 내부적으로 연결리스트를 이용하여 선입선출로 데이터를 관리하고 있습니다. 하지만 이부분은 개발자가 관리해줘야 효율적으로 관리해줄 수 있습니다. 다음의 두가지 요소를 직접 지정해줄 수 있습니다.
- countLimit
- totalCostLimit
먼저 countLimit을 사용해 보겠습니다. 다음과 같이 18로 지정하면 18개의 데이터만 저장이되고 새로운 데이터가 저장되면 선입선출로 데이터가 대체됩니다.
class NSCacheManager {
static let shared = NSCacheManager()
private let storage = NSCache<NSString, UIImage>()
private init() {
storage.countLimit = 18
}
/* 코드 생략 */
}
서버로부터 받아오는 이미지가 21개로 3개의 데이터가 지워지고 저장되고를 반복할 것 입니다. 하지만 아래의 짤을보면 약 1개의 데이터만 지워지고 저장되고를 반복함을 알 수 있습니다. 
내부구현을 보면 
다음으로 totalCostLimit을 사용해 보겠습니다. setObject로 값을 저장할때 cost값을 지정해줘야 합니다.(default값은 0)
만약, totalCostLimit으로 20이고 setObject로 데이터를 저장할때마다 cost값으로 2를 주게 되면 약 10개의 데이터를 저장할 수 있습니다.
class NSCacheManager {
/* 코드 생략 */
private init() {
storage.totalCostLimit = 20
}
func setObject(image: UIImage, urlString: String) {
let forKey = NSString(string: urlString)
self.storage.setObject(image, forKey: forKey, cost: 2)
}
}
totalCostLimit도 역시 countLimit과 마찬가지로 엄격하게 관리되지는 않습니다. 하지만 근접한값으로 관리해주기 때문에 이런식으로 관리해주는 것은 메모리적으로 나쁘지 않다고 생각합니다.
