본문 바로가기
개린이 이야기

캐시정책에 관하여

by iOS 개린이 2024. 1. 24.

왜 필요한가?

- 이전 시간에 이미지 캐싱에 대해 학습했다. (2024.01.18 - [개린이 이야기] - 이미지 캐싱에 관하여)

- 이미지 캐싱은 네트워크 요청을 최소화함으로써 사용자 경험을 크게 향상시킨다.
하지만 캐싱으로 인한 메모리 사용량 증가는 시스템 리소스에 부담을 줄 수 있다. 

따라서 NSCache나 cachesDirectory의 자동 삭제(auto-eviction)기능에 의존하기보다는, 앱의 상황에 맞는 적절한 캐시 정책을 수립하여 적용하는 것이 중요하다. 

캐시 만료 정책

- 각 데이터에 만료일을 설정하고, 정기적으로 만료된 데이터를 제거하는 방법

- KingFisher 라이브러리 코드를 참고하여, 캐시 관리 기능을 구현!

 

 

저장할 객체 선언

final class StorageObject {
    let imageData: Data
    private let expiration: StorageExpiration
    var estimatedExpiration: Date  // 만료 날짜
    
    init(imageData: Data, expiration: StorageExpiration) {
        self.imageData = imageData
        self.expiration = expiration
        self.estimatedExpiration = expiration.estimatedExpirationSince()
    }
    
    var isExpired: Bool {
        return estimatedExpiration.isPast
    }
}

extension Date {
    /// 현재 시간과 비교하여, 과거의 날짜(만료됨)인지 여부를 반환
    var isPast: Bool {
        return timeIntervalSince(Date()) <= 0
    }
}

 

- 이미지 데이터와 만료일(estimatedExpiration)을 계산하여 저장
- 만료일이 지났는지 체크는 isExpired 프로퍼티를 통해 확인

 

만료일 계산

/// 캐시된 데이터의 만료를 정의
enum StorageExpiration {
    case seconds(TimeInterval)
    case days(Int)
    
    func estimatedExpirationSince() -> Date {
        switch self {
        case .seconds(let seconds):
            return Date().addingTimeInterval(seconds)
            
        case .days(let days):
            // 하루를 초 단위로 환산 * 일 수
            let duration = TimeInterval(86_400) * TimeInterval(days)
            return Date().addingTimeInterval(duration)
        }
    }
}

 

- 메모리에 저장되는 데이터일 경우, 초 단위로 계산

- 디스크에 저장되는 데이터일 경우, 일 단위로 계산

 

 

Memory Storage 전체 코드

/// 캐시 메모리 저장소
final class MemoryStorage {
    // MARK: - Property
    private let storage: NSCache = {
        let cache = NSCache<NSURL, StorageObject>()
        // 캐시 최대 용량 설정
        let totalMemory = ProcessInfo.processInfo.physicalMemory
        let costLimit = totalMemory / 4
        cache.totalCostLimit = (costLimit > Int.max) ? Int.max : Int(costLimit)
        return cache
    }()
    
    private var keys = Set<NSURL>()  // 캐시에 저장된 객체들의 키를 추적하는 데 사용
    private let lock = NSLock()
    
    // MARK: - Init
    init() {
        // 240초 주기로 만료된 데이터를 제거
        Timer.scheduledTimer(withTimeInterval: 240, repeats: true) { [weak self] _ in
            guard let self else { return }
            self.removeExpired()
        }
    }
    
    // MARK: - CRUD
    /// 데이터 저장
    func store(_ imageData: Data, forKey key: NSURL) {
        lock.lock()
        defer { lock.unlock() }
        
        let storageObject = StorageObject(imageData: imageData, expiration: .seconds(300))
        storage.setObject(storageObject, forKey: key)
        keys.insert(key)
    }
    
    /// 데이터 조회
    func value(forKey key: NSURL) -> Data? {
        return storage.object(forKey: key)?.imageData
    }
    
    /// 저장된 모든 데이터 제거
    func removeAll() {
        lock.lock()
        defer { lock.unlock() }
        
        storage.removeAllObjects()
        keys.removeAll()
    }
    
