문제 정의

회사 코드에서 하나의 API가 로직 상의 문제로 단시간에 여러번 호출되는 문제가 있었다. 로직을 변경하기엔 리스크가 있었고, 캐시 도입이 필요하다고 느꼈다.

프로젝트 내에 UserDefaults기반의 캐시가 있었지만, 다음과 같은 한계가 존재했다.

  • TTL 기능 없음
  • 용량 제한 기능 없음

따라서 이러한 문제점을 해결하기 위해 메모리 캐시(NSCache)디스크 캐시(FileManager 기반) 을 조합한 이중 캐시 구조를 구현하고자 했다.

캐시 구조 설계하기

[데이터 조회 흐름]

메모리 캐시(NSCache)
   ↓ miss
디스크 캐시(FileManager + Codable + 암호화)
   ↓ miss
네트워크 API 호출

이번 캐시 시스템은 메모리(NSCache)디스크(FileManager 기반) 를 함께 사용하는 이중 캐시 구조로 설계되어있다. 이중 구조로 설계하여 빠른 접근과 데이터 지속성을 만족시킬 수 있도록 하였다.

각각의 계층은 아래와 같은 역할을 수행한다.

  • 메모리 캐시: 가장 빠른 속도로 데이터를 조회할 수 있는 공간. 앱이 종료되면 사라지지만, 실행 중에는 최우선으로 접근
  • 디스크 캐시: 앱 종료 후에도 데이터를 보존할 수 있는 파일 기반 저장소. 메모리 캐시에 데이터가 없을 때 fallback 역할

메모리 캐시

private let memoryCache = NSCache<NSString, CacheObject<T>>()

메모리 캐시에는 NSCache를 사용하였다.

NSCache는 딕셔너리처럼 직관적인 사용 방법을 제공하면서 시스템 내부적으로 메모리를 관리해주는 기능을 가지고 있다.

🧐 딕셔너리가 아니고 왜 NSCache를 사용할까?

그렇다면 딕셔너리가 아니라 NSCache를 사용해야하는 이유가 뭘까?

딕셔너리를 사용할 때 다음과 같은 문제점이 있다.

  • 메모리 사용량이 많아져 가용 메모리가 부족해졌을 때 시스템 내부적으로 용량을 정리해 주지 않는다.
  • Thread-Safe하지 않아 수동으로 lock을 걸어야 한다.

→ NSCache는 이러한 문제점을 자동으로 해결해준다.

NSCache 특징

  • NSCache는 시스템 내부적으로 용량 관리를 하여 Out of Memory 크래시를 방지할 수 있다.
    • iOS는 메모리 압박**(memory pressure)**상황을 감지하면 NSCache에 저장된 객체를 자동으로 비워준다.
  • Thread-safe하기 때문에 여러 스레드에서 동시 접근해도 안전하다.

자동으로 용량 관리를 해주기 때문에 편리하기는 하지만 데이터가 언제 지워질지 알 수 없다.

그렇기 때문에 메모리 캐시에만 의존하지 않고 디스크 캐시까지 활용하는 이중 구조가 필요하다.

디스크 캐시

