- 이미지 캐싱으로 유명한 라이브러리인 KingFisher가 있다.
KingFisher 라이브러리의 코드를 분석해보면서, 이미지 캐싱을 직접 구현해보는 것이 좋을 것 같다고 판단하여 글을 정리해본다.
캐시 메모리와 디스크 메모리
1. 캐시 메모리
- 속도: 매우 빠름, RAM에 저장되어 바로 접근이 가능
- 수명: 앱 실행 중에만 유지되며, 앱이 종료되면 사라짐
- 크기 제한: 일반적으로 제한적, 메모리 부족 시 자동으로 데이터가 제거될 수 있음
2. 디스크 메모리
- 속도: 상대적(캐시 메모리)으로 느림. 파일 시스템에 저장되어 디스크 접근이 필요
- 수명: 앱이 종료되어도 데이터가 유지됨
- 크기 제한: 상대적으로 큰 저장 공간을 가지고 있음
전체 프로세스
1. 캐시 메모리에서 이미지 찾기
- 먼저, 메모리 내 캐시(NSCache)에서 이미지를 검색하고, 존재하면 바로 사용한다.
2. 디스크 메모리에서 이미지 찾기
- 캐시 메모리에 이미지가 존재하지 않을 경우, 디스크(FileManager)에서 이미지를 검색하고, 존재하면 바로 사용한다.
3. URL에서 이미지 다운로드
- 디스크에도 이미지가 없다면, 주어진 URL을 통해 이미지를 다운로드 한다.
4. 이미지 캐시 및 디스크 저장
- 다운로드 된 이미지를 메모리 캐시와 디스크에 저장하여, 다음 사용 시 빠르게 접근할 수 있게 한다.
NSCache
- Key-Value 쌍을 일시적으로 저장하는 변경 가능한 컬렉션이다.
- 시스템 메모리를 과도하게 사용하지 않도록 여러 자동 추방정책을 적용한다. ex) 다른 앱이 메모리를 필요로 할 때, 캐시의 일부 항목을 제거하여 메모리 사용량을 최소화
- 다른 스레드에서 동시에 NSCache를 조회, 추가, 제거 등을 수행해도 락 없이 안전하게 작업을 수행할 수 있다.
FileManager
- 파일과 디렉토리(폴더)들이 저장되어 있는 파일 시스템과 상호작용할 수 있는 인터페이스
- 파일의 위치를 지정할 때는 'NSURL' 클래스 사용이 권장된다. 왜냐하면 'NSURL' 은 경로 정보를 내부적으로 더 효율적으로 변환할 수 있기 때문이다.
- FileManager를 이용하여 디스크 캐싱을 진행
디스크 캐싱
- Filemanger를 통해 파일을 저장할 때 여러 디렉토리가 있고, 각 디렉토리는 특성을 가지고 있다.
1. 캐시 디렉토리(cachesDirectory)
- 목적: 재생성 가능한 데이터를 저장하기 위한 임시 저장소, 앱이 다운로드 한 데이터나 생성한 데이터를 저장하는 데 적합하다.
- 특징: 시스템에 의해 자동으로 정리될 수 있다. 즉, 디스크 공간을 확보하기 위해 캐시 파일을 제거할 수 있음
2. 문서 디렉토리(documentDirectory)
- 목적: 사용자 데이터나 앱이 생성하고 관리하는 중요한 문서 파일을 저장한다. 앱이 필수적으로 사용하는 파일을 저장하는 데 적합하다.
- 특징: 사용자에 의해 직접 제거되지 않는 이상, 파일은 유지된다.
어떤 디렉토리를 선택할 것인지는 데이터의 종류, 상황 등에 맞게 판단해야 한다~~~!
(여기서는 캐시 디렉토리에 저장할 것임)
저장할 객체 구현
final class StorageObject {
let imageData: Data
private let expiration: StorageExpiration
var estimatedExpiration: Date // 만료 날짜
init(imageData: Data, expiration: StorageExpiration) {
self.imageData = imageData
self.expiration = expiration
self.estimatedExpiration = expiration.estimatedExpirationSince()
}
var isExpired: Bool {
return estimatedExpiration.isPast
}
}
- 이미지의 데이터 형태를 저장하는 것을 알 수 있다.
(expiration, estimatedExpiration 등 데이터의 만료에 관한 부분은 다음 글(캐시 정책)에서 설명할 예정)
Memory Storage
/// 캐시 메모리 저장소
final class MemoryStorage {
// MARK: - Property
private let storage: NSCache = {
let cache = NSCache<NSURL, StorageObject>()
return cache
}()
// MARK: - Init
init() { }
// MARK: - CRUD
/// 데이터 저장
func store(_ imageData: Data, forKey key: NSURL) {
let storageObject = StorageObject(imageData: imageData, expiration: .seconds(300))
storage.setObject(storageObject, forKey: key)
}
/// 데이터 조회
func value(forKey key: NSURL) -> StorageObject? {
return storage.object(forKey: key)
}
/// 저장된 모든 데이터 제거
func removeAll() {
storage.removeAllObjects()
}
}
- 캐시 메모리에 관한 처리를 수행하는 객체
- 기존에는 ImageCache 객체 내에서 캐시, 디스크 관련 작업을 함께 처리했다.
하지만 이런 방식은 SOLID 원칙에 어긋나기 때문에 책임을 분리하여 캐시 관련 작업은 MemoryStorage에서 담당하도록 수정했다.(가독성 및 유지보수 용이)
Disk Storage
/// 디스크 메모리 저장소
final class DiskStorage {
// MARK: - Property
private let fileManager = FileManager.default
private var directoryURL: URL? // 이미지가 저장되어 있는 디렉토리 URL
// MARK: - Init
init() {
createDirectory()
directoryURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("Bridge")
}
// MARK: - CRUD
/// 데이터 저장
func store(_ imageData: Data, identifier: String) {
guard let resultURL = directoryURL?.appendingPathComponent(identifier) else { return }
let storageObject = StorageObject(imageData: imageData, expiration: .days(7))
do {
// 디스크 캐싱
try storageObject.imageData.write(to: resultURL)
} catch {
print("데이터 저장 실패 \(error.localizedDescription)")
}
}
/// 데이터 조회
func value(with identifier: String) -> Data? {
guard let resultURL = directoryURL?.appendingPathComponent(identifier) else {
return nil
}
guard fileManager.fileExists(atPath: resultURL.path) else {
return nil
}
return try? Data(contentsOf: resultURL)
}
/// 저장된 모든 데이터 제거
func removeAll() {
guard let directoryURL else { return }
do {
try fileManager.removeItem(at: directoryURL)
print("저장된 디렉토리 제거 완료")
} catch {
print("저장된 디렉토리 제거 실패: \(error)")
}
}
// MARK: - Helpers
/// 데이터를 저장할 디렉토리 생성(존재하지 않을 경우)
private func createDirectory() {
guard let directoryURL else { return }
if !fileManager.fileExists(atPath: directoryURL.path) {
do {
try fileManager.createDirectory(
atPath: directoryURL.path, withIntermediateDirectories: true
)
} catch {
print("디렉토리 생성 실패: \(error)")
}
}
}
}
- 디스크 캐싱과 관련된 처리를 담당하는 객체
- 특정 디렉토리를 생성하여 이미지를 저장하고 관리한다.
ImageCache 구현
final class ImageCache {
// MARK: - Property
static let shared = ImageCache()
private let memoryStorage = MemoryStorage()
private let diskStorage = DiskStorage()
// MARK: - Init
private init() { }
// MARK: - Load
func load(url: URL, completion: @escaping (UIImage?) -> Void) {
}
}
- 싱글톤으로 관리하는 이유는 앱 전반에서 일관성 있는 데이터 관리를 위함이다.
- load 메서드에서 메인 기능을 수행할 예정
load
func load(url: URL, downsampleSize: CGSize, completion: @escaping (UIImage?) -> Void) {
// 1. 캐시 메모리에서 이미지 데이터 찾기
if let object = memoryStorage.value(forKey: url as NSURL) {
let image = UIImage(data: object.imageData)
DispatchQueue.main.async {
completion(image)
}
return
}
// 2. 디스크 메모리에서 이미지 데이터 찾기
if let diskImageData = diskStorage.value(with: url.lastPathComponent) {
let image = UIImage(data: diskImageData)
DispatchQueue.main.async {
completion(image)
}
// 캐시에 이미지 데이터 저장
memoryStorage.store(diskImageData, forKey: url as NSURL)
return
}
// 3. 이미지 다운로드
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self else { return }
if let error {
print(error.localizedDescription)
DispatchQueue.main.async {
completion(nil)
}
return
}
guard let data else {
DispatchQueue.main.async {
completion(nil)
}
return
}
let image = UIImage(data: data)
DispatchQueue.main.async {
completion(image)
}
self.memoryStorage.store(data, forKey: url as NSURL)
self.diskStorage.store(data, identifier: url.lastPathComponent)
}
.resume()
}
- 위 프로세스에서 설명했듯이, 순서에 맞게 작업을 수행한다.
적용하기
/// URL을 가지고 이미지를 다운로드하여 적용하는 메서드
/// 캐시, 디스크 메모리에 해당 이미지가 존재한다면 가져와서 적용
/// - Parameter urlString: 이미지가 위치한 URL의 문자열 형태
/// - Parameter size: 다운샘플링 할 이미지의 크기
/// - Parameter placeholderImage: 이미지 적용에 실패했을 경우, 적용되는 플레이스홀더 이미지
func setImage(
from urlString: URLString?,
size: CGSize,
placeholderImage: UIImage? = UIImage(named: "profile.small")
) {
// 유효하지 않은 URL인 경우 플레이스홀더를 이미지로 설정
guard let urlString, let url = URL(string: urlString) else {
image = placeholderImage
return
}
// 기존 이미지 제거
image = nil
// 인디케이터 설정
let indicator = addActivityIndicator()
ImageCache.shared.load(url: url, downsampleSize: size) { [weak self] image in
guard let self else { return }
UIView.transition(with: self, duration: 0.3, options: .transitionCrossDissolve) {
indicator.removeFromSuperview()
// 이미지가 없으면 플레이스홀더 적용, 있으면 해당 이미지 적용
self.image = image == nil ? placeholderImage : image
}
}
}
- UIImageView의 extension 메서드
- UX적인 요소를 개선하기 위해서 이미지 로드 중에는 인디케이터가 이미지 뷰에서 돌아갈 수 있도록 했고,
로드에 실패하면 placeholder 이미지를 적용하도록 구현
Reference
- https://developer.apple.com/documentation/foundation/nscache
- https://developer.apple.com/documentation/foundation/filemanager
'개린이 이야기' 카테고리의 다른 글
캐시정책에 관하여 (0) | 2024.01.24 |
---|---|
Downsampling(다운샘플링)에 관하여 (0) | 2024.01.20 |
MVVM(Model-View-ViewModel)에 관하여 (0) | 2023.07.12 |
Git Flow에 관하여 (0) | 2023.06.26 |
Git에 관하여 (0) | 2023.06.25 |