iOS

iOS) URLSession + NSCache 실습(2) - NSCache

snowe 2021. 9. 15. 13:57

저번 글에 이어서 오늘은 자주 사용하는 이미지 캐싱 라이브러리인 Kingfisher의 Base인 NSCache에 대해서 알아보도록 할게요!

Caching을 하는 이유

예를들어, 서버에서 어떤 API를 호출해서 이미지를 받아온다고 한다면 우리는 그 이미지 처리를 위해 비동기 코드를 작성하곤 합니다. 가장 주된 이유는 이미지를 받아오는데에 걸리는 시간이 이미지 크기에 따라 엄청 오래걸릴 수 있기 때문이죠!

같은 이유로 사용자가 방금 불러온 이미지인데 잠깐 나갔다가 들어왔을 때 다시 API를 호출하고 이미지를 불러올 때 같은 시간이 소요된다면 비효율적이겠죠? 이미지의 크기가 크다면 클수록 더더욱 그럴 것 같아요.

그래서 우리는 기기 내부에 존재하는 임시 저장소인 Cache에 이미 불러온 이미지를 넣어두고 빠르게 꺼내 쓸 수 있게 하는거에요. 그리고 이러한 행위 자체를 Caching이라고 합니다!

Cache의 종류

Cache는 두 종류인데, Memory Cache, Disk Cache로 나뉩니다.

Memory Cache

기기를 끄면 사라지는 Cache에요. 오늘 우리가 알아볼 NSCache를 통해서 구현할 수 있습니다!

Disk Cache

메모리 캐시와 다르게 기기를 꺼도 남아있어요.
두 가지 방법으로 구현할 수 있는데, UserDefault를 사용한다면 앱을 삭제했을 때 같이 사라질 것이고, 파일 경로에 이미지를 저장한다면 앱이 삭제되더라도 캐시가 남아있게 됩니다.

필수는 아니지만 보통은 파일 경로에 저장하는 것 같습니다

Cache 구현

Caching 순서도

  1. API를 호출해서 이미지 url을 받아온다.
  2. url을 key값으로 memory cache에 저장된 데이터가 있는지 확인한다.
  3. 있으면 쓰고 없다면 disk cache에서 확인해본다.
  4. 거기도 없다면 url로 이미지를 가져온 뒤 memory cachedisk cache에 각각 저장해준다.

Memory Cache 구현

아까 memory cache는 NSCache를 통해 구현한다고 했죠?! 저는 NSCache를 아래와 같이 싱글톤 객체로 구현해주었어요!

class ImageCacheManager {
    // 키값으로 쓸타입과 캐시에 넣을 타입을 정해주면된다.
    // 내가 사용할 key값: Image URL String
    static let shared = NSCache<NSString, UIImage>()
    private init() {}
}

막간을 이용한 한가지 꿀팁
NSString에서 lastPathComponent 속성을 사용하면 url에서 파일명만 추출할 수 있다.

예를들어, www.test.com/image/test.png 라면 test.jpg만 뽑아오는 것

그래서 무튼 위에서 만든 NSCache 객체를 아래처럼 사용하여 캐싱하면 된다.

let cacheKey = NSString(string: url).lastPathComponent

// 다운로드된 이미지를 메모리 캐시에 저장
ImageCacheManager.shared.setObject(image, forKey: cacheKey as NSString)

Disk Cache 구현

Disk Cache는 File Manager를 이용해서 구현할 수 있어요.

File Manager는 파일 시스템과 상호작용하는 주요 수단으로써 파일 시스템의 내용에 대한 편리한 인터페이스라고 애플 독스가 설명해주고있어요

암튼, 이걸로 구현을 할건데 먼저 저장될 디렉토리를 찾기 위해서 NSSearchPathForDirectoriesInDomains를 사용해야해요 NSSearchPathForDirectoriesInDomains는 디렉토리 검색 경로 목록을 생성해줘요.

NSSearchPathForDirectoriesInDomains(directory: FileManager.SerachPathDirectory, domainMask: FileManager.SearchPathDomainMask, expandTilde: Bool)

여기서 첫번째 파라미터인 directory에 들어가는 기본 제공 path는 이만큼이 있어요.

그리고 expandTilde를 true로 해주면 해당 경로 뒤에 문자열을 붙여서 새로운 경로를 만들 수 있어요!

이정도로 하고 우리가 사용할 path를 아래처럼 구성할 수 있겠네요.

let cacheKey = NSString(string: url).lastPathComponent

// 1. 경로로 cache폴더를 지정
guard let path = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first else { return }

// 2. 1에서 지정한 cache경로 뒤에 key값으로 폴더 생성
let filePath = URL(fileURLWithPath: path).appendingPathComponent(cacheKey)

