Image Cache - Disk, Memory Storage

Cache

목록 보기
2/2
post-thumbnail

시작하기에 앞서

이번시간엔 Disk, Memory 캐시에 핵심 포인트를 정리해 보려고 합니다.

목표

  • 네트워크 요청 횟수를 줄이기
  • 이미지 로딩 체감을 개선하기
  • 앱 종료 이후에도 캐시를 유지
  • 메타데이터를 갱신하여 회신화 하기

Memory Storage

  • NSCache 기반의 인메모리 캐시 방식
  • 앱 종료 시 사라지며, 가장 빠른 조회 경로 사용
  • I/O 비용이 매우 적기 때문에 디코딩 이후 이미지를 바로 반환하기에 적합
  • totalCostLimit 기준으로 시스템에서 자동 제거 -> 메모리 압박 상황관리
  • NSCache`는 LRU에 가까운 방식으로 정리됨
  • 비용 기준으로 메모리 압박을 완화
public init(totalCostLimit: Int = 64 * 1024 * 1024) {
    self.cache = NSCache<NSString, EntryBox>()
    self.cache.totalCostLimit = totalCostLimit
}

Disk Storage

  • FileManager 기반의 디스크 캐시
  • 앱 종료 후에도 유지됨 -> 재실행 시에도 캐시가 남음.
  • I/O가 상대적으로 부담됨 -> 메모리 캐시 miss 시의 2차 레이어로 사용
  • 디스크 용량 / 유통기한 기준으로 정리하여 캐시의 용량 관리
private struct LastPath {
    static let data = ".data" // 이미지 데이터
    static let metadata = ".meta.json" // 갱신정보들
}
public var filenameSafeHash: String {
    let data = Data(rawValue.utf8)
    let digest = SHA256.hash(data: data)
    return digest.map { String(format: "%02x", $0) }.joined()
}

디스크 저장 구조

  • baseURL 하위에 SHA256 해시 파일명으로 저장
    • 이름에서 "/" 등과같은 문자에 의한 경로 손상 방지, 길이 제한 등의 의한 이유
  • <hash>.data는 이미지 원본 데이터
  • <hash>.meta.jsonCacheMetadata를 JSON으로 저장

Metadata

public struct CacheMetadata: CacheSerializer {
    public let originalUrlString: String
    public let createdDate: Date
    public var eTag: ETagCache?
    public var accessCount: Int
    public var lastAccessTime: Date
    public var lastModified: Date?
}

디스크 캐시에서 접근 정보를 기록하여 pruning 기준으로 사용됩니다.
마지막 접근 시간과 접근 횟수는 LRU, 유통기한을 통해 정리할때 활용됩니다.

접근 정보 갱신

  • 디스크에서 읽어올 때마다 touch()를 호출합니다.
    • 횟수를 통해 덜 썻는지 구분하기 위함.
  • accessCountlastAccessTime을 최신화하여 LRU 기준을 유지합니다.
public mutating func touch() {
    accessCount += 1
    lastAccessTime = Date()
}

Disk Read/Write 포인트

  • read 시 metadata.touch()로 접근 정보를 갱신합니다.
  • write 시 data + metadata를 함께 기록합니다.
  • 요청마다 접근시간/횟수를 업데이트하여 LRU 판단 근거로 사용합니다.
private func readEntry(for key: CacheKey) -> CacheEntry<CacheMetadata>? {
    // ... load data/metadata
    metadata.touch()
    // ... re-save metadata
    return CacheEntry(data: data, metadata: metadata)
}

메모리 히트 시 메타데이터 업데이트

  • 메모리 히트는 디스크를 즉시 쓰지 않고, DiskAccessRecorder에 쌓아둡니다.
  • 기본 5초 디바운스로 모아서 disk.touch(keys)를 호출합니다.
  • 앱이 백그라운드로 가는 시점에는 즉시 flush()로 반영합니다.
public func get(_ key: CacheKey) async -> CacheEntry<CacheMetadata>? {
    if let entry = await memory.get(key) {
        Task { await accessRecorder.record(key) }
        return entry
    }
    // ...
}
func record(_ key: CacheKey) {
    pending.insert(key)
    scheduleFlushIfNeeded()
}

Pruning (가지치기)

  • 오래된 항목 제거 후, 크기 제한 초과 시 LRU 순으로 제거합니다.
if now.timeIntervalSince(item.lastAccessTime) > ageLimit {
    try? fileManager.removeItem(at: item.dataURL)
    try? fileManager.removeItem(at: item.metaURL)
}

...

for item in items where totalSize > diskLimit {
	try? fileManager.removeItem(at: item.dataURL)
	try? fileManager.removeItem(at: item.metaURL)
	totalSize -= item.size
	removed += 1
}

캐시 저장 시점

  • Memory hit: 즉시 반환합니다.
  • Disk hit: 메모리에 올려둔 뒤 반환합니다.
  • Network hit: 메모리와 디스크에 모두 기록합니다.

캐시 흐름

  • 1차로 메모리 캐시를 조회합니다.
  • 메모리 miss일 경우 디스크 캐시를 조회합니다.
  • 디스크 hit면 메모리에 다시 올립니다. ( 승격 ) 그 후 반환합니다.
  • 디스크 miss이면 네트워크 요청 후 두 캐시에 모두 저장합니다.
  • 메모리 hit는 디스크 메타데이터를 즉시 쓰지 않고 모아둔 후 정리합니다.

다이어그램

Image Cache Flow

마치며

이번엔 저번편에 이어서
좀더 구현쪽에 가깝게 정리를 해보았습니다.
전체를 설명하기 보단 핵심 설명과, 흐름을 다시한번 자세히 정리하는게
좋을 것 같다는 생각이 들었어서 이번편은 여기서 마무리 지어보오록 하겠습니다.
모두 고생 하셨습니다.

profile
IOS 개발자 새싹이, 작은 이야기로부터

0개의 댓글