Today I Learn

Swift - NSCache + Actor 기반 이미지 캐시 매니저 구현기(2)

Goniii 2025. 4. 11. 19:54

이전 포스팅에서 프로젝트에서 이미지 캐시를 적용한 이유와

NSCache + Actor 기반으로 Kingfisher 같은 라이브러리 없이

간단한 이미지 캐싱 매니저를 구현하는 과정을 소개했었다

 

2025.04.11 - [Today I Learn] - Swift - NSCache + Actor 기반 이미지 캐시 매니저 구현기(1)

 

Swift - NSCache + Actor 기반 이미지 캐시 매니저 구현기(1)

버거킹 앱을 클론 코딩한 iOS 사이드 프로젝트에서 이미지 캐시를 적용한 이유와 구체적인 적용 방법에 대해 공유하려고 한다 캐시가 왜 필요했을까? 아래는 프로젝트의 메인 화면이다 해당

soo-hyn.tistory.com

 

이번 글에서는 캐시 데이터의 만료 정책디스크 사용량 관리에 대해 소개해보려고 한다

캐시란?

자주 사용하는 데이터를 임시 저장소에 저장해두고, 다음 요청 때 원본 데이터가 아닌 캐시된 데이터를 사용하는 것

→ 캐시는 ‘임시 저장소’‘저장’ 해서 사용하는 것이기 때문에 이 데이터를 ‘언제 삭제할 것인가’, ‘어느 정도 저장할 것인가’ 를 정해줘야 한다

 

캐싱 주의점

  1. 만료 정책: 캐시된 데이터가 너무 오래되면 갱신 필요
  2. 정합성: 캐시와 실제 데이터가 다를 수 있음 → 리프레시 필요
  3. 메모리 관리: 메모리에 너무 많이 캐시하면 앱 성능에 영향
  4. 오래된 정보 사용 방지: 사용자에게 오래된 데이터를 보여주지 않도록 설계 필요

 

캐시 만료 정책이란

캐시 만료 정책은 캐시된 데이터가 언제까지 유효한지를 정하는 규칙

 

예를 들어 이미지가 네트워크를 통해 다운로드되어 캐시에 저장되었다고 할 때,

이후 같은 요청이 들어오면 계속 캐시된 이미지를 반환하게 된다

하지만 서버의 데이터가 바뀌었다면?

➡️ 캐시된 이미지가 오래된 정보가 되면서 사용자에게 오류를 줄 수 있다

 

이를 방지하기 위해 보통 다음과 같은 방식으로 TTL(Time-To-Live) 정책을 사용한다

  • ex) 이미지를 저장한 후 24시간 동안만 유효
  • 시간이 지나면 캐시는 삭제되고, 다음 요청 시 새로 다운로드하여 갱신

NSCache는 메모리를 자동으로 관리해주지만, 만료 정책 기능은 없다

이미지가 너무 자주 갱신될 필요는 없지만, 하루 이상 지난 이미지를 사용하는 것은 UX가 저하된다

→ 직접 TTL을 두면 적절한 주기로 이미지를 갱신

 

구현 목표

  • 이미지를 캐시에 저장할 때 시간 정보를 함께 저장
  • 이미지 요청 시 기간이 만료되었는지 검사
    • 만료되지 않았다면 캐시 반환
    • 만료되었으면 새로 다운로드 후 캐시 저장

 

캐시 만료 시간 저장용 Actor

이전 포스팅에서 다뤘던 비동기 상황에서 일관성 유지를 위해 만료 시간도

직렬큐를 사용하는 actor로 구현했다

final actor ExpiryDateStore {
    private var expiryDates: [String: Date] = [:]

    func getExpiryDate(for key: String) -> Date? {
        return expiryDates[key]
    }

    func setExpiryDate(for key: String, to date: Date) {
        expiryDates[key] = date
    }

    func clear() {
        expiryDates.removeAll()
    }
}
  • expiryDates 는 urlString을 key 값으로 사용하고 value로 만료 시간을 가진다
  • getExpiryDate(for:) 는 저장된 만료 시간 조회한다
  • setExpiryDate(for:) 는 TTL 기준 만료 시간 저장한다
  • clear() 는 전체 만료 기록 초기화한다

 

캐시 만료 정책이 적용된 ImageCacheManager

final class ImageLoader {
    static let shared = ImageLoader()

    private let cache = NSCache<NSString, UIImage>()
    private let taskStore = OngoingTaskStore()
    private let expiryStore = ExpiryDateStore() // 캐시 만료 시간을 저장할 액터
    private let ttl: TimeInterval = 60 * 60 * 24 // 24시간