디스크 캐시 활용의 핵심 포인트를 알아보자.

  • Serialization(직렬화)
    • 디스크에는 바이트 단위로만 데이터를 저장할 수 있기 때문에 Data타입으로 변환하여 저장해야 한다.
    • 그렇기 때문에 Codable을 준수하는 객체를 활용하여 Data로 변환하는 Serialization(직렬화) 과정이 필요하다.
  • 암호화
    • 디스크에 저장되는 데이터는 개인정보보호가 필요할 수 있다. 그렇기 때문에 암호화 과정을 추가하였다.
  • 파일 관리(유지 시간, 용량)
    • 디스크 캐시 내부에 저장되는 데이터는 앱 성능에 영향을 미치지 않도록 해야한다.
    • TTL을 설정하여 유지 시간이 만료된 파일은 삭제되도록 한다.
    • LRU(Least Recently Used) 방식을 통해 가장 오래 접근하지 않은 파일을 우선적으로 삭제하여 용량 관리를 한다.
  • I/O 작업 직렬화
    • FileManager를 통해 데이터를 읽고 쓰도록 구현하였는데, 디스크 입출력은 속도가 느리고 연산 비용이 느리다. 또, 병렬로 데이터를 처리하게 되면 파일이 충돌되거나 race condition 상태 등의 문제가 발생할 수 있다.
    • 그렇기 때문에 DispatchQueue를 통해 파일 접근을 serial queue를 활용하도록 설계했다.
    • 그리고, 메인 스레드에서 디스크 저장 작업을 수행하게 되면 메인 스레드가 잠시 차단될 가능성이 있으며 그렇게 되면 UI가 멈출 수 있는 문제가 있다.
    • 때문에 대부분의 작업은 async를 통해 백그라운드에서 수행하고, 데이터 조회와 같이 바로 접근해야 하는 경우만 동기 작업을 하도록 했다.

캐시 시스템의 핵심 기능

CacheKey 프로토콜

다양한 캐시 타입을 다루기 위해 공통의 설정을 가지는 프로토콜을 정의했다.

protocol CacheKey: Hashable {
    /// 캐시가 저장될 디렉토리 이름
    var directory: String { get }
    /// 캐시를 식별하기 위한 고유 identifier 생성 메서드
    func getIdentifier(key: String) -> String
    /// Time To Live
    var ttl: TimeInterval { get }
    /// 디스크 최대 용량
    var diskLimit: UInt64 { get }
    /// 디스크 암호화 필요 여부
    var shouldEncrypt: Bool { get }
}
 
extension CacheKey {
    func getIdentifier(key: String) -> String {
        "\\(self.directory)_\\(key)"
    }
}

사용하고자 하는 데이터의 종류에 따라 CacheKey 타입을 생성하고, 해당 CacheKey를 채택하여 동일한 구조의 CacheKey를 사용할 수 있게 된다.

TwoLevelCache

final class TwoLevelCache<Key: CacheKey, T: Codable>

TwoLevelCache는 제네릭 클래스로 CacheKey를 키로, Codable데이터를 저장하도록 정의하였다.

✅ 데이터 조회하기 (메모리 → 디스크)

키에 해당하는 데이터를 메모리에서 먼저 조회 후 없으면 디스크에서 조회한다.

디스크에서 불러오게 되면 해당 데이터를 메모리에 저장해두고 그 이후 다시 접근할 때 메모리에서 우선적으로 가져올 수 있도록 한다.

if let cached = memoryCache.object(forKey: nsKey)?.value {
    return cached
}
 
return ioQueue.sync {
    let data = try? Data(contentsOf: fileURL)
    let object = shouldEncrypt
      ? try? cryptoManager.decrypt(T.self, from: data)
      : try? decoder.decode(T.self, from: data)
    memoryCache.setObject(CacheObject(object), forKey: nsKey)
    return object
}

✅ 데이터 저장하기 (비동기 저장 + 용량 제한)

데이터를 저장할 때는 메모리 캐시에 바로 저장하고, 디스크 캐시에 비동기로 데이터를 저장한다.

데이터를 저장한 후 enforceSizeLimit()LRU를 적용한다.

// 1) 메모리 캐시
memoryCache.setObject(CacheObject(object), forKey: nsKey)
 
// 2) 디스크 캐시 (비동기)
ioQueue.async { [weak self, identifier] in
    guard let self = self else { return }
		...
    do {
        let data: Data = try encoder.encode(object)
        try data.write(to: fileURL, options: .atomic)
        
        // 용량 제한 적용
        if let maxCap = self.diskLimit {
            self.enforceSizeLimit(maxCap)
        }
    } catch { }
}

만료 및 용량 정책

✅ TTL 만료 파일 정리

디스크에 저장된 파일 중 TTL을 넘긴 파일을 삭제한다.

