-
Notifications
You must be signed in to change notification settings - Fork 0
Cache Policy
작성자: 배현진
캐시 생성 시간 추가하고, 만료 시간을 정해 캐시 생성된지 만료 시간만큼이 지난다면 삭제
- CacheableImage에 createdDate 추가
- expiredTime 추가
- 캐시의 createdDate로부터 expiredTime 만큼의 시간이 지난 경우 삭제
가장 최근에 사용된 데이터를 캐시에 유지하고, 오래된 데이터를 제거하는 방식
즉, 자주 사용되는 데이터는 캐시에 남기고 사용되지 않는 데이터를 제거한다.
- 단점 : 참조된 시간 또는 최근 사용여부를 기록해야 한다.
- 장점 : 많이 사용되는 캐시 정책 중 하나이다.
현재 메모리 캐시는 NSCache를 사용함으로써 자동 캐시 삭제 기능이 추가되어 있다. 따라서 캐시 삭제 기능이 없는 디스크 캐시에 우선적으로 정책 적용 시도했다.
- cacheLimit : 디스크 캐시의 최대 용량 (캐시에 저장할 최대 이미지 수)
- 용량이 아닌 개수로 제한을 설정하게되면 큰 용량 데이터가 저장되는 경우 캐시 용량을 빠르게 차지해나갈 수 있다. → 그냥 용량으로 제한하는 것이 더 나을까? 고민하게 됨
- 하지만 메이트 목록에 사용되는 이미지들은 다운샘플링된 썸네일 이미지이기 때문에 특별히 튀는 용량없이 저장될 수 있을 것이다. → 이미지 수로 제한해도 문제 없을 것.
- 개수로 정책을 설정하는 것에 대해서도 좋다고는 생각이 들지 않네요ㅠ 저희는 이미지를 따로 다운샘플링을 하지 않으니까 친구가 업로드한 이미지에 따라서 디스크캐시에 차지하는 용량이 천차만별이 될 것 같아요.
- usageOrder : 디스크 캐시의 사용된 순서를 관리하는 배열
- 캐시가 재참조되어 사용된다면 해당 배열에서 순서를 변경해주어야함
- updateDiskUsageOrder()
- 지정된 디스크 캐시 이미지 수(cacheLimit)를 넘어선다면 배열 속 가장 오래된 데이터를 삭제해야함
- removeOldestDiskImage()
- 캐시가 재참조되어 사용된다면 해당 배열에서 순서를 변경해주어야함
LRU와 LFU를 혼합한 정책이라고 볼 수 있다.
가장 오래전 참조된 객체를 우선적으로 확인하고 그 책체가 참조된 횟수가 캐시가 가진 평균 참조 횟수보다 높다면 자주 참조된 객체이므로, 다시 참조 가능성이 생겨 삭제 대상으로 정하지는 않는다.
즉, 참조한지 가장 오래된 객체 중 참조 횟수가 평균 참조 횟수보다 적은 객체를 삭제한다.
캐싱으로 인한 메모리 사용량 증가는 시스템 리소스에 부담을 줄 수 있어 NSCache나 cachesDirectory의 자동 삭제 기능에 의존하기보다는, 앱의 상황에 맞는 적절한 캐시 정책을 수립하여 적용하는 것이 중요하다고 한다.
하지만, NSCache를 사용하면 Thread-Safe 하기 때문에 따로 DispatchQueue를 사용하거나 lock를 이용할 필요가 없고 좀 더 간단하게 구현이 가능하다.
만약 LRU로 변경한다면?
메모리 캐시 정책을 NSCache를 이용해 자동으로 사용하지 않고, LRU를 이용한 방식으로 변경한다면 캐시 제거 기준을 직접 지정(디스크 캐시 제한처럼)할 수 있다.
디스크 캐시에 LRU 정책 적용한 것과 비슷한 방식으로 구현할 . 수있다. 다른 점은 이미지 데이터를 저장하는 딕셔너리가 필요로 해진다는 점이다. 이 딕셔너리가 메모리 캐시의 역할을 하게된다.
private var cachedImages: [String: CacheableImage] = [:]
private var usageOrder: [String] = []
private let cacheLimit: Int = 100
func saveMemoryCache(urlString: String, cacheableImage: CacheableImage) {
// NSCache 방식
// cache.setObject(cacheableImage, forKey: urlString as NSString)
if cachedImages[urlString] == nil && cachedImages.count >= cacheLimit {
removeOldestImage()
}
cachedImages[urlString] = cacheableImage
updateUsageOrder(for: urlString)
}
func imageFromMemoryCache(urlString: String) -> CacheableImage? {
// NSCache 방식
// return cache.object(forKey: urlString as NSString)
guard let image = cachedImages[urlString] else { return nil }
updateUsageOrder(for: urlString)
return image
}
private func updateUsageOrder(urlString: String) {
if let index = usageOrder.firstIndex(of: urlString) {
usageOrder.remove(at: index)
usageOrder.append(urlString)
}
}
private func removeOldestImage() {
guard let oldestImage = usageOrder.first else { return }
cachedImages.removeValue(forKey: oldestImage)
usageOrder.removeFirst()
}테스트를 위해 디스크 캐시 제한을 이미지 5개로 두었다.
메이트 목록에 4개의 데이터가 존재할경우, (앱 첫 빌드 시점이라 전부 새로 캐시에 저장되는 중) 정상적으로 캐시에 추가되고, usageOrderCount가 증가함을 확인할 수 있다.

이후 데이터를 1개씩 추가하며 확인해보았다.
데이터 5개
→ 문제나 변화 없이 usageOrderCount가 5로 변경되며 캐시에 추가되었다.
데이터 6개
→ usageOrderCount가 6으로 변경되었다. 이때, 제한값을 넘어서게 되어 캐시 삭제가 진행된다. Disk cache contents 로그 부분을 확인하면 가장 초기 값이 삭제되어 5개로 유지됨을 확인할 수 있다.

