왜 필요한가?
- 이전 시간에 이미지 캐싱에 대해 학습했다. (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
'개린이 이야기' 카테고리의 다른 글
Concurrency에 관하여 (0) | 2024.07.28 |
---|---|
면접 후기 (1) | 2024.07.19 |
Downsampling(다운샘플링)에 관하여 (0) | 2024.01.20 |
이미지 캐싱에 관하여 (0) | 2024.01.18 |
MVVM(Model-View-ViewModel)에 관하여 (0) | 2023.07.12 |