[iOS/Swift] Kingfisher를 쓰지 않고 이미지 캐싱 구현하기 - NSCache

네트워크 통신을 통해 이미지를 받아와야 하는 경우

지금까지는 Kingfisher라는 라이브러리를 사용해 해당 이미지를 처리했었다.

 

그러던 와중에 외부 라이브러리를 쓰지 않고, 이미지를 로드하고 캐싱하는 기능을 구현하고 싶었다.

 

이번 글에서는 이미지 캐싱에 대해 알아보고, MemoryCash와 DiskCashe를 실제로 구현해보도록 하겠다.


이미지 캐싱이란?

먼저 캐시라는 개념에 대해 알아보도록 하자.

캐시는 컴퓨터 과학 에서 데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다.
캐시에 데이터를 미리 복사해 놓으면 계산이나 접근 시간없이 더 빠른 속도로 데이터에 접근할 수 있다.

즉, 캐싱이란 데이터를 미리 캐시라는 별도에 장소에 복사해놓고, 이후에 접근할 때 더 빠른속도로 접근할 수 있게 하는 것을 말한다.

 

예를 들어, 네트워크 통신을 통해 이미지를 받아올 때 이미지들을 캐시에 저장해놓으면, 이후에 같은 검색어로 다시 검색했을때 조금 더 빠르게 이미지를 로드할 수 있다.

 

그리고 iOS에서 이미지 캐싱을 구현하는 방식은 두 가지가 있다.

 

1. 메모리 캐시

  • NSCache를 이용해 이미지를 메모리에 저장
  • 앱이 종료되면 메모리가 해제 -> 이미지가 캐시에서 삭제됨
  • 처리 속도가 빠름

2. 디스크 캐시

  • FileManager를 이용해 이미지를 디스크에 저장
  • 앱이 종료되어도 이미지는 남아있음
  • 메모리 캐시에 비해 처리 속도가 느림

이제 각 캐싱을 코드로 어떻게 구현하는지 살펴보겠다.


메모리 캐시

먼저, 이미지를 어디에 캐싱할지 옵션을 다음과 같이 정의해놓을 수 있다.

enum SaveOption {
    case onlyMemory
    case onlyDisk
    case both
    case none
}

이제, 개발자는 이미지 캐싱 정책에 따라 메모리 또는 디스크에 저장할지를 선택할 수 있다.

 

다음으로 url을 key로써 사용하게 될텐데, url 전체가 아닌 특정 부분을 key로 사용하기 위한 메서드도 정의해줄 수 있다.

protocol Cacheable {
    /// URL로부터 고유한 Key를 추출하는 함수
    func createKey(from url: URL) -> String
}

extension Cacheable {
    func createKey(from url: URL) -> String {
        let stringURL = url.absoluteString
        let stringArray = stringURL.split(separator: ".")
        return String(stringArray[stringArray.count - 2])
    }
}

이제 이 `Cacheable`프로토콜을 채택한 클래스는 url를 key로써 사용할 수 있다.

 

다음으로는 NSCache를 이용한 메모리 캐시 구현부이다.

final class MemoryCache: Cacheable {
    static let shared = MemoryCache()
    private let cache = NSCache<NSString, UIImage>()
    
    private init() { }
    
    func loadImage(url: URL) -> UIImage? {
        let key = createKey(from: url)
        return cache.object(forKey: key as NSString)
    }
    
    func saveImage(image: UIImage, url: URL, option: SaveOption) async {
        guard option != .onlyDisk, option != .none else { return }
        
        let key = createKey(from: url) as NSString
        cache.setObject(image, forKey: key)
    }
}

NSCache는 key-value쌍을 임시로 저장하는데 사용되는 변경 가능한 Collection이다.

 

위에서 정의한 createKey메서드를 통해 url을 key값으로, 해당 이미지를 캐시에 저장할 수 있다.


디스크 캐시

다음으로 디스크캐시 코드의 구현부이다.

final class DiskCache: Cacheable {
    static let shared = DiskCache()
    private var fileManager = FileManager.default
    
    private init() { }
    
    func loadImage(url: URL) async -> UIImage? {
        guard let filePath = checkPath(url),
              fileManager.fileExists(atPath: filePath) else { return nil }
        
        return await withCheckedContinuation { continuation in
            DispatchQueue.global(qos: .background).async {
                let image = UIImage(contentsOfFile: filePath)
                continuation.resume(returning: image)
            }
        }
    }
    
    func saveImage(image: UIImage, url: URL, option: SaveOption) async {
        guard option != .onlyMemory, option != .none else { return }
        
        guard let filePath = checkPath(url),
              !fileManager.fileExists(atPath: filePath) else { return }
        
        let imageData = image.jpegData(compressionQuality: 1.0)
        
        await Task {
            if fileManager.createFile(atPath: filePath,
                                    contents: imageData,
                                    attributes: nil) {
                print("💾 Disk에 저장했습니다.")
            } else {
                print("⚠️ Disk 공간이 부족합니다.")
            }
        }.value
    }
    
