[iOS] 이미지 캐싱 기록

어흥·2024년 10월 6일
0

iOS

목록 보기
10/10

😱 문제 상황

이번 프로젝트에서 API를 통해서 특정 리스트를 받아오고 테이블 뷰를 통해서 보여주는 화면이 있었습니다.

각 셀이 보여줘야 하는 데이터는 이름, 프로필 이미지가 있었습니다.

셀이 가지고 있는 모델은 아래와 같았습니다.

struct CellModel {
		let info: String
		let imageURL: String
}

따라서 cell이 업데이트될 때 마다 서버통신을 하여 URL을 통해 이미지를 로드해야 했습니다. 동일한 이미지인데 반복적으로 서버통신을 통해서 이미지를 로드하는 과정이 필요할까요? .

개선이 필요합니다! 그래서 저는 Cache를 이용하기로 했습니다.

어떤 방향으로 캐싱을 수행하려고 했는지 기록하려고 합니다.

☘️ 캐시를 적용해서 개선하기

애플에서 제공하는 캐시는 NSCache와 URLCache 2가지가 있습니다.

저는 NSCache를 이용했습니다. ( 이유는 따로 없습니다… 추후 NSCache와 URLCache를 비교해보도록 하겠습니다.)

기존에는 indexPath를 Key로 설정하여 value인 이미지를 접근하는 것으로 설정했는데 해당 화면이 아닌 다른 화면에서는 값을 알 수 없기 때문에 키를 URL로 변경했습니다.

1️⃣ 1. 캐싱 용량 제한하기

NSCache size를 관리하는 방법은 countLimit, totalCostLimit 2가지가 있습니다.

countLimit은 캐시가 보유하는 최대 object수를 설정하여 관리하는 것이고 totalCostLimit은 cache에 있는 object를 삭제하기 전에 보유할 수 있는 최대 cost를 설정하여 관리하는 것입니다.

🗣️ 저는 totalCostLimit을 이용하여 캐시의 사용량을 제한하기로 결정했습니다.

NSCache를 이용하여 이미지를 캐싱하려고 하는데 이미지와 같은 리소스는 해상도, 파일 형식과 같이 여러 요소에 따라서 용량에서 큰 차이가 있을 수 있어 이미지 개수로 캐시를 제한하는 것이 적합하지 않다고 판단했기 때문입니다.

이렇게 캐시를 용량을 기준으로 제한하기로 결정했는데…. 문제는 제한 용량을 어떻게 정하지? 였습니다. …

용량이 너무 많으면 메모리를 너무 많이 소모하고 너무 적다면 또 캐싱을 적용한 효과가 미미할 거 같아서.. ‘적당히‘ 그 기준을 잡아야 할 거 같은데 이 부분이 잘 모르겠어서 일단 5MB로 정하였습니다.

final class CacheManager {
    ...    
    let cache: NSCache<urlString, CacheableImage> // CacheableImage는 제가 별도로 선언한 타입입니다. 
    
    private init() {
        cache = NSCache<NSString, CacheableImage>()        
        cache.totalCostLimit = 5 * 1024 * 1024 // 5MB
    }
    ...
}

2️⃣ 2. 디스크 캐시 활용하기

캐싱을 사용하는 방식 중 하나는 disk cache를 함께 활용하는 것입니다.

memory cache 보다는 속도는 느리지만 서버통신과 비교하면 cost가 더 적게 발생하기 때문에 disk cache를 사용하기로 결정했습니다.

Disk Cache를 UserDefault와 FileManager로 구현할 수 있는데요.

🗣️ 저는 Filemanager의 Cache Directory에 저장하기로 결정했습니다.

FileManager에서 CacheDirectory를 지원해주기 때문입니다.

final class CacheManager {    
    let cache: NSCache<urlString, CacheableImage>
    let fileManager: FileManager
    let logger = Logger(subsystem: "CacheManager", category: "cache")
    
     private var cacheDirectoryURL: URL? {
		    fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first
		 }
  

    private init() {
        cache = NSCache<NSString, CacheableImage>()
        cache.totalCostLimit = 5 * 1024 * 1024 // 5MB
        fileManager = FileManager()
    }
}

FileManager의 Cache Directory 주요 특징은 OS가 필요에 따라 저장공간을 확보하기 위해 캐시 데이터를 삭제한다는 것입니다.