    /// 만료된 데이터 제거
    private func removeExpired() {
        lock.lock()
        defer { lock.unlock() }
        
        for key in keys {
            guard let object = storage.object(forKey: key) else {
                keys.remove(key)
                continue
            }
            
            // 데이터가 만료된 경우 제거
            if object.isExpired {
                storage.removeObject(forKey: key)
                keys.remove(key)
            }
        }
    }
}

 

- 메모리 캐싱과 데이터를 주기적으로 관리해주는 객체의 전체 코드
- 메인 기능들을 하나씩 파악해보자.


데이터 메모리 캐싱

/// 데이터 저장
func store(_ imageData: Data, forKey key: NSURL) {
    lock.lock()
    defer { lock.unlock() }

    let storageObject = StorageObject(imageData: imageData, expiration: .seconds(300))
    storage.setObject(storageObject, forKey: key)
    keys.insert(key)
}


- 'StorageObject' 를 메모리에 캐싱하는 메서드

- 데이터의 만료일을 5분으로 설정 후 캐싱

- 캐싱된 데이터들은 'keys' 에도 추가(나중에 만료된 데이터를 추적하기 위해 사용)

 

*NSLock*

- NSLock은 멀티스레딩 환경에서 안전하게 자원을 관리하기 위해 사용된다.
- 지난 시간에 학습했던 내용인데, NSCache는 Thread-safe 하기 때문에 lock 없이도 안전하게 작업을 수행할 수 있다.
하지만 'keys' 와 같은 일반 컬렉션은 non-Thread-safe 하다.
이는 한 스레드가 데이터를 쓰고 있을 때, 다른 스레드도 이에 접근하면 데이터레이스(data race)나 데드락(deadlock)과 같은 문제가 발생할 수 있다.
- lock을 호출한 스레드는 잠금에 대한 소유권을 가지게 된다. 
이 잠금은 unlock이 호출될 때까지 해제되지 않기 때문에 다른 스레드는 이 잠금이 해제될 때까지 기다려야 한다.
이를 통해 'keys'에 대한 스레드 안전성을 보장할 수 있다.

 

만료된 데이터 제거(Memory)

/// 만료된 데이터 제거
private func removeExpired() {
   lock.lock()
   defer { lock.unlock() }

   for key in keys {
      guard let object = storage.object(forKey: key) else {
         keys.remove(key)
         continue
      }
            
      // 데이터가 만료된 경우 제거
      if object.isExpired {
           storage.removeObject(forKey: key)
           keys.remove(key)
      }
  }
}


- 캐시 메모리 내에 있는 모든 데이터를 순회하며, 만료된 데이터를 제거하는 것은 비효율적
따라서 캐싱된 데이터를 추적할 수 있는 keys를 미리 구현하여 효율적으로 개선

- keys는 메모리와 완벽하게 동기화될 수 없음(auto-eviction) 
따라서 keys에는 저장되어 있지만, 메모리에 해당 데이터가 없다면, keys에서도 제거



주기적으로 만료된 데이터를 제거하는 타이머

// MARK: - Init
init() {
   // 240초 주기로 만료된 데이터를 제거
   Timer.scheduledTimer(withTimeInterval: 240, repeats: true) { [weak self] _ in
       guard let self else { return }
       self.removeExpired()
   }
}

 

- 240초에 한 번씩 'keys'를 순회하면서 만료된 데이터를 체크하여 제거한다.

 

NSCache의 TotalCostLimit

private let storage: NSCache = {
    let cache = NSCache<NSURL, StorageObject>()
    // 캐시 최대 용량 설정
    let totalMemory = ProcessInfo.processInfo.physicalMemory
    let costLimit = totalMemory / 4
    cache.totalCostLimit = (costLimit > Int.max) ? Int.max : Int(costLimit)
    return cache
}()

 

- NSCache의 프로퍼티에는 totalCostLimit이 있는데, 말 그대로 저장할 수 있는 크기를 제한해주는 것이다.
- 시스템 메모리의 4분의 1에 해당하는 크기로 totalCostLimit을 지정해주었음(고냥 KingFisher 따라하기).
- countLimit이라는 프로퍼티도 있는데, 이건 저장할 수 있는 객체의 최대 갯수를 제한하는 것이니 필요할 경우 써먹자!

 

 

 