    private init() {}

    func loadImage(for urlString: String) async -> UIImage? {
        if let cachedImage = cache.object(forKey: urlString as NSString),
           let expiryDate = await expiryStore.getExpiryDate(for: urlString), // 만료 시간 가져오기 
           expiryDate > Date() { // 유효성 확인
            print("캐시 이미지 사용")
            return cachedImage
        }

        if let ongoingTask = await taskStore.getTask(for: urlString) {
            let image = await ongoingTask.value
            print("중복 요청 방지 후 이미지 반환")
            return image
        }

        let task = Task<UIImage?, Never> {
            defer {
                Task { await taskStore.removeTask(for: urlString) }
            }

            guard let url = URL(string: urlString) else { return nil }

            do {
                let (data, _ ) = try await URLSession.shared.data(from: url)
                guard let image = UIImage(data: data) else { return nil }

                cache.setObject(image, forKey: urlString as NSString)
                await expiryStore.setExpiryDate(for: urlString, to: Date().addingTimeInterval(ttl)) // 캐시에 저장할 때, 만료 기간도 함께 저장
                print("이미지 다운로드 성공")
                return image
            } catch {
                print("이미지 다운로드 실패")
                return nil
            }
        }

        await taskStore.setTask(task, for: urlString)
        return await task.value
    }
    
    // 전체 캐시 비우기
    func clearCache() async {
        cache.removeAllObjects()
        await taskStore.clear()
        await expiryStore.clear()
    }
}
  • private var expiryDates : 캐시 만료 시간 저장 (이미지 유효 기간 관리)
    • key: URLString
    • value: Date (캐시 저장 당시 Date + TTL)
  • private let ttl: : 캐시 만료 시간 (현재시각 이후 TTL 이후까지 저장, 캐시가 유효한 지속 시간))
  • if let expiryDate = expiryDates[urlString], expiryDate > Date()
    • 캐싱된 데이터가 있으며, 만료 기간이 지나지 않았을 때만, 캐시 데이터를 반환
    • 캐싱된 데이터가 없거나, 만료 기간이 지났으면 새롭게 다운로드 및 저장 후 반환
  • self.expiryDates[urlString] = Date().addingTimeInterval(self.ttl)
    • 이미지 다운로드 후 만료 기간을 저장 (현재 시각으로부터 TTL 이후)
    • 새로 이미지를 다운로드한 시점 기준으로 TTL만큼 더한 시각을 만료 시각으로 저장
  • clearCache()
    • 전체 캐시를 수동으로 비우고 싶을 때 사용
    • 예) 로그아웃 시, 설정에서 캐시 비우기 기능 등에서 유용하게 사용됨

 

테스트

테스트 시나리오

1. TTL을 5초로 설정하고 이미지 캐싱이 잘 되는지 확인한다

2. 이미지가 캐싱된 5초 동안은 캐시 데이터를 사용하지만

3. 만료 기간이 지난 5초 이후에는 새로운 데이터를 다시 받아와 캐시에 저장한다

→ TTL이 잘 적용된 것을 확인할 수 있다

 

NSCache로 용량 제한하기

NSCache는 자동으로 메모리 관리를 해주는 클래스이므로, 캐시된 객체가 메모리를 압박하게 되면 자동으로 제거되도록 설계되어 있다

앱에서 적절하게 최대 용량을 제한하는 기능을 아래 두 가지 속성을 이용하여 쉽게 추가할 수 있다

  1. totalCostLimit: 전체 캐시가 사용할 수 있는 최대 비용(=크기)
  2. countLimit: 캐시에 저장할 수 있는 최대 객체의 수

1. totalCostLimit (총 비용 제한)

NSCache는 각 객체에 비용(cost)를 부여할 수 있다.

예를들어, 이미지 용량(바이트 수)을 비용으로 계산한고

캐시 전체의 비용 합계가 직접 설정한 totalCostLimit을 초과하면, 자동으로 객체를 제거해준다

⇒ totalCostLimit은 MB 단위가 아닌 바이트 단위!

let cache = NSCache<NSString, UIImage>()
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB 제한
if let imageData = image.pngData() {
    cache.setObject(image, forKey: urlString as NSString, cost: imageData.count)
}

→ 이미지를 저장할 때는 비용을 지정해주어야 totalCostLimit을 설정한 의미가 있게 된다

 

2. countLimit (객체 수 제한)

