일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- struct
- 캐시
- RxSwift
- 반응형
- SwiftUI
- observable
- CS
- 세종대학교
- 실습
- 라이브러리
- 이론
- Kingfisher
- 네트워크
- collectionview
- snkit
- mvc
- 학과별커뮤니티
- 옵셔널
- uikit
- 대표
- 토이프로젝트
- async
- 프로토콜
- ios
- 기초문법
- 구름톤 유니브
- 스트럭트
- WeatherKit
- swift
- 동시성
- Today
- Total
스윞한 개발자
SNKit #3: 디스크/하이브리드 캐시의 구현 본문
안녕하세요!
이번 포스팅에서는 지난 시간에 메모리 캐시에 대해 구현했던 것에 이어서 디스크/하이브리드 캐시를 구현해 본 과정을 정리해 보았습니다!

디스크 캐시 구현 세부사항
디스크 캐시는 앱이 종료된 후에도 데이터를 유지할 수 있는 장점이 있지만, 메모리 캐시보다는 접근 속도가 느립니다. SNKit에서는 파일 시스템을 활용한 디스크 캐시를 다음과 같이 구현했습니다.
final class DiskCache {
private let fileManager = FileManager.default
private let cacheDirectory: URL
private let lock = NSLock()
private let capacity: Int
private let expirationPolicy: ExpirationPolicy
init(
directory: URL?,
capacity: Int,
expirationPolicy: ExpirationPolicy
) {
let cacheDirectory = directory ?? fileManager.urls(
for: .cachesDirectory,
in: .userDomainMask
).first!.appendingPathComponent("SNKitCache", isDirectory: true)
self.cacheDirectory = cacheDirectory
self.capacity = capacity
self.expirationPolicy = expirationPolicy
createDirectoryIfNeed()
}
func store(_ cacheable: Cacheable) {
guard let image = cacheable.image, let data = image.jpegData(compressionQuality: 0.8) else { return }
let key = cacheable.identifier
let fileURL = cacheURL(for: key)
lock.lock()
defer { lock.unlock() }
do {
var metaData: [String:Any] = [
"createdAt": Date().timeIntervalSince1970,
"lastAccessedAt": Date().timeIntervalSince1970
]
if let eTag = cacheable.eTag {
metaData["eTag"] = eTag
}
let metadataURL = fileURL.appendingPathExtension("metadata")
let metadataData = try JSONSerialization.data(withJSONObject: metaData, options: [])
try metadataData.write(to: metadataURL)
try data.write(to: fileURL)
removeFilesIfNeeded()
} catch {
print("디스크 캐시 저장 실패")
}
}
func retrieve(with identifier: String) -> UIImage? {
let fileURL = cacheURL(for: identifier)
let metadataURL = fileURL.appendingPathExtension("metadata")
lock.lock()
defer { lock.unlock() }
guard fileManager.fileExists(atPath: fileURL.path) else {
return nil
}
guard fileManager.fileExists(atPath: metadataURL.path) else {
try? fileManager.removeItem(at: fileURL)
return nil
}
if let metadata = try? Data(contentsOf: metadataURL),
let info = try? JSONSerialization.jsonObject(with: metadata, options: []) as? [String:Any],
let createdAt = info["createdAt"] as? TimeInterval {
let creationDate = Date(timeIntervalSince1970: createdAt)
let currentDate = Date()
if expirationPolicy.isExpired(createdAt: creationDate, currentDate: currentDate) {
try? fileManager.removeItem(at: fileURL)
try? fileManager.removeItem(at: metadataURL)
return nil
}
updateAccessTime(for: metadataURL, info: info)
}
do {
let data = try Data(contentsOf: fileURL)
return UIImage(data: data)
} catch {
print("디스크 이미지 로딩 실패!")
try? fileManager.removeItem(at: fileURL)
try? fileManager.removeItem(at: metadataURL)
return nil
}
}
}
이외에도 더 많은 코드가 있지만 간단하게 디스크 캐시에 이미지를 저장하고, 가져오는 코드를 작성해보았습니다.
DiskCache 클래스의 주요 특징
- 파일 기반 저장: 이미지 데이터는 파일 시스템에 저장되며, 각 이미지는 식별자의 해시값을 파일 이름으로 사용합니다.
- 메타데이터 별도 저장: 이미지 파일과 함께 메타데이터 파일(.metadata 확장자)도 함께 저장하여 생성 시간, 마지막 접근 시간, ETag 등의 정보를 관리합니다.
- 만료 정책 적용: 이미지를 로드할 때 만료 정책에 따라 유효성을 검사하고, 만료된 이미지는 자동으로 삭제합니다.
- LRU 기반 용량 관리: 지정된 용량을 초과하면 가장 오래 사용되지 않은 이미지부터 삭제하는 LRU(Least Recently Used) 알고리즘을 구현했습니다(자세한 부분은 완성된 후 깃허브를 참고해주세요!!).
- 스레드 안전성: NSLock을 사용하여 여러 스레드에서 동시에 접근해도 안전하게 동작하도록 했습니다.
하이브리드 캐시 시스템
메모리 캐시와 디스크 캐시를 각각 구현한 후, 이 둘을 효율적으로 조합한 하이브리드 캐시 시스템을 다음과 같이 구현했습니다!
이번 SNKit을 개발하면서, 제가 가장 많이 쓰던 Kingfisher를 많이 참고했습니다! Kingfisher 에서도 적절하게 하이브리드 캐시를 지원하며 다양한 캐시 정책을 적용하고 있어 저도 더 보완해 적용해 보았습니다!!
final class HybridCache: @unchecked Sendable {
private let memoryCache: MemoryCache
private let diskCache: DiskCache
private let dispatchQueue = DispatchQueue(label: "com.snkit.hybridcache", qos: .utility)
init(
memoryCache: MemoryCache,
diskCache: DiskCache
) {
self.memoryCache = memoryCache
self.diskCache = diskCache
}
func store(_ cacheable: Cacheable) {
memoryCache.store(cacheable)
dispatchQueue.async { [weak self] in
self?.diskCache.store(cacheable)
}
}
func retrieve(with identifier: String) -> UIImage? {
if let image = memoryCache.retrieve(with: identifier) {
return image
}
return dispatchQueue.sync {
if let image = diskCache.retrieve(with: identifier) {
if let url = URL(string: identifier) {
let cacheable = CacheableImage(image: image, imageURL: url, identifier: identifier)
DispatchQueue.main.async {
self.memoryCache.store(cacheable)
}
}
return image
}
return nil
}
}
func remove(with identifier: String) {
memoryCache.remove(with: identifier)
dispatchQueue.async { [weak self] in
self?.diskCache.remove(with: identifier)
}
}
}
하이브리드 캐시도 일부의 코드만 가져왔습니다! 하이브리드 캐시의 경우에는 코드의 구조가 어렵지는 않았습니다.
HybridCache 클래스는 다음과 같은 전략을 사용합니다:
- 접근 방식: 먼저 빠른 메모리 캐시를 확인하고, 없으면 디스크 캐시를 확인합니다. (메모리 -> 디스크)
- 디스크 -> 메모리: 디스크 캐시에서 이미지를 찾으면 자동으로 메모리 캐시에도 저장합니다.
- 비동기 디스크 저장: 메모리 캐시에는 즉시 저장하고, 디스크 캐시에는 백그라운드 스레드에서 비동기적으로 저장합니다.
- 스레드 안전성: 디스패치 큐를 사용하여 스레드 안전성을 확보합니다.
이러한 하이브리드 접근 방식은 메모리 캐시의 속도와 디스크 캐시의 지속성을 모두 활용할 수 있게 해줍니다.
또한 디스크 캐시를 구현하면서 만료정책도 설정해주었는데요!!
만료 정책 구현
캐시 항목이 얼마나 오래 유지될지 결정하는 만료 정책은 다음과 같이 구현했습니다(Kingfisher를 많이 참고했습니다..!!)
public struct ExpirationPolicy: Sendable {
public enum Rule: Sendable {
case never
case days(Int)
case date(Date)
case expired
}
private let rule: Rule
public init(rule: Rule) {
self.rule = rule
}
...
}
만료정책의 경우는 4개의 옵션으로 구성되어있습니다.
- never: 캐시 항목이 자동으로 만료되지 않습니다.
- days(Int): 지정된 일수 후에 만료됩니다.
- date(Date): 지정된 날짜 이후에 만료됩니다.
- expired: 항상 만료된 상태로, 캐시를 사용하지 않도록 합니다.
이번 포스팅은 여기서 마무리 하겠습니다! 제가 지난 포스팅에서 메모리캐시를 구현하고 이번 포스팅에서는 디스크, 하이브리드까지 구현한 과정을 정리해보았습니다. 직접 만료정책과 캐시를 구현해 보는 과정에서 미흡했던 부분도 많았습니다. 잘못된 점에 대한 피드백은 언제나 환영입니다!!! 긴 글 읽어주셔서 감사합니다.

'프로젝트' 카테고리의 다른 글
SNKit #5: 이미지 처리, SNKit의 마무리... (2) | 2025.04.27 |
---|---|
SNKit #4: ETag 검증과 이미지 다운로드 시스템 (2) | 2025.04.08 |
SNKit #2: 메모리 캐시의 구현 (0) | 2025.03.25 |
SNKit #1: iOS 이미지 캐싱 라이브러리 기획 (0) | 2025.03.16 |
그래빗 : 성공적인 투자 GraBit과 함께! (0) | 2024.04.08 |