이외에도 공식문서를 살펴보니 다음과 같이 동작된다고 합니다.

  • iOS 2.2 이상: 이 디렉토리의 내용은 iTunes나 iCloud에 의해 백업되지 않는다. 또한, 시스템은 장치를 완전히 복원할 때 이 디렉토리의 파일을 삭제한다.
  • iOS 5.0 이상: 시스템은 디스크 공간이 매우 부족할 경우, 드물게 Caches 디렉토리를 삭제할 수 있다. 하지만 앱이 실행 중일 때는 절대 발생하지 않는다. 그러나 백업에서 복원하는 것 외에도 Caches 디렉토리가 삭제될 수 있는 다른 조건이 있을 수 있음을 인지해야 한다.

중간 정리를 하자면 제가 정한 캐싱 방식은 다음과 같습니다.

🗓️ 캐싱 방식

  1. 이미지가 memory cache에 존재하는지 확인합니다.
  2. miss라면 disk cache에 존재하는지 확인합니다.
    • hit 라면 해당 이미지를 memory cache에 추가합니다.
  3. miss라면 서버 통신을 통해서 이미지를 받아옵니다.
    • 해당 이미지 disk cache에 추가합니다. (이때 memory cache에 따로 추가해야 할까요? )

이렇게 디스크 캐시를 함께 활용하여 캐싱을 한다면 효율적으로 메모리 캐시의 용량을 관리할 수 있을 것입니다.

하지만 여기서 문제가 발생합니다. 어떤 문제일까요?

😱 프로필 이미지의 URL은 변경되지 않은채 이미지 자체는 업데이트될 수 있습니다. 프로필 이미지가 변경되었는데 URL이 동일한다면 cache에서 계속 hit이 되어 변경된 이미지가 반영하지 않습니다.

Cache에 있는 리소스가 서버에 있는 리소스와 동일한지 확인하는 절차가 필요합니다.

따라서 ETag를 통해서 해당 리소스가 동일한지 확인하는 절차가 필요합니다.

⚡️ ETag를 활용하여 리소스가 동일한지 확인하기

ETag는 서버에서 리소스를 식별할 수 있는 고유한 문자열입니다.

서버의 내용이 변경될 때마다 새로운 ETag 값을 생성하여 클라이언트에게 제공합니다. 클라이언트는 이 값을 저장하고 이후, 같은 리소스를 요청하기 전에 이 값을 보내어 리소스가 변경되었는지 확인할 수 있습니다.

따라서 Cache가 hit되더라도 동일한 리소스인지 확인하기 위해 ETag값과 함께 request 보내 해당 리소스가 변경되었는지 확인하는 절차를 추가해야 합니다.

따라서 이미지와 함께 ETag를 저장할 수 있도록 타입을 선언했습니다.

final class CacheableImage { // NSCache에 저장되는 구조 - class만 사용가능 
    let eTag: NSString
    let image: UIImage
    
    init(eTag: NSString, image: UIImage) {
        self.eTag = eTag
        self.image = image
    }
}

struct CacheableData: Codable { // Disk Cache에 저장되는 구조 
    let eTag: String
    let image: Data
    
    init(eTag: String, image: Data) {
        self.eTag = eTag
        self.image = image
    }
}

이미지를 받아올 때 ETag와 함께 받아왔고 ETag값을 함께 요청보내서 동일한 리소스인지 확인하는 코드를 추가했습니다.

func verifyETag(from etag: String, onCompleted: @escaping (ETagResult) -> Void) {
        ...
            switch httpResponse.statusCode {
            case 304:
                onCompleted(.success(true))
            default:
                onCompleted(.success(false))
            }
        }
}

제가 세운 캐싱 방식에 대해서 정리해 보겠습니다.

📃 정리

캐싱 방식

  1. 이미지가 memory cache에 존재하는지 확인합니다.
    • hit 라면 ETag를 통해서 동일한 이미지인지 확인합니다.
    • miss라면 disk cache로 이동합니다.
  2. 이미지가 disk cache에 존재하는지 확인합니다.
    • hit 라면 ETag를 통해서 동일한 이미지인지 확인합니다.
    • miss라면 서버통신을 수행합니다.
  3. 서버 통신을 통해서 이미지를 받아옵니다.
    • 해당 이미지 memory cache, disk cache에 추가합니다

지금까지 제가 이미지 캐싱을 적용한 방법에 대해서 작성했습니다.

아직까지 부족한 점이 많습니다. NSCache는 제한된 용량이나 개수가 넘으면 알아서 삭제된다고 하지만 삭제되는 시점이 언제이고 메모리 부족 현상이 일어나지는 않을지에 대해서 나중에 공부해보겠습니다…

부족한 내용일 수 있고 잘못된 내용이 포함될 수 있습니다! 언제든지 편하게 지적해주세요!

0개의 댓글