[Swift] Image caching(이미지 캐싱)

o_jooon_·2024년 3월 27일
1

swift

목록 보기
3/11
post-thumbnail

그동안 이미지 처리를 Kingfisher에만 의존하여 하였습니다.
지난 프로젝트에서 메모리 사용량을 줄이기 위해 이미지 다운 샘플링과 캐싱에 대해 공부해봤는데, 이번에는 라이브러리 없이 직접 구현하며 공부해 보았습니다.

전체 코드


NSCache

먼저 캐싱은 간단하게 말하면, 자주 사용하는 데이터나 값을 미리 복사해 놓은 ‘임시’ 장소입니다.
캐시에 데이터나 값을 미리 복사해 놓으면 계산이나 접근 시간 없이 빠른 속도로 데이터에 접근할 수 있기 때문에 많은 분야에서 사용됩니다.

캐시에 관해 깃허브에 정리했던 내용

Swift에서는 주로 서버에서 이미지들을 받아올 때 사용하는데, 이미지를 매번 서버에서 불러오는 것보단 캐시에서 읽어오는게 훨씬 빠르겠죠??

우선, Swift에서 캐싱을 하기 위해선 NSCache를 사용해야 합니다.
Document에 정의된 NSCache입니다.

리소스가 부족할 때 제거될 수 있으며 임시로 키-값 쌍을 사용하는 변경 가능한 컬렉션 이라고 하네요.
말 그대로, 딕셔너리와 같이 Key-Value 쌍으로 값이 존재하며 임시로 저장이 됩니다.
좋은 점은 캐시가 메모리를 너무 많이 사용하지 않도록 자동으로 캐시를 제거해주기도 한다네요.

이미지를 저장하기 위해 보통 아래와 같이 KeyType은 NSString, ObjectType은 UIImage를 사용합니다.

let cache = NSCache<NSString, UIImage>()

여기서 드는 의문점이 있습니다.
왜 String이 아닌 NSString이 쓰이냐 라는 것인데요!
NSCache가 Objective-C 기반 클래스(NSObject를 상속)이기 때문에, Swift와 Objective-C의 호환성을 위해 NSString을 쓴다고 합니다.
Swift에서 String을 as NSString으로 변환시켜주면 간단하기 때문에 아주 조금 불편해요.

이미지 캐싱을 위해선 <Key(NSString): Value(UIimage)>와 같이 저장하고 꺼내 쓰는 것이죠.
그럼 간단한 프로젝트와 함께 예시 코드를 보여드리겠습니다.


구현

이번에 진행한 프로젝트는 크게 다음과 같은 과정을 거칩니다.

  1. 서버에서 이미지의 url을 받아온다.
  2. 해당 이미지가 캐시에 존재하면, 캐시에서 이미지를 불러온다.
  3. 캐시에 이미지가 없으면, url과 UIimage(data: )를 통해 얻는다.
  4. 이미지를 CollectionView의 Cell을 통해 보여준다.

캐시는 메모리 캐시 -> 디스크 캐시 순으로 탐색합니다.
메모리 캐시에 존재하는 파일 들은 앱이 종료되면 사라지고, 디스크 캐시에 존재하는 파일들은 앱이 종료되어도 사라지지 않아요!

한 번에 코드를 올리기엔 가독성이 떨어지기 때문에 캐싱 관련 코드만 작성했고, 전체 코드는 깃허브 링크에서 확인이 가능합니다.

캐싱 관련 class

Cacheable

protocol Cacheable {
    func convertToKey(from url: URL) -> String
}

extension Cacheable {
    func convertToKey(from url: URL) -> String{
        let s = url.absoluteString
        let key = String(stride(from: 0, to: s.count, by: 5).map {
            s[s.index(s.startIndex, offsetBy: $0)]
        })
        
        return key.replacingOccurrences(of: "/", with: "")
    }
}

이런 쓸데 없는 프로토콜은 구현한 이유는 제가 이미지 url를 받아오는 곳의 파일 이름이 모두 같아서 구분이 안되기 때문입니다..ㅠ
기존에 생각했던 방식은 url의 마지막 값인 이미지의 이름을 Key로 하여 캐시에 저장하려 했으나, 모두 같은 이름이라 제대로 구현이 되지 않아 url 자체를 임의로 잘라 중복을 최대한 줄였습니다.
해당 프로토콜을 캐시를 위한 두 class에 상속할 거에요.

CacheOptions

enum CacheOption {
    case onlyMemory
    case onlyDisk
    case both
    case nothing
}