NSCache가 저장할 수 있는 최대 개수를 지정한다

예를 들어, 이미지를 저장하는 캐시의 객체 수를 100개로 제한한다면, 캐시에 저장할 수 있는 이미지는 100개가 된다. 이 제한을 초과하면 자동 제거된다 (자동 제거 방식은 아래에서 자세히 다루겠다)

cache.countLimit = 100 // 최대 100개 객체 저장

 

 

물론 이 두 가지 속성을 같이 사용할 수도 있다!

let cache = NSCache<NSString, UIImage>()
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
cache.countLimit = 100 // 최대 100개 이미지 저장

→ 이 경우는 50MB, 100개 중 먼저 도달한 조건에 따라 객체가 제거된다

즉, 하나라도 넘기면 바로 제거된다

 

 

이처럼 NSCache에 최대 비용이나 객체의 수에 제한을 두게 되면 자동으로 객체를 제거해주는데 이 방식에 대해 자세히 알아보자

어떤 객체부터 제거될까? → LRU 방식

NSCache는 캐시된 객체 중 가장 오랫동안 사용되지 않은 객체부터 제거한다. 이를, LRU (Least Recently Used) 정책이라고 한다

예를 들어,

A, B, C, D라는 이미지가 순서대로 캐싱되어 있다라고 가정해보자

최근에 사용한 순서가 D → A → B 이고, C가 가장 오래 사용되지 않았다면, 제한 초과 시 C부터 제거되는 방식!

즉, 단순히 오래된 순이 아니라, 최근에 사용된 이력을 기준으로 삭제되는 것이다

객체를 캐시에 setObject(_:forKey:cost:)로 등록할 때, 내부적으로 접근 시간을 기록하고,

캐시에 접근(object(forKey:))할 때도 접근 시간을 갱신한다

제한 초과 시, LRU 정책에 따라 내부 순서를 확인하여 가장 오래 안 쓴 애부터 순차적으로 제거한다

 

→ 내부 정책은 https://github.com/gnustep/libs-base 에서 Objective-C로 되어 있다고 한다..

 

NSCache 사용 중 iOS 시스템에서 메모리가 부족해질 경우!

NSCache는 iOS 시스템에서 메모리가 부족해질 때 다른 객체보다 우선적으로 제거된다

앱이 크래시 나기 전에 NSCache 내부 객체부터 자동으로 해제되어 앱 안정성을 높이는 데 큰 기여를 한다.

→ iOS가 메모리 경고를 감지하면 NSCache 내부 객체를 자동으로 비우도록 최적화 되어 있다고 한다.

 

자 여기까지 NSCache의 메모리 관리에 대해 알아봤으니, 다시 ImageCacheManager 로 돌아가 보자

해당 프로젝트에는 totalCostLimit 속성을 이용하여 최대 메모리 크기를 지정해주었다

아래는 캐시 총 용량을 50MB로 지정했고, 이미지의 데이터 크기를 기반으로 cost를 지정해주었다

final class ImageCacheManager {
		... // 작업 관리, 만료 시간 관리 객체 선언

    private init() {
        cache.totalCostLimit = 50 * 1024 * 1024 // 캐시 총 용량 제한 (50MB)
    }

    func loadImage(for urlString: String) async -> UIImage? {
				... // 캐시 데이터, 진행 중인 작업 확인
        let task = Task<UIImage?, Never> {
            defer {
                Task { await taskStore.removeTask(for: urlString) }
            }

            guard let url = URL(string: urlString) else { return nil }

            do {
                let (data, _ ) = try await URLSession.shared.data(from: url)
                guard let image = UIImage(data: data) else { return nil }
                let cost = imageCost(image: image)

                cache.setObject(image, forKey: urlString as NSString, cost: data) // 이미지의 데이터를 cost로 넣어줌
                await expiryStore.setExpiryDate(for: urlString, to: Date().addingTimeInterval(ttl))
                print("이미지 다운로드 성공")
                return image
            } catch {
                print("이미지 다운로드 실패")
                return nil
            }
        }

        await taskStore.setTask(task, for: urlString)
        return await task.value
    }
}

 

 

추가적으로 NSCache는 Thread-Safe로 멀티 스레드 환경에서도 데이터의 일관성을 유지하며 안전하게 사용 가능하다

 

 

https://developer.apple.com/documentation/foundation/nscache

 

NSCache | Apple Developer Documentation

A mutable collection you use to temporarily store transient key-value pairs that are subject to eviction when resources are low.

developer.apple.com