    // URL로 fileManager 내에서 데이터를 찾을 fileURL 생성
    private func checkPath(_ url: URL) -> String? {
        let key = createKey(from: url)
        
        /// Home 디렉토리에 있는 Cache 디렉토리 경로
        let documentsURL = try? fileManager.url(
            for: .cachesDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: true)
        let fileURL = documentsURL?.appendingPathComponent(key)
        
        return fileURL?.path
    }
}

코드의 흐름은 다음과 같다.

`checkPath` 메서드를 통해 URL로 fileManager 내에서 데이터를 찾을 fileURL을 생성하고,

이 메서드로 FileManager 내에 파일이 존재하는지 여부를 확인한다.

 

파일이 존재할경우 해당 파일을 load하고, 존재하지 않을 경우에 save하는 메서드가 정의되어있다.

 


ImageCacheManager 구현

이제 위의 메모리 캐시와 디스크 캐시 구현부를 토대로, 실제 네트워크 통신에서 이미지를 처리하는 ImagaCacheManager를 구성해보겠다.

 

네트워크 통신부는 URLSession과 Swift Concurrency의 async await으로 구성하였다.

final class ImageCacheManager {
    enum CacheError: Error {
        case invalidURL
        case loadFail
    }
    
    static let shared = ImageCacheManager()
    
    private var memoryCache = MemoryCache.shared
    private var diskCache = DiskCache.shared
    
    private init() { }
    
    func load(url: URL?, saveOption: SaveOption) async throws -> UIImage? {
        guard let url else {
            throw CacheError.invalidURL
        }
        
        // Memory Cache에 존재하면 바로 반환
        if let cachedImage = memoryCache.loadImage(url: url) {
             print("💿 Memory Cache에서 로드")
            return cachedImage
        }
        
        // Disk Cache에서 로드 (비동기 처리)
        if let cachedImage = await diskCache.loadImage(url: url) {
            await memoryCache.saveImage(image: cachedImage, url: url, option: saveOption)
             print("💾 Disk Cache에서 로드")
            return cachedImage
        }
        
        // 서버에서 데이터 요청 (비동기 URLSession 사용)
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            guard let image = UIImage(data: data) else {
                print("❌ 서버로부터 이미지 로드 실패 (데이터 변환 오류)")
                return nil
            }
            
            // 캐시에 저장 (비동기 처리)
            await memoryCache.saveImage(image: image, url: url, option: saveOption)
            await diskCache.saveImage(image: image, url: url, option: saveOption)
             print("🌐 서버에서 로드", url)
            
            return image
        } catch {
            print("❌ 서버로부터 이미지 로드 실패: \(error.localizedDescription)")
            throw CacheError.loadFail
        }
    }
}

코드의 흐름은 다음과 같다.

  • 메모리 캐시에 존재하면 해당 이미지를 로드한다.
  • 디스크 캐시에 존재한다면 해당 이미지를 로드한다.
  • 캐시에 이미지가 존재하지 않다면, 서버에서 데이터를 요청해서 saveOption에 따라 메모리 또는 디스크에 이미지를 저장한다.

 

이렇게 만든 ImageCacheManager를 실제 코드에서 사용할땐 다음과 같다.

func configure(_ imageURL: String) {

		// ...
		        
    guard let imageUrl = URL(string: imageURL) else { return }
    
    Task {
        do {
            let image = try await ImageCacheManager.shared.load(url: imageUrl, saveOption: .onlyMemory)
            thumbImageView.image = image
        } catch {
            print("이미지 로드 실패")
            thumbImageView.image = UIImage(systemName: "xmark.octagon")
        }
    }
}

 

 

이제 캐싱을 구현하지 않을때와, 구현했을때의 차이를 보겠다

네크워크 통신을 할 때 이미지 로드 속도

 

캐시에서 이미지를 불러올 때

 

이렇게 해서 이미지 캐싱을 직접 구현해보았다.

다만, 단순히 구현에서 그칠게 아니라 효율적인 캐싱 정책을 만드는것이 더 중요하다.

즉, 어떤 경우에 디스크에 혹은 메모리에 저장할 것인가?

또 어떤 시점에 캐시에 올라간 데이터를 삭제할 것인가?

 

이런 정책을 세워서 효율적으로 캐시를 관리하는게 더 중요할 것이다.


*참고자료
https://xerathcoder.tistory.com/279

 

[Swift 기능] Image Caching 처리 in UIKit

서론안녕하세요! 개발자 제라스입니다! 👋🏻🤖👋🏻너무 오랜만에 돌아왔습니다 ㅠㅠ요새 프로젝트들을 하고 개인 공부를 하다보니 포스팅이 많이 늦어졌네요... 오늘은 Image Caching(이미지

xerathcoder.tistory.com