Kingfisher에 옵션을 설정하여 캐싱을 할 수 있는 기능이 있길래 초라하지만 따라해보았습니다..

  • onlyMemory -> 메모리에만 캐시 저장
  • onlyDisk -> 디스크에만 캐시 저장
  • both -> 메모리와 디스크 모두에 캐시 저장(기본값)
  • nothing -> 캐시 저장 안함

ImageCache

class ImageCache: Cacheable {
	static let shared = ImageCache()
    private let memoryCache = NSCache<NSString, UIImage>()	// 메모리 캐시
    
    private init() {}
    
	func loadImage(
        _ url: URL?,
        _ option: CacheOption = .both,
        completion: @escaping (UIImage?) -> Void) {
        	// 이미지 불러오기 (메모리 -> 디스크 -> url 순)
    	}
   
   	private func saveImage(_ image: UIImage, _ url: URL, _ option: CacheOption) {
          // 이미지 메모리 캐시에 저장하기
    }
}

ImageCache의 구조는 다음과 같습니다.
전체적인 캐시를 관리해 줄 싱글톤 객체입니다.
loadImage()와 saveImage() 메서드를 통해 이미지를 캐시에서 불러오거나, 메모리 캐시에 저장합니다.
메모리 캐시가 아닌 전체적인 캐시를 관리하는 class로 만든 이유는.. 메모리 캐시 관련 작업의 코드가 매우 짧아서 합쳤습니다 ㅎㅎ...

ImageCache.loadImage()

func loadImage(
        _ url: URL?,								// 이미지의 url
        _ option: CacheOption = .both,				// 캐시 옵션 설정(Kingfisher와 비슷하게 구현해봤어요)
        completion: @escaping (UIImage?) -> Void)	// UIImage를 옵셔널로 반환
    {
        guard let url = url else {					// 유효한 url이 아니면 nil 반환
            completion(nil)
            return
        }
        
        let key = convertToKey(from: url)			// 위에서 설명한 임의 키값 설정
        
        // 가장 먼저 메모리 캐시에 해당 이미지가 존재하는지 체크 (Key를 이용)
        if let cachedImage = memoryCache.object(forKey: key as NSString) {
        	// 존재 한다면 이미지를 불러오고 종료
            print("Load image from Memory")
            completion(cachedImage)
            return
        }
        
        // 메모리 캐시에 존재하지 않는다면 디스크 캐시 체크
        // DispatchQeueue를 써준 이유: 이미지 용량에 따라 시간이 걸릴 수 있기 때문에
        // 							디스크 캐시 확인과 url에서 불러오는 작업은 백그라운드에서 비동기로 처리
        DispatchQueue.global().async {
        	// 디스크 캐시에 해당 이미지가 존재하는지 체크
            if let cachedImage = DiskCache.shared.loadImage(url) {
                // 존재 한다면 메모리 캐시에 저장하고 이미지 반환 후 종료
                self.saveImage(cachedImage, url, option)
                completion(cachedImage)
                return
            }
            // 디스크 캐시에도 존재하지 않는다면 url에서 데이터를 가져와 이미지화
            if let imageData = try? Data(contentsOf: url),
               let image = UIImage(data: imageData) {
                // 가져온 이미지를 메모리 캐시와 디스크 캐시에 각각 저장 후 이미지 반환
                print("Download the image.")
                self.saveImage(image, url, option)
                DiskCache.shared.saveImage(image, url, option)
                completion(image)
            } else {
                print("Not able to download the image.")
                completion(nil)
            }
        }
    }

이미지를 불러오는 과정은 다음과 같이 구현하였습니다.
1. url이 유효한지 체크
2. 메모리 캐시에 이미지가 존재하는지 체크
3. 없으면 디스크 캐시에 이미지가 존재하는지 체크
4. 없으면 url을 통해 받아옴

ImageCache.saveImage()

private func saveImage(_ image: UIImage, _ url: URL, _ option: CacheOption) {
		// 옵션에 따라 캐싱 여부 결정
        if option == .onlyDisk || option == .nothing {
            return
        }
        
        // Key를 통해 NSCache 객체에 이미지 저장
        let key = convertToKey(from: url) as NSString
        memoryCache.setObject(image, forKey: key)
        print("Save image to memory")
    }

네. 메모리 캐시에 저장 자체는 아주 간단합니다. Key를 통해 값을 저장해주기만 하면 끝입니다.

DiskCache

class DiskCache: Cacheable {
    static let shared = DiskCache()
    private let fileManager = FileManager.default
    
    private init() {}
    