여기서 의아한 점이 생겼다.
왜 순서 유지가 안되는 것일까??
지금 로그에 찍힌 내용을 확인해보면, 가장 먼저 위치한 데이터가 잘 삭제되지만, 새로 추가된 데이터가 가장 마지막에 저장되지 않고 중간에 위치하게 되고 있다.
원인은 content가 디스크 캐시 파일에서 가져온 데이터라는 점이었다.
usageOrder를 기준으로 로그를 찍어보면, 순위가 정상적으로 적용된다는 것을 확인할 수 있었다.

앞선 구현 상태에서는 비동기 처리를 따로 하지 않아 모두 동기적으로 동작했다.
디스크 캐시에 이미지를 가져오고 저장하는 과정에 비동기 처리를 추가하여 한번에 많은 이미지가 처리되어도 문제없도록 개선 시도했다.
- 디스크 캐시에서는 usageOrder 상태를 관리해줄 필요가 있다. 여기서 동시성 문제를 Actor를 이용하면 방지할 수 있다.
- 디스크 캐시에서 이미지 저장과 로드가 동시에 발생하더라도 Actor를 사용하면 한번에 한 스레드에 접근하도록 보장된다.
- 즉, 동시성 문제와 충돌 없이 관리할 수 있다.
- Actor를 사용하며 async await을 이용해 비동기 처리함으로써 확인이 더 간단하다.
기존 코드는 ImageNSCacheManager 속에서 전체적으로 메모리 캐시와 디스크 캐시가 적용되고 있었다.
디스크 캐시 부분에만 Actor를 이용해 비동기 처리하기 위해서 DiskCacheManager로 따로 분리해 구현했다.
이때, 디스크 캐시 실행 함수들이 분리되었어도, 그 호출은 ImageNSCacheManager에서 그대로 이뤄지고 있었기 때문에 클래스명을 CacheManager로 변경하였다.
- 디스크 캐시 Actor로 분리
actor DiskCacheManager {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var usageOrder: [String] = []
private let cacheLimit: Int = 50
private let cacheDirectoryPath: URL
init(cacheDirectoryPath: URL) {
...
}
func saveToDist(urlString: String, cacheableImage: CacheableImage) async throws {
...
}
func loadFromDist(urlString: String) async throws -> CacheableImage? {
...
}
private func updateDiskUsageOrder(urlString: String) async {
...
}
private func removeOldestDiskImage() async {
...
}
private func printDiskCacheDirectory() {
...
}
}현재 사용 여부를 갱신해 가장 오래전에 사용된 데이터를 삭제하는데 배열을 이용하고 있었다.
하지만 배열을 이용하게 되면 값을 검색하거나 제거할 경우의 시간 복잡도가 O(n) 이었다.
소량의 메이트 데이터를 갖는 상태에서는 문제가 없지만, 메이트 데이터가 대량이 될 경우를 대비해 시간복잡도를 개선하고자 했다.
처음에는 캐시 시간복잡도 개선에 많이 사용되는 방식인 Double Linked List 사용을 고려했다.
이를 이용하면, 사용 순서를 유지하고 노드에 대한 참조를 딕셔너리 형태로 저장해 키를 검색하고 노드 정도를 업데이트해줄 수 있다. 모두 O(1)의 시간복잡도를 가지게 된다.
하지만, 팀원들과 의견을 나누는 과정에서 Swift Collections 패키지를 추가해 사용할 수 있는 OrderedSet을 이용해보기로 결정하였다. 이 과정에서, OrderedSet이 데이터 추가와 삭제에는 시간복잡도가 O(1)이지만 조회의 경우에 O(logn)의 시간복잡도를 갖기 때문에 Double Linked List 사용이 더 개선된는 부분이 크지 않을까 하는 의견도 존재했다. 하지만, 캐시 정책에서 OrderedSet의 조회 없이 추가와 삭제만 이용된다는 점에서 좀 더 사용이 간편한 OrderedSet을 적용해보기로 최종 결정 하였다.
Swift Collections 패키지를 추가하고 OrderedSet을 이용해 LRU 정책을 적용하였다.
정상적으로 동작됨을 확인할 수 있었다.

하지만, 그 이후에 문제가 발생했는데, CI 테스트 실행 과정에서 OrderedSet 관련한 오류가 발생하며 워크플로우가 실패로 끝난다는 점이었다. 이 부분은 문제 해결 과정에 있으며, 해결되면 트러블슈팅으로 따로 기록하려고 한다.
- 14 Pro 18.1.1 기준
- 새로 추가되어 캐시 존재하지 않는 상태 기준
위 조건을 동일하게 하여 아래 6가지 항목을 측정해 비교하고자 한다.
- 소량의 메이트 데이터 (4개) → profiling 브랜치 기준으로 재측정 필요
- 다량의 메이트 데이터 (40개) → profiling 브랜치 기준으로 재측정 필요
- 소량의 메이트 데이터 (4개) + 비동기
- 다량의 메이트 데이터 (40개) + 비동기
- 소량의 메이트 데이터 (4개) + 비동기 + 시간복잡도 개선 (OrderedSet)
- 다량의 메이터 데이터 (40개) + 비동기 + 시간복잡도 개선
현재 Instruments 동작 멈춤 문제로 인해 측정이 밀리고 있는 중이다. 계속해서 동일 현상 발생한다면, 다른 팀원 기기로 측정 시도해봐야 할 것 같다.
NI, MPC
리팩토링/리디자인
테스트
Supabase
- 배현진
- 윤지성
- 최진원
- 허혜민