위에가 path, 아래가 filePath


그럼 이제 FileManager 객체를 만들고 그 안에 우리가 찾는 이미지가 저장되어있는지 체크해주면 끝!

let fileManager = FileManager()

// 이 이름으로 캐싱된 데이터 있니?
if fileManager.fileExists(atPath: filePath.path) {
    guard let imageData = try? Data(contentsOf: filePath) else {
        print("disk cache image data nil")
        return
    }

    guard let cachedImage = UIImage(data: imageData) else {
        print("disk cache image data nil")
        return
    }

    DispatchQueue.main.sync {
        print("CACHE>> Load Disk cache")
        // disk cache에서 썼으면 다음을 위해 memory cache에도 저장해주기
        ImageCacheManager.shared.setObject(cachedImage, forKey: cacheKey as NSString)
        self.image = cachedImage
    }
    return
}

전체 코드 및 설명

메모리 캐시와 디스크 캐시 처리를 따로 빼서 코드를 간소화 할 수도 있겠지만 일단은 설명을 위해 쭉 순서대로 작성해보았습니다.

import UIKit

extension UIImageView { 
    func setImageUrl(_ url: String) {

        // 캐시에 사용될 Key 값
        let cacheKey = NSString(string: url).lastPathComponent

        // MARK: Memory Cache
        // 1. `url`을 key값으로 `memory cache`에 저장된 데이터가 있는지 확인한다.
        if let cachedImage = ImageCacheManager.shared.object(forKey: cacheKey as NSString) {
            DispatchQueue.main.sync {
                print("CACHE>> Load Memory cache")
                // 불러오려는 이미지가 이미 메모리 캐시에 저장되어 있으면 캐싱된 이미지를 사용
                self.image = cachedImage
            }
            return
        }

        // 메모리 캐시에 원하는 이미지가 없다면

        // MARK: Disk Cache
        // 2. disk cache(UserDefault 혹은 기기Directory에있는 file)에서 확인
        guard let path = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first else { return }

        let filePath = URL(fileURLWithPath: path).appendingPathComponent(cacheKey)
        let fileManager = FileManager()

        if fileManager.fileExists(atPath: filePath.path) {
            guard let imageData = try? Data(contentsOf: filePath) else {
                print("disk cache image data nil")
                return
            }

            guard let cachedImage = UIImage(data: imageData) else {
                print("disk cache image data nil")
                return
            }

            DispatchQueue.main.sync {
                print("CACHE>> Load Disk cache")
                // 있다면 다음에 더 빨리 불러오기 위해 memory cache에 추가해주고 사용한다.
                ImageCacheManager.shared.setObject(cachedImage, forKey: cacheKey as NSString)
                self.image = cachedImage
            }
            return
        }

        // 이마저도 없다면
        // 3. 서버통신을 통해서 받은 URL로 이미지를 가져온다.
        DispatchQueue.global(qos: .background).async {
            if let url = URL(string: url) {
                URLSession.shared.dataTask(with: url) { (data, res, err) in
                    if let _ = err {
                        DispatchQueue.main.async {
                            self.image = UIImage()
                        }
                        return
                    }
                    DispatchQueue.main.async {
                        if let data = data, let image = UIImage(data: data) {
                            print("CACHE>> No cache I'll make new")
                            // URL로 이미지 불러와서 사용
                            self.image = image

                            // 다음을 위해 caching 해주기
                            // 다운로드된 이미지를 메모리 캐시에 저장
                            ImageCacheManager.shared.setObject(image, forKey: cacheKey as NSString)
                            // 다운로드된 이미지를 디스크 캐시에 저장
                            fileManager.createFile(atPath: filePath.path, contents: image.jpegData(compressionQuality: 0.4), attributes: nil)
                        }
                    }
                }.resume()
            }
        }
    }
}

마치며, 주의점

순수하게 구현하려니까 직접 구현해야하는 부분이 많긴해도 재밌는 것 같아요.

그리고 사실 이게 전부는 아니에요..ㅎㅎ 만약 서버에서 이미지를 교체했지만 이미지 파일명은 그대로일 경우 현재 방식대로면 파일명으로 캐시 데이터를 찾으니까 이전 이미지를 계속해서 불러오겠죠? 이러면 안되는데 말이에요

이걸 방지하기 위해서 ETag라는 리소스 식별자를 사용한다고 합니다. 사용을 위해서는 서버에도 이를 처리하기 위한 개발이 되어있어야 해요.
header부에 ETag값을 넣고 변경/미변경의 응답코드를 다르게 줌으로서 리소스 변경이 있는지 없는지를 확인할 수 있는 방법이어서 해당 문제를 해결할 수 있다고 하네요!

다음엔 이것도 공부해볼게요. 감사합니다 :)