[iOS] 이미지 캐시 모듈 구현하기 (feat. Kingfisher)
[iOS] 이미지 캐시 모듈 구현하기 (feat. Kingfisher)
부스트캠프에서 그룹 프로젝트를 진행 중, 주간 발표 세션에서 다른 팀이 이미지 캐시 모듈을 개발했다는 소식을 들었습니다.
이미지 캐시를 UIImage 의 extension 을 이용해서라던지, 혹은 클래스 하나로 분리하여 디스크에 파일을 저장해본 적은 있지만 완전히 독립적으로 동작할 수 있는 모듈은 구현해본적이 없던 터라 저희도 이미지 캐시 모듈을 구현해보고자 했습니다.
아무래도 iOS 이미지 캐시 라이브러리 중 가장 유명한 Kingfisher 를 모방해보면 좋겠다는 생각을 했고, 저희 이미지 캐시 모듈 이름은 KingReceiver 로 정했읍니다. ^.^
Kingfisher?
Kingfisher 는 이미지 캐시 라이브러리로 간단하게 이미지 캐시와 placeholder 등을 제공합니다.
Kingfisher 는 다음과 같이 KingfisherWrapper 라는 구조체와, KingfisherCompatible 이라는 프로토콜로 구성되어 있습니다.
먼저 KingfisherCompatible 은 Kingfisher 와 호환 가능한 메소드들을 사용할 수 있도록 하는 프로토콜입니다. KingfisherCompatible 을 채택하는 경우 extension 에 구현된 kf 라는 변수를 사용할 수 있게 되며, 해당 변수는 KingfisherWrapper<Self> 타입입니다.
KingfisherWrapper 는 Base 라는 제네릭 타입을 필요로 하는 구조체로써, 프로퍼티로 앞서 언급한 제네릭 Base 타입의 base 변수를 가지고 있습니다.
그래서 Kingfisher 와 호환 가능한 타입으로 하여금 KingfisherCompatible 을 준수하도록 하고, KingReceiverWrapper 를 확장하여 Base 가 해당 타입인 경우에 사용될 메소드들을 구현해주면 됩니다.
import Kingfisher
let url = URL(string: "https://example.com/image.png")
imageView.kf.setImage(with: url)
그래서 Kingfisher 를 사용한다면, 위와 같이 UIImageView 에 간단하게 이미지를 설정할 수 있습니다.
저 또한 Kingfisher 와 같이 Wrapper 객체와 Compatible 프로토콜을 이용해 KingReceiver 의 기본 틀을 생성했습니다.
Cache
뻔하지만, 캐시를 이용하게 되면 URL 을 이용한 이미지 요청이 들어온 경우 흐름은 다음과 같습니다.
Cache - Memory? Disk?
메모리를 이용할지, 디스크를 이용할지에 대한 선택이 필요했습니다. 메모리를 이용한다면 NSCache 를, 디스크를 이용한다면 Caches 디렉토리를 활용할 생각이었습니다.
그럴일은 없겠지만, 캐시를 전혀 이용하지 않는 경우도 고려하고자 했습니다. 결과적으로 3개의 경우가 필요했고, 여기서 공통되는 부분을 구현하는데 프로토콜을 이용하고자 했습니다.
3개의 경우, 공통적으로 필요한 부분은 URL 을 이용해 이미지 데이터를 가져오는 부분이었습니다. 이를 프로토콜에 fetch 라는 이름의 메소드로 선언해두고, 실제로 이미지 데이터를 가져오는 역할의 메소드는 extension 을 이용해 fetchImageData 라는 이름의 메소드로 구현했습니다.
public protocol ImageCache {
func fetch(with url: URL, completion: @escaping (Data?) -> Void)
}
extension ImageCache {
func fetchImageData(url: URL, completion: @escaping (Data?) -> Void) {
// fetch image data -> completion ...
}
}
이렇게 만들어진 ImageCache 프로토콜은 모든 경우에 채택됩니다.
결과적으로 위와 같은 관계가 생성됩니다. 메모리와 디스크 등 캐시를 사용하는 경우에는 fetch 메소드에서 fetchImageData 메소드를 호출하고, completion 으로 전달되는 결과값을 캐시에 저장합니다.
다음은 UIImageView 입니다. 위의 Kingfisher 부분에서 설명했듯, 제네릭 타입 Base 를 이용해 사용할 메소드들을 구현할 수 있습니다. 해당 모듈같은 경우에서는 KingReceiverWrapper 의 Base 가 UIImageView 인 경우에 메소드를 구현하면 됩니다.
func setImage(
with absoluteURL: String,
placeholder: UIImage? = nil,
indicator: UIActivityIndicatorView = UIActivityIndicatorView(),
cachePolicy: ImageCacheFactory.Policy = .memory,
resizing: Bool = true,
scale: CGFloat = 1
)
그렇게 구현된 setImage 메소드입니다. String 타입의 absoluteURL 을 이용해 이미지 데이터를 요청하고, 요청 실패 시 출력할 placeholder 와, 요청 중 나타낼 indicator 를 인자로 받습니다. 어떤 캐시를 사용할건지, 다운샘플링을 어느정도로 진행할 것인지에 대한 정보도 인자로 받습니다.
캐시 종류는 ImageCacheFactory 라는 싱글톤 클래스를 선언하여 리턴하도록 했습니다. 이미지 데이터 저장 시 매번 다른 캐시 객체를 사용하면 안되기 때문에 싱글톤으로 구현했습니다.
public final class ImageCacheFactory {
@frozen public enum Policy {
case none
case memory
case disk
}
private static let shared = ImageCacheFactory()
private init() {}
private lazy var noneImageCache = NoneImageCache()
private lazy var memoryImageCache = MemoryImageCache()
private lazy var diskImageCache = DiskImageCache()
static func make(with policy: Policy) -> ImageCache {
switch policy {
case .none: return Self.shared.noneImageCache
case .memory: return Self.shared.memoryImageCache
case .disk: return Self.shared.diskImageCache
}
}
}
이렇게 되면 ImageCache 프로토콜 타입의 fetch 메소드를 사용할 수 있게 되기 때문에 이미지 데이터를 요청할 수 있습니다.
Downsampling
캐시를 이용해 이미지 요청 횟수를 감소시키긴 했지만, 매번 고화질의 원본 이미지를 출력한다면 메모리에 부담이 생길 가능성이 있습니다.
이미지 한 장이면 몰라도, 이미지를 여러 장 띄워야 하는 UICollectionView 의 경우라면 더더욱 그렇습니다. 그래서 이미지 다운샘플링 메소드를 구현했습니다.
이미지 다운샘플링의 경우 몇가지 방법이 존재했습니다.
UIGraphicsBeginImageContext 를 이용하는 경우, 이미지 그래픽 포맷이 항상 픽셀당 4바이트를 사용하기 때문에 좋은 방법이 아닙니다.
UIGraphicsImageRenderer 를 이용하는 경우, 적절한 그래픽 포맷을 골라서 이미지를 렌더링하기 때문에 위의 경우보다 많은 메모리를 절약할 수 있습니다.
하지만 UIImage 를 이용해 다운샘플링 하는 경우 내부 좌표공간 변환을 필요로 하여 성능이 떨어질 수 있고, 전체 이미지의 압축을 풀게 되어 메모리 사용량도 증가할 가능성이 있습니다.
ImageIO 프레임워크를 이용하면 이미지의 dirty 메모리 비용만 지불하도록 하기 때문에 보다 개선된 다운샘플링이 가능합니다.
이미지 크기를 알려주기 위한 lower-level API 를 이용하기 때문에 개선 전보다 약 50% 나 빠른 성능을 확인할 수 있다고 합니다.
위의 내용들은 WWDC 2018 의 iOS Memory Deep Dive 라는 세션에서 참고했습니다. 다음 링크를 참고하시면 더 많은 내용들을 확인하실 수 있습니다.
Cache Limit
NSCache 의 경우 여러가지 교체 정책을 통합해서 사용한다고 합니다. 만약 다른 앱이 메모리를 필요로 하게 될 때 교체 정책에 따라 캐시 아이템을 제거한다고 합니다.
NSCache 를 살펴보면 countLimit 과 totalCostLimit 이라는 프로퍼티가 있는데, 이는 말 그대로 캐시에 저장할 수 있는 최대 아이템 개수, 그리고 최대 캐시 용량을 설정해주는 프로퍼티입니다.
이 프로퍼티들의 default 값은 둘 다 0인데, 0일 경우 따로 제한이 없다고 합니다. 아마 메모리 사용량이 너무 커지게 되면 iOS 가 메모리를 제거해주는것 같습니다.
그래도 둘 다 제한이 없는 상태에서 사용하기에는 무리가 있다고 생각했습니다. 아이템의 개수보다는 용량으로 관리하는 것이 효율적이라고 생각했고, 프로젝트에서 한 번 앱을 구동 후 사용할 시 캐시에 적재될 이미지들의 총 용량이 50MB 가 넘을 일은 없을 것 같아 50MB 로 설정하기로 했습니다.
필요 시 캐시의 용량을 조정할수도 있기 때문에, 생성자에서 파라미터로 받을 수도 있게 해놓았습니다.
디스크 캐시의 경우도 사실 용량 조정을 해주거나, 최대 아이템 개수를 조정해줘야 하지만 일단 프로젝트에서 디스크 캐시를 사용하는 경우가 없어서 아직 구현하지는 않았습니다 ,, ^.^ 차차 방법을 생각해보고 업데이트할 예정입니다.
ETag
저희는 URL 이 캐시를 관리하는 identifier 의 역할을 하기 때문에, URL 은 그대로지만 서버에서 이미지 데이터가 바뀌어버리는 경우에 대응할 방법이 없습니다.
이를 위한 해결책으로 ETag 를 활용하기로 했습니다.
이미지를 서버에 요청하는 경우, 헤더에 ETag 라는 필드로 어떤 값을 전달받게 됩니다. 이 값은 이미지 데이터가 바뀌지 않는 한 바뀌지 않습니다. 저희는 Firebase 를 이용해 이미지를 저장하기 때문에 ETag 기능을 지원했지만, 다른 경우 서버 개발자와의 합의가 필요하겠..죠..?
하지만 ETag 의 일치성을 확인하기 위해 이미지 데이터 전체를 주고받게 된다면 캐시를 사용하는 의미가 없어집니다.
이미지를 서버에 요청할 때 헤더에 If-None-Match 라는 필드에 ETag 값을 넣어서 요청하면 문제가 해결됩니다. 이미지 데이터가 변경되지 않았을 시 304 not modified 가 response 로 도착합니다. 이 때 body 는 비어있습니다. 이미지 데이터가 변경되었거나 첫 요청 (ETag 가 비어있다거나) 일때는 body 에 이미지 데이터와 함께 200 response 가 도착합니다.
하지만 여기서 문제가 발생했습니다. 서버에서 이미지 데이터가 변경되지 않은 경우 Postman 을 이용해 요청할 시 304 not modified response 가 제대로 도착했지만, URLSession 으로 요청할 시 200 response 가 도착하는 문제였습니다.
서버에서는 304 response 를 제대로 전달해줄테지만, URLSession 이 캐싱된 response 를 전달해주기 때문에 이런 문제가 발생하는 것이었습니다.
저는 URLRequest 를 생성할때 cachePolicy 를 지정해주는 방식으로 문제를 해결했습니다.
reloadIgnoringLocalAndRemoteCacheData 옵션을 선택하는 것으로 요청 시 캐시를 무시하게 하였고, 304 response 가 정상적으로 도착하는 것을 확인할 수 있었습니다.
해당 문제에 대한 해결방법은 다음 링크를 참고하였습니다.
마무리
현재까지 구현된 사항은 여기까지이며 SPM 을 이용하여 사용할 수 있도록 해놓은 상태입니다.
사실 다른 개발자분들께서 제 패키지를 사용했으면 좋겠다는 마음보다는, 생각없이 사용하던 다른 라이브러리들의 기능을 직접 구현해보며 학습하고, 앞으로 진행할 프로젝트에서도 사용해보면서 보완해야겠다 라는 의도가 큰 것 같습니다.
그리고 프로젝트 최종 발표에 이 모듈에 대한 내용을 포함시켰는데, 저희 프로젝트를 담당해주시던 멘토님께서 이미지 캐시는 어떤 문제를 해결한 것이라기보다는 당연히 해야할 일이고, 이것을 어떤 식으로 접근했는지에 대해서 더 고민해보면 좋을 것 같다는 의견을 주셨습니다.
그래서 제가 이미지 캐시를 구현하며 고민했던 부분들, 접근했던 방식들을 기록해보고자 했습니다.
혹시나 잘못된 부분이라거나 의문이 있으시다면 언제든지 댓글로 남겨주세요! 감사합니다.