본문 바로가기
개린이 이야기

이미지 캐싱에 관하여

by iOS 개린이 2024. 1. 18.

- 이미지 캐싱으로 유명한 라이브러리인 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

- https://github.com/onevcat/Kingfisher

'개린이 이야기' 카테고리의 다른 글

캐시정책에 관하여  (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