    func loadImage(_ url: URL) -> UIImage? {
        // 디스크 캐시에서 이미지 불러오기
    }
    
    func saveImage(_ image: UIImage, _ url: URL, _ option: CacheOption) {
        // 디스크 캐시에 이미지 저장
    }
    
    private func checkPath(_ url: URL) -> String? {
        // 디스크 경로 체크
    }
}

디스크 캐시 관련 코드는 다음과 같습니다.
디스크의 특정 경로에 파일로 저장하기 때문에 FileManager를 이용합니다.
ImageCache와 마찬가지로 싱글톤 객체로 구현해주었습니다.

DiskCache.checkPath()

 private func checkPath(_ url: URL) -> String? {
 	let key = convertToKey(from: url)			// key 생성
 	let documentsURL = try? fileManager.url( 	// 캐시 directory 경로 탐색
    	for: .cachesDirectory,
    	in: .userDomainMask,
    	appropriateFor: nil,
    	create: true)
    let fileURL = documentsURL?.appendingPathComponent(key)	// 원하는 파일의 최종 경로
    
    return fileURL?.path						// String으로 반환
}

디스크의 특정 경로를 반환하는 역할을 해주는 메서드입니다.
캐시 directory에 접근 후, key에 해당하는 경로를 반환합니다.

파일이 존재하지 않아도 nil을 반환하지 않아서 검색해 본 결과,
fileManager.url() 자체가 유효하지 않거나 잘못된 접근 권한, 예기지 못한 에러 발생 시에 nil을 반환하고,
대부분의 경우엔 경로를 잘 반환하다고 합니다.

사용한 fileManager.url() 메서드의 역할은 다음과 같습니다.

func url(
    for directory: FileManager.SearchPathDirectory,
    in domain: FileManager.SearchPathDomainMask,
    appropriateFor url: URL?,
    create shouldCreate: Bool
) throws -> URL
  • directory: FileManager에서 접근할 directory를 설정합니다.
    -> SearchPathDirectory 타입에는 아주 많은 종류가 있는데, 캐시를 저장할 예정이기 때문에 .cachesDirectory로 캐시 전용 directory에 접근해줍니다.
  • domain: FileManager에서 접근할 domain을 설정합니다.
    -> SearchPathDomainMask 타입도 마찬가지로 많은 종류가 있지만, .userDomainMask로 현재 사용자 전용 directory에 접근해줍니다.
    -> document에 따르면, .allDomainMask 은 사용할 수 없다고 합니다. 보안 문제 때문에 개인 사용자의 데이터 접근을 막으려고 하는 것 같네요.
  • url: 반환되는 URL을 결정합니다.
    -> directory에 .itemReplacementDirectory, domain에 .userDomainMask 두 조건이 아닌 이상 무시된다고 하네요. 캐시 directory를 사용하기 때문에 nil로 처리했습니다.
  • shouldCreate: 지정한 경로에 directory가 없으면 생성할 지 결정합니다.
    -> Bool 값에 따라 생성 여부를 결정하는데, true로 설정해줍니다.

따라서, 해당 메서드는 (Damin 이름)/Library/ ~~~ /(캐시 directory) 와 같은 캐시 파일이 저장되어 있는 경로를 Optinal(URL) 타입으로 반환합니다.

해당 프로젝트의 경우는 다음과 같은 경로가 설정되네요.
/Users/joon/Library/Developer/CoreSimulator/Devices/29679AB7-2958-418E-8C16-D6A726AC98EF/data/Containers/Data/Application/26DB8EFD-295D-477A-8661-5F4635FD6DF2/Library/Caches/

let fileURL = documentsURL?.appendingPathComponent(key)

해당 코드는 위에서 받은 경로(URL 타입)에 key를 추가하여 key에 해당하는 파일의 최종 경로를 만들어줍니다.
(Damin 이름)/Library/ ~~~ /(캐시 directory)/(key에 해당하는 파일) 과 같은 형태의 경로가 생성되겠죠??
이제 해당 url을 .path를 통해 String으로 만들어준 후 반환합니다.

위에 아주 긴 경로에 key가 추가되는 겁니다. key가 abcdef 라고 한다면,
/Users/joon/Library/ ~~~ / Library/Caches/abdedf 와 같이 경로가 설정되는 것이죠.

DiskCache.loadImage()

func loadImage(_ url: URL) -> UIImage? {
	if let filePath = checkPath(url), fileManager.fileExist(atPath: filePath) {
    	print("Load image from Disk")
    	return UIImage(contentsOfFile: filePath)
    }

	return nil
}

