저번 글에서 이미지를 업로드하는 여러 가지 방법에 대해 살펴봤다면, 이번 글에서는 그 이미지들을 메모리 차원에서 캐싱해 놓고 재사용해줄 수 있는 방법에 대해 정리해보려 합니다!
그리고 사실 공부하면서 메모리 캐싱까지만 정리하고 만족하려고 했지만 ㅎㅎ 스터디 함께하시는 분이 흥미로운 키워드를 던져주셔서 다음 글에서는 디스크 캐싱까지 정리해보려 합니다. 어떤 키워드인지는 디스크 캐싱 글에서 공개!
📍 Cache캐시란?
캐시..캐싱...이미지 캐싱... 많이는 들어봤는데 일단 Cache의 의미가 무엇인지부터 제대로 알아보시죠.
- 캐시(cache)는 데이터나 값을 미리 복사해 놓는 임시 장소를 뜻한다
- 캐시에 데이터를 미리 복사해 놓으면 계산이나 접근 시간 없이 더 빠른 속도로 데이터에 접근할 수 있기 때문에, 캐시에의 접근 시간에 비해 원래 데이터를 접근하는 시간이 오래 걸리는 경우나 값을 다시 계산하는 시간을 절약하고 싶은 경우에 사용한다.
- 캐시는 시스템의 효율성을 위해 여러 분야에서 두루 쓰이고 있다.
오늘 우리가 코드로 볼 예시처럼 주로 서버에서 이미지들을 받아올 때 사용하는데, 이미지를 매번 서버에서 불러오는 것보단 캐시에서읽어오는 게훨씬 빠르겠죠??
📍 iOS에서의 Cache
근데 우리가 궁금한 건 iOS 환경에서는 어떻게 캐시를 할 수 있는가! 우리가 만든 앱에서는 어떻게 캐싱을 할 수 있는가! 일 것입니다.
iOS에서 사용하는 Cache는 두 가지로 나뉘는데 메모리 캐시(Memory Cache)와 디스크 캐시(Disk Cache)입니다.
메모리 캐시(memory cache)
- iOS에서 자체적으로 제공해 주는 캐시 (NSCache)를 통해 메모리 캐시를 관리함
- But, 앱이 종료되면 메모리가 해제 -> 저장한 데이터가 날아감
- 저장공간이 작다는 단점이 있으나 처리속도는 빠르다는 장점이 있음
메모리 캐시는 말 그대로 메모리를 사용하는 것이기 때문에 앱 실행 효율을 생각해서 영리하게 사용해야 할 것 같다. 나중에 다시 언급하겠지만 캐싱에 사용되는 NSCache라는 것이 시스템적으로 필요하지 않은 항목이라고 생각했을 때는 폐기한다고 하긴 하지만 메모리가 많이 사용되고 있다는 것은 인지하면서 관리해주어야 할 것 같다.
디스크 캐시(disk cache)
- 캐시에 저장할 데이터를 기기 내부에 아카이빙 하는 방식으로 App을 껐다가 켜도 데이터가 사라지지 않고 남아있다
- FileManager객체를 사용하여 데이터를 파일 형태로 디스크에 저장할 수 있음
- 저장공간이 크긴 하나 처리속도는 비교적 느림 (물론 네트워크를 이용한 데이터 다운로드보단 훨씬 빠름)
📍 캐싱 과정
난 처음에 캐싱하는 과정이 왜 이렇게나 복잡한 건지 의아했다.
디스크 캐싱까지 다 보고 나서 이러한 과정이 이해가 되었고, 이렇게 여러 검색, 검증의 과정을 거쳐도 네트워킹해서 이미지를 다운로드하는 것보다 빠르니까 캐싱이라는 것을 사용해 주는 게 아닐까?!
- 필요한 이미지가 생기면,
- 먼저 메모리 캐시에서 해당 이미지를 검색
- 없는 경우, 디스크 캐시에서 해당 이미지를 검색
- 없는 경우, URLString으로 이미지를 네트워크 다운로드
- 메모리 캐시와 디스크 캐시에 해당 이미지를 저장
- 다음번 요청 시에는 메모리 캐시에서 이미지를 불러옴
- 프로세스(앱) 재시작 이후의 요청에는 디스크 캐시에서 불러온 후 메모리 캐시에 추가
📍 메모리 캐시에서 사용되는 NSCache란?
일단 모르겠으면 공식문서 고고
https://developer.apple.com/documentation/foundation/nscache
NSCache | Apple Developer Documentation
A mutable collection you use to temporarily store transient key-value pairs that are subject to eviction when resources are low.
developer.apple.com
공식문서에서는, "리소스가 부족할 때 제거될 수 있는 임시 key-value값 쌍을 임시로 저장하는 데 사용하는 변경 가능한 컬렉션" 이라고
라고 소개한다.
* 이미지를 저장하기 위해서는 보통 KeyType은 NSString, ObjectType은 UIImage를 사용하곤 한다.
* KeyType에 String이 아니라 NSString을 사용하는 이유는? NSString이 AnyObject타입이기 때문!
Overview
공식문서에 있는 정의가 처음에는 뭔 소리야 했는데 overview를 보면 감이 좀 온다.
일단, 캐시 객체는 몇 가지 면에서 다른 변경 가능한 컬렉션들(Mutable Collections)과 다르다고 한다. 밑에 내용을 보면,
- 이 NSCache클래스에는 캐시가 시스템 메모리를 너무 많이 사용하지 않도록 보장하는 다양한 자동 제거 정책이 통합되어 있습니다. 다른 애플리케이션에 메모리가 필요한 경우 이러한 정책은 캐시에서 일부 항목을 제거하여 메모리 사용량을 최소화합니다.
- Thread-Safe하게 구현되어 있어 따로 lock하지 않아도 다른 스레드에서 캐시의 항목을 추가, 제거, 검색할 수 있다.
- NSMutableDictionary 객체 와 달리 캐시는 캐시에 저장된 주요 객체를 복사하지 않습니다.
- 일반적으로 NSCache개체를 사용하여 생성 비용이 많이 드는 임시 데이터가 포함된 개체를 임시로 저장합니다. 이러한 개체를 재사용하면 해당 값을 다시 계산할 필요가 없으므로 성능상의 이점을 얻을 수 있습니다. 그러나 객체는 애플리케이션에 중요하지 않으며 메모리가 부족한 경우 폐기될 수 있습니다. 폐기되면 필요할 때 해당 값을 다시 계산해야 합니다.
요약하면 다른 컬렉션들과 다르게 시스템적으로 필요한 경우 캐싱되었던 메모리를 폐기할 수 있고, thread-safe한 특성을 가졌다는 것이다.
* 여기서 "자동 제거 정책"으로 직역된 "auto-eviction policies"에 대해서는 여러 의견이 있는 것 같은데 정확한 정보는 아니어서 일단 패스!
Dictionay와 비교해 보자
우리가 종종 사용하는 Dictionay와 형태가 크게 다르지 않아서(둘 다 key-value pairs 형태) Dictionay와 비교했을 때의 차이점을 보면 좀 더 와닿을 것 같다.
특수 목적성이 없다면 NSCache를 사용해야 할 때 Dictionary를, Dictionary를 사용해야 할 때 NSCache를 사용하는 실수를 저지를 수도 있을 것 같은데, 위의 차이점을 기반으로 보면, NSCahce의 경우 디바이스의 메모리가 부족하면 캐시된 데이터를 삭제하기도 하기 때문에 구분해서 사용해주어야 하고, NSCache는 여러 개의 스레드에서 접근할 때 따로 lock을 걸지 않아도 thread-safe하게 동작하는 특성을 가지고 있다는 점이 다르다!
📍 NSMutableDictionary객체와 달리 cache는 캐시에 저장된 주요 객체를 복사하지 않습니다 ??
공식문서의 overview에서 살펴본 NSCache가 다른 컬렉션들과 다른 몇 가지 항목들 중에서 "NSMutableDictionary 객체와 달리 cache는 캐시에 저장된 주요 객체를 복사하지 않습니다." 이 항목만 제대로 이해가 가지 않았다
NSMutableDictionary와 NSCache가 둘 다 컬렉션인 거 알겠는데 ㅇㅇ
왜? 어떻게? NSMutableDictionary는 객체를 복사하고 cache는 복사하지 않는단말임???
이해한 바 대로 정리해 보겠다! ( 생각보다 몰랐던 개념이 많아서 글 길어짐 주의.. ㅠㅠ)
https://inuplace.tistory.com/1050
여기 블로그 글에서 NSMutableDictionary와 NSCache의 차이점을 볼 수 있는 스택오버 플로우의 코드를 소개해줬는데 NS 타입들은 아직 너무 낯설어서.. 이해하는데 한세월 ..이해하기 위해 NSCopying까지 찍먹하고 온건 안 비밀 ㅎ 일단 소개된 코드를 봐보자
NSMutableDictionary
let mutableDict = NSMutableDictionary()
var dictKey: NSMutableString = "key1"
mutableDict.setObject(1, forKey: dictKey)
print("mutableDict => ", mutableDict) //dicKey를 키로 하는 value가 세팅된다
dictKey.setString("key2")
mutableDict.setObject(2, forKey: dictKey)
print("mutableDict => ", mutableDict) //"key2" 키가 추가된 dictionary
print(mutableDict.object(forKey: "key1")) //Optional(1)
print(mutableDict.object(forKey: "key2")) //Optional(2)
NSCache
let cache = NSCache<NSString, NSNumber>()
var cacheKey: NSMutableString = "key1"
cache.setObject(1, forKey: cacheKey)
print("cache => ", cache)
cacheKey.setString("key2")
cache.setObject(2, forKey: cacheKey)
print("cache => ", cache)
print(cache.object(forKey: "key1")) //nil
print(cache.object(forKey: "key2")) //Optional(2)
NSMutableDictionary와 NSCache 인스텐스에 대해 같은 메서드를 사용해서 비슷하게 처리를 해줬음에도 불구하고 print를 찍어서 보면 다른 결과가 나오는 것을 확인할 수 있다.
왜 그러는 걸까?
setObject 메서드
일단 NSMutableDictionary와 NSCache의 setObject 메서드를 보면 차이점이 있는데
NSMutableDictionary의 setObject
NSCache의 setObject
* 여기서 KeyType은 NSCache클래스의 제네릭 타입이었음. 즉, anyObject 타입.
NSCopying 프로토콜을 을 준수하는 key값이냐 아니냐가 우리가 중점적으로 봐야 하는 차이점이다!
그럼 NSCopying이 뭐길래..?
(후.. 모르는 걸 파고 파다 보면 끝도 없음 ㅠㅠ 하지만 재밌다..!)
NSCopying이란?
기본적으로 값타입과 참조 타입의 복사 방식은 다르다.
값타입은 깊은 복사(Deep Copy), 참조 타입은 얕은 복사(Shallow Copy) 를 하는데, 얕은 복사하던 참조타입도 깊은 복사 할 수 있도록 하는 것이 바로 NSCopying이다.
이렇게만 설명하면 뭐 어떻게 이해하라고.. 라는 생각이 들 테니 코드 예시를 통해 깊은 복사와 얕은 복사부터 천천히 보자
값타입의 깊은 복사 ((Deep copy))
struct Person {
var name: String = ""
init(name: String) {
self.name = name
}
}
var person = Person(name: "person1")
var copyPerson = person
print("person: \(person.name)") //person1
print("copyPerson: \(copyPerson.name)") //person1
copyPerson.name = "person2"
print("person: \(person.name)") //person1
print("copyPerson: \(copyPerson.name)") //person2
- 값타입(구조체)를 복사 할당하더라도 인스턴스의 변화가 다른 인스턴스에 영향을 주지 않는다. 이게 바로 깊은 복사이다.
- 깊은 복사는 메모리를 더 많이 소비한다
* 참고
깊은 복사에서는 메모리를 더 많이 소비한다는 단점을 보완하기 위해 채택한 기술이 바로 COW, CopyOnWrite이다.
이 COW를 통해 값타입의 복사가 발생한 직후에는 얕은 복사를 하고, 한쪽의 데이터가 변하면 그때 비로소 깊은 복사를 진행하여 메모리를 최대한 낭비하지 않을 수 있도록 한다.
* COW에 대해서는 여기서 좀 더 자세하게 설명해 놨다!
[Swift] mutating 키워드와 COW(Copy On Write)
참조타입의 얕은 복사 ((Shallow copy))
class Person {
var name: String = ""
init(name: String) {
self.name = name
}
}
let person = Person(name: "person1")
let copyPerson = person
print("person: \(person.name)") //person1
print("copyPerson: \(copyPerson.name)") //person1
copyPerson.name = "person2"
print("person: \(person.name)") //person2
print("copyPerson: \(copyPerson.name)") //person2
- 참조타입(클래스)를 복사 할당하면 인스턴스의 변화가 같은 메모리 주소를 참조하는 다른 인스턴스에 영향을 준다. 이게 바로 얕은 복사!!
- 얕은 복사는 같은 메모리 주소를 참조하므로 메모리를 절약할 수 있게 된다.
위에서 봤듯이 값타입은 COW라는 기술을 통해서 얕은 복사를 한번 거치고 나서 깊은 복사를 할 수 있게 되었다.
그럼 참조타입은 얕은복사밖에 못하는 걸까??
답은 No No. 여기서 등장하는 개념이 NSCopying이다.
* NSCopying 프로토콜
참조타입도 NSCopying 프로토콜을 채택하여 깊은 복사를 할 수 있다.
- NSCopying 프로토콜의 필수 구현 메서드 copy(with : )
- 새로운 인스턴스를 생성해서 반환하므로 (어쩌면 당연히) 깊은 복사가 발생할 수 있게 되는 것!
class Person: NSCopying {
var name: String
init(name: String) {
self.name = name
}
//NSCopying 프로토콜이 요구하는 copy 메서드에서는 새로운 Person 인스턴스를 생성하여 반환
func copy(with zone: NSZone? = nil) -> Any {
return Person(name: self.name)
}
}
var person = Person(name: "person1")
var copyPerson = person.copy() as! Person //copy()를 호출해서 인스턴스 복사
copyPerson.name = "person2"
print("person: \(person.name)") //person1
print("copyPerson: \(copyPerson.name)") //person2
공식문서의 정의를 보면 "객체가 자신의 기능적 복사본을 제공하기 위해 채택하는 프로토콜" 이라고 하는데 으흠 이제 이해가 간다!
NSMutableDictionary의 setObject 메서드
자 그럼 다시 NSMutableDictionary의 setObject로 돌아와 보자. 위에 적었던 코드 예시도 함께 보자.
let mutableDict = NSMutableDictionary()
var dictKey: NSMutableString = "key1"
mutableDict.setObject(1, forKey: dictKey)
print("mutableDict => ", mutableDict) //dicKey를 키로 하는 value가 세팅된다
dictKey.setString("key2")
mutableDict.setObject(2, forKey: dictKey)
print("mutableDict => ", mutableDict) //"key2" 키가 추가된 dictionary
print(mutableDict.object(forKey: "key1")) //Optional(1)
print(mutableDict.object(forKey: "key2")) //Optional(2)
NSMutableDictionary의 setObject메서드에서 forKey 파라미터는 NSCopying을 준수해야 한다고 했다.
이 의미는.
- → NSCopying을 준수하는 NSString의 서브 클래스인 NSMutableString가 forKey 파라미터의 인자로 들어오는 게 가능한 것! 을 의미하고
- → setObject를 하면 key에 대해 참조를 복사(얕은 복사)하는 게 아니라 값을 복사(깊은 복사)를 할 수 있게 되어 ( key가 NSCopying프로토콜을 준수하기 때문)
- → 객체 자체가 복사되었기 때문에 key1에 대한 value도, key2에 대한 value도 조회가 가능한 것이다.
NSCache의 setObject 메서드
NSCache도 위에 코드를 다시 가져와서 설명해 보면,
let cache = NSCache<NSString, NSNumber>()
var cacheKey: NSMutableString = "key1"
cache.setObject(1, forKey: cacheKey)
print("cache => ", cache)
cacheKey.setString("key2")
cache.setObject(2, forKey: cacheKey)
print("cache => ", cache)
print(cache.object(forKey: "key1")) //nil
print(cache.object(forKey: "key2")) //Optional(2)
NSMutableDictionary와는 다르게 NSCache는 AnyObject 타입(참조타입)을 key로 갖지만 NSCopying을 준수하진 않기 때문에 setObject할 때 같은 참조를 가진 key라면 객체를 복사하는 게 아니라 그 참조를 가진 value를 바꾸게 된다는 것!!!
와.. 이해하니까 보이는데 이해 못 했을 때는 그저 막막했다고 한다..
NSMutableDictionary와 NSCache의 차이점이 있다는 공식문서의 한 줄을 모르겠어서 이거 이해하는데만 반나절을 써버렸당🤯
나름대로 이해를 했지만 내가 해석하고 정리한 게 맞는지 검증해야 하니 gpt에게 한 번 더. 물어보았다!!
후하.. 다행히 내 뇌피셜이 어느 정도 맞는듯하다..짜릿해
그리고 gpt에 물어보고 한 가지 더 인사이트를 얻은 게 있다면 NSCache에서는 키 객체가 복사(깊은복사)되지 않기 때문에, 메모리 관리 및 성능 측면에서 이점을 제공할 수 있다는 것! 우리가 이미지 캐싱에 사용하려는 NSCache는 어쨌든 우리의 메모리를 사용하려는 건데 이러한 작동 원리로 조금이나마 메모리가 낭비되지 않도록 하는 것 같다.
같은 컬렉션이라는 뿌리를 가지고 있더라도 이렇게 필요에 따라 달라지는 작동 방법 재밌써..
📍 실제 프로젝트 적용 코드 (memory cache 방법)
앱이 실행되는 동안 하나의 NSCache Collection에 이미지가 저장되고 가져오고 해야 하기 때문에 ImageCacheManager는 싱글톤 패턴으로 만들어놓고
final class ImageCacheManager {
static let shared = NSCache<NSString, UIImage>()
private init() {}
}
위에서 본 캐싱 과정에서는 디스크 캐시까지 합쳐진 과정이라고 한다면,
메모리 캐시만의 과정을 보면 아래와 같다.
- 이미지를 네트워크에서 다운로드하기 전에 캐시된 이미지가 있는지 검색
- 캐시된 이미지가 있다면 -> 캐시된 이미지를 가져와서 image에 적용
- 캐시된 이미지가 없다면 -> 네트워크 통신을 하여 이미지를 다운로드
- 새롭게 다운로드된 이미지 캐싱
이 과정대로
캐싱된 이미지가 있는지 검증하는 것부터 이미지를 캐싱하는 1~4과정까지를 코드로 구현해 보자
extension UIImageView {
func loadImage(urlString: String) {
print("⭐️⭐️loadImage⭐️⭐️")
let cacheKey = NSString(string: urlString)
// imageCache 1) 이미지를 네트워크에서 다운로드하기 전에 캐시된 이미지가 있는지 검색
if let cachedImage = ImageCacheManager.shared.object(forKey: cacheKey) {
// imageCache 2) 캐시된 이미지가 있다면 -> 캐시된 이미지를 가져와서 image에 적용
print("⭐️⭐️loadImage⭐️⭐️ - 캐시된 이미지가 있다면")
self.image = cachedImage
return
}
// imageCache 3) 캐시된 이미지가 없다면 -> 네트워크 통신을 하여 이미지를 다운로드
print("⭐️⭐️loadImage⭐️⭐️ - 캐시된 이미지가 없다면")
self.setImageDataFromUrlString(urlString: urlString)
}
private func setImageDataFromUrlString(urlString: String) {
print("❤️❤️setImageDataFromUrlString❤️❤️")
ImageLoadManager.shared.downloadImage(urlString: urlString) { data in
DispatchQueue.main.async() { [weak self] in
guard let self, let image = UIImage(data: data) else {return}
let cacheKey = NSString(string: urlString)
//imageCache 4) 새롭게 다운로드된 이미지 캐싱
ImageCacheManager.shared.setObject(image, forKey: cacheKey)
self.image = image
}
}
}
}
이런 검색 결과 화면을 만든다고 가정했을 때 collectionView와 페이지네이션으로 구현해서 화면에 띄워지는 셀의 갯수만큼만 이미지가 로드된다고 하더라도 사용자가 검색했던 걸 또다시 검색했을 때 이전에 봤던 것과 동일한 이미지여도 다시한 번 네트워킹을 통해 로드되어야한다.
사실 네트워킹을통해 원격에 저장된 이미를 다운 받는다고 하더라도 지금의 기술로는 너무 찰나의 순간이기도 하고 이미지 용량이 크지 않은 이상 문제가 될만한 것은 없지만 거슬리는 점을 굳이 찾아보자면!
(( 개발자는 개선할 여지가 있고 그 방법을 안다면 응당 ㅎㅎ 시도해보아야한다,,!))
- 네트워킹 시간동안 찰나의 깜박임이지만 사용자는 다시 이미지가 로딩되는 것을 경험해야하고
- 용량이 큰 이미지라고 한다면 안그래도 이미지를 받아오는데 시간이 많이 걸리는데, 이전에 봤던 이미지를 또 불러온다고 했을 때 사용자는 또 그 시간 동안 기다려야할 것이다.
- 그리고 굳이 똑같은 이미지에 대해 계속 네트워킹을 다시해줄 필요가 있을까?라는 의문이 든다!
이런 불편한 감정이 들 때 우리가 사용할 수 있는 것이 NSCache이다.
위에 작성한 코드에 처럼 print를 찍어서 확인해보았을 때,
이전에 collectionView가 뜰 때 로드되어서 캐싱되어있던 이미지들은 네트워킹 필요 없이 바로 저장되어 있던 이미지를 꺼내서 세팅해주고, 스크롤을 밑으로 내려서 이전에 로드된 적 없던 이미지들은 네트워킹을 통해 이미지가 로드되고 캐싱되게 된다 (다음번에 똑같은 이미지를 띄워야할 때는 이 이미지들도 네트워킹이 필요 없어지겠지!!)
다음 글에서는 메모리 캐싱 뿐만 아니라 fileManager를 사용해서 디스크 캐싱까지 적용할 수 있는 방법에 대해 작성하려하고, 디스크 캐싱을 할 때 고려해보면 좋은 포인트에 대해서도 공유해보려한다!
참고
https://inuplace.tistory.com/1050
https://jeong9216.tistory.com/527
https://dev200ok.blogspot.com/2021/11/dictionary-nscache-thread-safe.html