Disk Storage

/// 데이터 저장
    func store(_ imageData: Data, identifier: String) {
        guard let resultURL = directoryURL?.appendingPathComponent(identifier) else { return }
        let storageObject = StorageObject(imageData: imageData, expiration: .days(7))
        
        do {
            // 디스크 캐싱
            try storageObject.imageData.write(to: resultURL)
            
            // 속성 정의(만료날짜) 및 저장
            let attributes: [FileAttributeKey: Any] = [
                .modificationDate: storageObject.estimatedExpiration.fileAttributeDate
            ]
            try fileManager.setAttributes(attributes, ofItemAtPath: resultURL.path)
            
        } catch {
            print("데이터 및 속성 저장 실패  \(error.localizedDescription)")
        }
    }

 

- 해당 디렉토리에 데이터를 저장하는 메서드
- 만료일은 7일 이후로 설정

- NSCache와 다르게 FileManager를 통해 데이터를 저장할 경우, 객체가 Cadable하거나 Data 타입이어야 한다.
따라서 이미지 데이터만 저장하고, 만료일과 같은 정보는 파일의 속성으로 정의하여 setAttributes 한다.

 

 

만료된 데이터 제거(Disk)

/// 저장된 모든 파일의 URL을 가져오기
private func getAllFileURLs() -> [URL]? {
    guard let directoryURL else { return nil }
    
    guard let directoryEnumerator = fileManager.enumerator(
        at: directoryURL,
        includingPropertiesForKeys: [.contentModificationDateKey],
        options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants])
    else { return nil }
    
    guard let urls = directoryEnumerator.allObjects as? [URL] else { return nil }
    return urls
}

 

 

- 디렉토리 내에 있는 파일 URL을 가져오는 메서드
- enumerator를 사용하여 파일들을 열거한다. 열거 과정에서 숨겨진 파일과 하위 디렉토리의 항목은 무시한다.
- 열거된 객체를 URL로 변경하여 반환

 

 

// MARK: - 만료된 데이터 제거
    private func removeExpired() {
        guard let urls = getAllFileURLs() else { return }
        
        // 만료된 파일 조회
        let expiredFiles = urls.filter { url in
            do {
                let attributes = try fileManager.attributesOfItem(atPath: url.path)
                if let expirationDate = attributes[.modificationDate] as? Date {
                    return expirationDate.isPast
                }
            } catch {
                print("파일 속성 조회 실패: \(error.localizedDescription)")
            }
            return false
        }
        
        // 만료된 데이터 제거
        expiredFiles.forEach { url in
            do {
                try fileManager.removeItem(at: url)
                print("디렉토리 내 만료된 데이터 제거 성공: \(url)")
                
            } catch {
                print("디렉토리 내 만료된 데이터 제거 실패: \(error.localizedDescription)")
            }
        }
    }

 

- 'getAllFileURLs' 를 통해 가져온 URL들 중 만료된 파일을 필터링하여 모두 제거한다.

 

 

Disk 정리 시점

private func observeBackgroundNotification() {
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(didEnterBackground),
        name: UIApplication.didEnterBackgroundNotification,
        object: nil
    )
}

@objc private func didEnterBackground() {
    removeExpired()
}

 

- 앱이 백그라운드로 들어가는 시점에 디스크 정리를 수행한다.

 

 

 

 

Reference:

- https://github.com/onevcat/Kingfisher

- https://developer.apple.com/documentation/foundation/nslock

- https://developer.apple.com/documentation/foundation/filemanager/1413667-setattributes

'개린이 이야기' 카테고리의 다른 글

Downsampling(다운샘플링)에 관하여  (0) 2024.01.20
이미지 캐싱에 관하여  (0) 2024.01.18
MVVM(Model-View-ViewModel)에 관하여  (0) 2023.07.12
Git Flow에 관하여  (0) 2023.06.26
Git에 관하여  (0) 2023.06.25