디스크 캐시에서 이미지를 불러오는 역할을 하는 메서드 입니다.
checkPath()를 통해 경로를 확인한 후, .fileExist()를 통해 경로에 파일이 존재하는지 확인합니다.
이미지가 존재하는 경우, 해당 이미지를 반환합니다.

이미 if let filePath = checkPath(url) {} 을 통해 확인할 수 있지 않나?
라고 생각 할 수는 있지만, 해당 코드는 경로 자체를 반환합니다.
.fileExsit()를 통해 파일 자체가 존재하는 지를 한번 더 확인해 주는 겁니다.

DiskCache.saveImage()

func saveImage(_ image: UIImage, _ url: URL, _ option: CacheOption) {
	if option == .onlyMemory || option == .nothing {
    	return
    }
        
 	if let filePath = checkPath(url),
    	!(fileManager.fileExists(atPath: filePath)) {
        if fileManager.createFile(
        	atPath: filePath,
            contents: image.jpegData(compressionQuality: 1.0),
            attributes: nil) {
            print("Save image to Disk.")
        } else {
        	print("Not able to save image to Disk.")
        }
    }
}

메모리 캐시와 같이 CacheOption에 따라 디스크 캐시에 이미지의 저장 여부를 결정해줍니다.
캐시 이미지의 경로를 확인한 후, 경로에 파일이 존재하는지 확인합니다.
파일이 존재하지 않으면 .createFile()을 통해 파일을 생성해 주는 것이죠.

사용한 fileManager.createFile()의 역할은 다음과 같습니다.

func createFile(
    atPath path: String,
    contents data: Data?,
    attributes attr: [FileAttributeKey : Any]? = nil
) -> Bool
  • path: 새로운 파일을 생성할 경로입니다.
    -> 캐시 파일의 경로를 지정해 주어야 하기 때문에 filePath 로 경로를 지정해줍니다.
  • data: 새로운 파일의 데이터입니다. saveImage() 에서는 .jpegData를 통해 JPEG 형식의 이미지를 저장해 주었습니다.
    -> 이미지의 형태로 저장해 주어야 하기 때문에 image.jpeg()로 데이터를 지정해줍니다.
    -> .jpeg는 compressionQuality로 압축 퀄리티를 지정할 수 있습니다. PNG 형식으로 저장이 가능하지만, JPEG가 PNG보다 용량이 적기 때문에 JPEG 형식으로 지정하였습니다.
  • attr: 새로운 파일의 속성을 정의합니다. 기본값은 nil입니다.
    -> 따로 속성을 지정해줄 필요가 현재는 없으니 nil로 지정해줍니다.
    -> FileAttributeKey를 통해 접근 권한 또는 날짜 및 시간 등등 다양한 속성을 지정해 줄 수 있습니다.

해당 메서드를 통해 디스크 캐시 directory에 이미지 파일을 생성합니다.


이렇게 캐싱 관련 코드들을 모두 작성해 보았습니다. 이제, 이 코드를 어떻게 활용하는지 알아봅시다.
나머지 코드들은 제가 임의로 CollectionView를 통해 이미지를 보여주는 것이기 때문에, 캐싱을 사용하는 부분만 보여드리겠습니다.

cache 사용

ViewController에서 API를 통해 데이터를 받아오는 메서드,
CollectionView에서 Cell을 보여주는 메서드,
Cell에서 이미지 뷰를 업데이트 해주는 메서드

이 세 코드로 이미지 캐싱이 어떤 식으로 진행되는지 보도록 합시다.

Viewcontroller

과정은 다음과 같습니다.
1. SearchBar를 통해 영화 제목을 검색하면, fetchMovies()를 통해 입력한 text와 맞는 영화의 썸네일들을 API를 통해 받아옵니다.
2. 데이터를 받아오면, CollectionView를 reload합니다.
3. CollectionView cell의 이미지를 업데이트 하여 보여줍니다.

fetchMovies()

private func fetchMovies(_ text: String) {
	API.searchMovies(text) { [weak self] movies in
    	self?.movies = movies
        DispatchQueue.main.async {
        	self?.collectionView.reloadData()
        }
    }
}

영화 제목을 검색하면 입력한 text를 매개 변수에 담아 실행합니다.
네트워킹 작업은 URLSession으로 구현하였고, movies를 통해 썸네일들의 url과 제목을 배열 형태로 담아 보내줍니다.
CollectionView의 reload 작업은 UI 관련 작업이기 때문에, 비동기로 실행되는 네트워킹 후에 DispatchQueue.main.async을 통해 메인 스레드에서 해주어야 합니다.