파일에 저장된 날짜와 설정한 TTL 값을 통해 확인 후 삭제를 수행한다.

/// 만료된 파일 정리
func clearExpiredFiles() {
    ioQueue.async {
        let now = Date()
        let fm = FileManager.default
        guard let urls = try? fm.contentsOfDirectory(
            at: self.diskCacheURL,
            includingPropertiesForKeys: [.creationDateKey],
            options: []
        ) else { return }
        
        for url in urls {
            if let rv = try? url.resourceValues(forKeys: [.creationDateKey]),
               let created = rv.creationDate,
               now.timeIntervalSince(created) > (self.ttl ?? 0) {
                try? fm.removeItem(at: url)
            }
        }
    }
}

앱 진입 지점에 해당 메서드를 호출하도록 하여 주기적으로 데이터를 정리해줄 수 있다.

✅ 디스크 용량제한

디스크가 너무 커지게 되면 앱 전체 용량을 많이 차지하게 될 수 있다. 이를 방지하기 위해 LRU(Least Recently Used) 정책을 적용하였다.

FileManagercontentAccessDateKey를 활용해 마지막으로 접근한 시점을 가져오고, 가장 오래된 파일부터 삭제한다.

/// 디스크 용량 제한 (LRU 삭제)
func enforceSizeLimit(_ maxBytes: UInt64) {
    ioQueue.async {
        let fm = FileManager.default
        guard let urls = try? fm.contentsOfDirectory(
            at: self.diskCacheURL,
            includingPropertiesForKeys: [.contentAccessDateKey, .fileSizeKey],
            options: []
        ) else { return }
        
        var items: [(url: URL, access: Date, size: UInt64)] = []
        var total: UInt64 = 0
        for url in urls {
            if let rv = try? url.resourceValues(forKeys: [.contentAccessDateKey, .fileSizeKey]),
               let access = rv.contentAccessDate,
               let size = rv.fileSize.map(UInt64.init) {
                items.append((url, access, size))
                total += size
            }
        }
        let sorted = items.sorted { $0.access < $1.access }
        for item in sorted {
            if total <= maxBytes { break }
            try? fm.removeItem(at: item.url)
            total -= item.size
        }
    }
}

CacheManager 통합하기

사용성 좋은 캐시 기능을 구현하기 위해 기능별로 캐시를 분리하고, 이를 통합으로 관리하는 CacheManager를 생성하여 사용하도록 했다.

/// 사용하고자 하는 Cache타입 정의
final class CacheManager {
    static let shared = CacheManager()
    private init() {}
    
    let userInfoDetailCache = TwoLevelCache<UserCacheKey, MyInformationEntity>(
        cacheKey: .userInfoDetails
    )
    
    let vehicleDetailCache = TwoLevelCache<VehicleCacheKey, VehicleDetailEntity>(
        cacheKey: .vehicleDetails
    )
}

이렇게 CacheManager로 통합 관리 함으로써 각 기능 별로 다른 캐시 정책을 적용할 수 있고, 어떤 타입의 캐시 데이터를 사용하더라도 일관된 인터페이스를 통해 접근하여 사용할 수 있게 되었다.

회고

API 요청 전에 캐시 데이터가 존재하는지 검사 후 데이터가 있으면 캐시에 저장된 데이터를 반환하는 방식으로 사용하였다.

짧은 시간 안에 같은 데이터를 여러번 요청하게 되는 문제가 있었지만 캐시 사용을 통해 반복적인 API 호출 횟수를 줄일 수 있었다.

개선할 점

contentAccessDate 를 통해 파일의 마지막 접근 시점을 가져올 수 있는데, 이 값이 자동 갱신이 되지 않을 수 있다는 문제점이 있다. 그래서 LRU 방식으로 정확하게 파일을 관리하기 위해서는 파일을 읽을 때 수동으로 접근 날짜 데이터를 갱신해줘야 할 필요성이 있다.