서론
이미지 캐싱을 구현하다가 NSCache와 Dictionary의 차이점이 궁금해서 찾아보았습니다.
이미 많은 블로그에서 다룬 주제이지만 직접 실험도 해보면서 제 지식으로 만들기 위해 포스팅 해봅니다.
중간 중간 주관적인 의견도 섞여 있으니 틀렸거나 반대 의견이 있으시다면 댓글로 알려주세요.
NSCache
NSCache는 key-value 형태의 데이터를 임시로 저장하는데 사용할 수 있는 가변 컬렉션(mutable collection) 입니다.
NSCache에 의해 캐싱된 데이터는 메모리가 부족할 때 일정한 규칙에 따라 제거될 가능성이 있습니다.
NSCache는 클래스 앞에 NS가 붙는 것으로 유추할 수 있듯이, Objective-C 환경에서 구동된다는 것이 특징입니다.
NSCache는 Key, Value가 AnyObject 타입인 것을 볼 수 있는데요.
var nsCache = NSCache<NSNumber, NSNumber>()
var dictionary = Dictionary<Int, Int>()
그렇기 때문에 NSCache는 Int 타입 대신 NSNumber 타입을 사용해야 합니다.
Int, Double 등은 클래스가 아닌 구조체이기 때문에 AnyObject 타입으로 사용하지 못하기 때문입니다.
Int 대신 사용된 NSNumber는 class이기 때문에 사용이 가능합니다.
굳이 Swift에서 익숙하게 작성하고 싶다면,
final class CustomNumber {
var number: Int = 0
}
var nsCache = NSCache<CustomNumber, CustomNumber>()
이렇게 직접 클래스를 만들어서 Int 타입 프로퍼티에 접근하는 식으로 구현하면 되겠습니다.
추가로, NSCache는 내부적으로 연결 리스트와 Dictionary를 사용합니다.
연결 리스트를 이용해 아래에서 다룰 메모리 관리에서 최적화를 하였고,
Dictionary를 이용해 O(1)의 시간복잡도로 탐색을 할 수 있죠.
메모리 관리
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는 메모리가 부족해지면 자동으로 캐시 데이터를 삭제하는 정책이 포함되어 있습니다.
NSCacheKey는 값을 복사하지 않고 참조합니다.
Unlike an NSMutableDictionary object, a cache does not copy the key objects that are put into it.
key와 value에 클래스만 올 수 있기 때문에 어쩌면 당연한 내용입니다.
복사를 하지 않기 때문에 복사를 지원하지 않는 클래스 타입을 키 타입으로 사용할 수 있습니다.
여기서 전에 다뤘던 NSCopying(깊은 복사와 얕은 복사(feat. NSCopying))이 나오는데,
복사가 이루어지려면 NSCopying 프로토콜을 채택해야 하지만 NSCacheKey는 이를 준수하지 않는 클래스 타입도 키 타입으로 사용할 수 있습니다.
Dictionary는 NSCopying을 준수한 타입만 키 타입으로 사용할 수 있기 때문에 이는 장점으로 작용될 수 있습니다.
하지만, 이 내용을 공부하며 WWDC16 - Understanding Swift Performance (1) 가 떠올랐는데요.
참조 타입은 Heap에 저장되기 때문에 캐시 히트가 발생해도 키 생성 과정에서 Heap 할당이 발생하여서,
Key 타입을 구조체로 바꿔 최적화한 사례를 얘기했었습니다.
Dictionary에서는 Key에 대해 struct를 사용함으로서 최적화가 가능했지만, NSCache는 클래스를 강제하기 때문에 이런 최적화가 불가능합니다.
따라서 Key 타입 관점으로만 본다면,
반드시 복사를 지원하지 않는 클래스를 키 타입으로 사용하는 경우가 아니라면 Dictionary가 더 유리할 듯 합니다.
NSCache는 메모리 부족일 때 캐시 데이터를 자동으로 제거되는 정책이 포함되어 있습니다.
이에 대해 알아보려면 공개된 NSCache 코드를 살펴보아야 합니다.
위에서 말했듯 NSCache는 연결리스트와 Dictionary를 이용해 구현되었습니다.
특히, 연결리스트가 메모리 관리와 연관이 있는데요.
NSCache는 값을 넣을 때 cost를 기준으로 오름차순 정렬합니다.
cost는 setObject로 데이터를 넣을 때 파라미터를 통해 설정할 수 있습니다.
cost를 설정하지 않는다면 0으로 설정됩니다.
이 cost를 이용해 연결 리스트를 정렬하고,
NSCache가 가질 수 있는 최대 Cost인 totalCostLimit, countLimit에 근거하여 제거 정책을 수행합니다.
(totalCostLimit : 캐시가 가지고 있을 수 있는 최대 cost / countLimit : 캐시가 가지고 있을 수 있는 최대 object 수)
구현된 코드를 살펴보면,
cost를 계산해서 데이터를 remove하는 것을 볼 수 있습니다.
연결 리스트는 이 제거 정책을 최적화하기 위해 도입되었는데요(라는 추측... ㅎ;)
연결리스트의 head는 가장 작은 cost Entry가 배치되죠.
따라서 O(1)의 시간복잡도로 가장 Cost가 작은 Entry를 제거할 수 있습니다.
만약 배열이었다면, 재배치 연산 오버헤드가 발생했을텐데 연결 리스트를 사용하여 이를 극복했습니다.
연결리스트의 단점은 탐색을 앞에서부터 시작해야 하기 때문에 탐색 연산이 O(n)의 시간복잡도를 가진다는 건데요.
캐싱은 빠른 연산을 위해 사용하는 것인데 탐색에 O(n)이 걸린다면 유용하게 사용하지 못할 것입니다.
그래서 Dictionary를 사용하여 탐색을 진행합니다.
head 타입과 entries 딕셔너리의 Value 타입이 같은 걸 볼 수 있네요.
이렇게 Dictionary를 도입하여 탐색도 O(1)로 완료할 수 있습니다.
Thread-Safety
NSCache와 Dictionary의 가장 큰 차이점이며, NSCache의 큰 장점 중 하나입니다.
NSCache는 Thread-Safety 하지만 Dictionary는 그렇지 않습니다.
NSCache는 내부적으로 NSLock 객체를 사용합니다.
insert를 할 때도 사용되고
remove를 할 때도 사용됩니다.
따라서 데이터를 삽입, 삭제할 때 여러 개의 Thread에서 동시에 업데이트할 걱정을 하지 않아도 됩니다.
Thread-Safety 테스트
직접 플레이그라운드에서 테스트를 해보았습니다.
var nsCache = NSCache<NSNumber, NSNumber>()
var dictionary = Dictionary<Int, Int>()
NSCache, Dictionary 모두 숫자를 Key, Value 타입으로 사용했습니다.
Dictionary 테스트 코드를 먼저 보겠습니다.
func addNumber() {
usleep(200000)
guard let value = dictionary[0] else {
print("fail")
return
}
print("dictionary: \(value)")
dictionary.updateValue(value + 1, forKey: 0)
}
딕셔너리에 값이 없으면 fail을 출력하고 있다면 값을 업데이트 합니다.
맨 처음에 값을 하나 넣을 예정이니 만약 fail이 나타난다면 값이 날아간 것을 의미합니다.
NSCache 테스트 코드도 거의 똑같습니다.
func addCacheNumber() {
usleep(200000)
guard let value = nsCache.object(forKey: NSNumber(value: 0)) else {
print("cache fail")
return
}
print("cache: \(value)")
nsCache.setObject(NSNumber(value: Int(value) + 1), forKey: NSNumber(value: 0))
}
cache fail 이 출력된다면 캐시 데이터가 삭제된 것이겠죠.
이번 테스트에서는 메모리 부족으로 인한 제거가 되지 않도록 했기 때문에 삭제가 되면 안 됩니다.
dictionary[0] = 0
nsCache.setObject(NSNumber(value: 0), forKey: NSNumber(value: 0))
for _ in 0..<5 {
for i in 0..<100 {
DispatchQueue.global().async {
addNumber() //Dictionary 업데이트
addCacheNumber() //NSCache 업데이트
}
}
}
테스트 코드를 시작하기 전 Dictionary와 NSCache에 값을 하나 넣었습니다.
멀티 스레드 환경에서 동시에 Key에 접근했을 때 동작에 무슨 차이를 보이는지가 이번 테스트의 주요 포인트 입니다.
위 코드는 Dictionary와 NSCache를 동시에 수행했지만 실제 테스트는 정확한 결과를 보기 위해 하나하나 수행했습니다.
Dictionary 테스트 결과
몇 번 수행되지 못하고 크래시가 나기도 하고,
500회를 모두 수행한다고 하더라도 모든 실행에서 캐시 데이터가 삭제되었습니다.
즉, 멀티 스레드에서 안정적인 동작을 보장하지 않았습니다.
NSCache 테스트 결과
NSCache는 여러 번의 실행에도 모두 500회 수행을 완료하였고, 단 한 번도 캐시 데이터가 삭제되지 않았습니다.
물론 데이터는 Data Race의 영향으로 중복된 값이 적용되었지만... 이건 Dictionary도 동일하니 넘어가고,
NSCache는 lock과 unlock을 해주었기 때문에 멀티 스레드에서 동시에 접근하는 것을 막아줘서 크래시가 나거나 캐시 데이터가 삭제되는 것을 사전에 방지해 주었습니다.
만약 멀티 스레드 환경에서 캐싱이 필요하다면 NSCache를 필수적으로 사용해야겠네요.
그렇지만 멀티 스레드 환경이 아니라면 Dictionary가 유리할 수도 있겠습니다.
싱글 스레드라면 굳이 lock과 unlock을 하지 않아도 되니 Dictionary를 사용하는 것이 (미미하겠지만) 성능 최적화가 될 것입니다.
결론
NSCache는 많은 부분에서 Dictionary 보다 유리한 모습을 보여주었습니다.
메모리 부족 상황에서 자동으로 메모리 관리를 해주고, 멀티 스레드 환경에서 Thread-Safety 했습니다.
하지만 Key 타입, Value 타입으로 클래스가 강제되어 캐시 hit 상황에서도 Heap 할당이 이루어지는 단점이 있습니다.
Dictionary는 데이터를 자동으로 삭제해주지 않고 Thread-Safety 하지도 않지만,
Int, Double, struct 같은 값 타입을 Key 타입으로 사용할 수 있어 캐시 Hit 상황에서 Heap 할당을 피하며 성능 최적화를 할 수 있습니다.
또한, 싱글 스레드 상황에서 불필요한 lock 연산을 하지 않아 불필요한 연산을 줄일 수 있습니다.
마지막으로 Swift에 맞는 익숙한 문법을 사용할 수 있죠.
"무조건 NSCache"라기 보다는 상황에 맞춰 사용하는 것이 좋을 것 같네요.
감사합니다!
참고
https://developer.apple.com/documentation/foundation/nscache
https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/NSCache.swift
https://aroundck.tistory.com/4717
https://jeonyeohun.tistory.com/383
https://felix-mr.tistory.com/13?category=468978
https://stackoverflow.com/questions/50013131/how-to-cache-array-of-doubles-in-swift
아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감과 댓글 부탁드립니다.