collectionView()

func collectionView(
	_ collectionView: UICollectionView,
    cellForItemAt indexPath: IndexPath)
-> UICollectionViewCell {
	guard let cell = collectionView.dequeueReusableCell(
    	withReuseIdentifier: Cell.id,
        for: indexPath) as? Cell else {
        return UICollectionViewCell()
    }
        
    let imageURL = URL(string: movies[indexPath.item].thumbnailPath)
    ImageCache.shared.loadImage(imageURL) { image in
    	DispatchQueue.main.async {
        	cell.updateImageView(img: image)
        }
    }
        
    return cell
}

네트워킹을 통해 받은 영화 배열에서 썸네일이미지 url들을 URL 타입으로 바꾸어줍니다.
그 후, ImageCache를 통해 이미지를 보여줍니다.
과정은 다음과 같아요.

  1. 네트워킹을 통해 영화의 썸네일 이미지 url들을 받아온다.
  2. 해당 url을 ImageCache를 통해 메모리 캐시, 디스크 캐시에 존재하는지 확인한다.
  3. 존재한다면 캐시에서 꺼내와 Cell의 이미지를 업데이트 한다.
  4. 존재하지 않는다면 다운로드하여 Cell의 이미지를 업데이트 한다.

Cell

func updateImageView(img: UIImage?) {
	imageView.image = img
}

이미지 뷰 하나만 만들어 주었고, 이미지를 변환해주는 메서드를 작성했습니다.


실행

자, 그럼 캐싱 관련 코드와 UI 관련 코드들을 작성했으니 직접 실행해봐야겠죠??

이미지가 캐시에 존재하지 않는 경우

CacheOption이 기본값(.both)

이미지가 메모리 캐시와 디스크 캐시에 모두 존재하지 않기 때문에,
새로 다운로드하고 캐싱 옵션이 기본값이니 두 캐시에 각각 저장해줍니다.

CacheOption이 .onlyMemory인 경우

다운로드 후 메모리 캐시에만 이미지를 저장합니다.

CacheOption이 .onlyDisk인 경우

다운로드 후 디스크 캐시에만 이미지를 저장합니다.

CacheOption이 .nothing인 경우

다운로드 후 이미지를 어디에도 저장하지 않습니다.

이미지가 캐시에 존재하는 경우

이미지가 디스크 캐시에 이미 존재하는 경우는 앱을 종료 후 실행해도 캐시에 남아있게 됩니다.
해당 화면은 디스크 캐시에 존재하는 경우입니다.

처음에는 앱이 종료되었었기 때문에 메모리 캐시는 비어있고 디스크 캐시에만 이미지가 존재합니다.
그렇기 때문에, 처음 동일한 키워드 검색을 하면 디스크 캐시에서 이미지를 꺼내와 메모리 캐시에 저장하고 UI를 표시해 주는 것이죠.

가장 밑에 Load image from Memory는 두 번째로 동일한 키워드 검색을 한 경우입니다.
위의 과정에서 디스크 캐시에서 이미지를 꺼내와 메모리 캐시에 이미지를 저장했죠?
그렇기 때문에, 디스크 캐시까지 탐색하지 않고 메모리 캐시만 탐색하여 이미지를 불러와 UI를 표시해 주었습니다.


느낀점

편하게 Kingfisher만 사용해 오다 직접 이미지 캐싱을 구현해보니 새롭고 재미있었습니다.
Alamofire를 사용하기 전에 URLSession을 사용해 보아야 하듯이, Kingfisher 사용 전에 진작 이미지 처리 관련 기본 코드들을 공부해볼걸 그랬네요 ㅎㅎ..

다음 포스팅은 아마 이미지 다운 샘플링 관련 작업일 것 같아요.
프로젝트를 진행하면서 이미지 관련 작업으로 메모리 사용량을 대폭 줄인 경험이 있었기 때문에, 캐싱에 대한 것도 알게 되었고 다운 샘플링에 대한 것도 알게 되었습니다. 둘 다 Kingfihser로 쉽게 처리해버림
라이브러리에 의존했던 코드들을 기본적인 작업으로만 구현하며 Swift에 대해 더욱 깊이 알아가려고 합니다!

profile
안녕하세요.

1개의 댓글

comment-user-thumbnail
2024년 11월 11일

좋은 글 감사합니다! 저도 Kingfisher로만 적용해봤는데,, NSCache로 직접 구현 해보니 훨씬 이해도가 올라가는 것 같네요 ㅎㅎ 감사합니다!

답글 달기