본문 바로가기
네이버 커넥트재단 부스트캠프/기술적도전

[WeTri][Cache] 캘린더 캐싱 with NSCache & FileManager

by Joahnee 2023. 12. 1.

해당 게시글은 네이버부스트캠프에서 그룹 프로젝트를 진행하며 기록하는 내용입니다.

틀린 내용이 있을 수 있습니다. 
저희 프로젝트는 WeTri입니다.

많이 구경하러 와주세요!

https://github.com/boostcampwm2023/iOS08-WeTri

 

GitHub - boostcampwm2023/iOS08-WeTri: 우리가 함께 만드는, 트라이애슬론 🏃🏻 | 🏊‍♂️ | 🚴

우리가 함께 만드는, 트라이애슬론 🏃🏻 | 🏊‍♂️ | 🚴. Contribute to boostcampwm2023/iOS08-WeTri development by creating an account on GitHub.

github.com

 

 

오늘 게시물은 제가 WeTri에서 CustomCalendar를 구현하면서 있었던 캐싱에 관련된 고민을 해결하는 해결과정을 기록하였습니다.

 

🤔 캐시를 고민하게된 이유?

 

백엔드와 함께 협업하면서, 캘린더를 클릭할 때 마다 서버를 호출하게 되면 잦은 호출로 인해 서버에게 높은 코스트의 부담을 줄 수 있기 때문에 해당 고민을 시작하게 되었습니다.

 

캘린더를 한 달 단위로 보여주니까 그럼 한번 호출할 때, 한달의 데이터를 다 받아와서 사용하면 되지 않나?

 

물론 그렇게 진행해도 문제는 없을 것 같지만,

어차피 오늘의 기록은 계속해서 변경될 것이고 이걸 서버로부터 받아서 사용하려면 하루를 업데이트 하기위해서도 모든 날짜의 정보를 받아와야해서 사용자의 네트워크 자원을 낭비할 수 있다고 판단했습니다.

 

또한, 한정된 시간과 자원에서 백엔드가 API명세를 하루 단위로 내려줬으므로 어떤 상황에서도 최적의 선택을 하기위한 연습을 하기에도 안성맞춤인 과제인것 같습니다.

 

🫡 진행 과정

 1️⃣ CacheKey를 무엇으로 ..?

날짜별로 기록이 들어가 있어서 날짜는 고유한 값으로 지정할 수 있다고 판단하여 날짜의 String값을 Key값으로 지정해줬습니다.

 

2️⃣ 메모리캐시를 Respository에서 구현..?

저희 팀은 Feature의 아키텍처 구조를 CleanArchitecture + MVVM-C를 채택하고 있는데요..!

 

그래서 위와 같은 고민이 발생했습니다.

 

NSCache를 Repository에서 갖고 있게 된다면,

 

다른 Feature에서 Cache를 사용하기 위해서는 각 Feature 마다, NSCache 인스턴스를 생성해야할 것입니다.

 

그렇게 되면 NSCache가 Feature별로 존재할 것이고,

 

같은 데이터를 여러 Feature에서 사용한다면 메모리 캐시에 데이터가 중복되어 저장될 수 있습니다.

 

예를 들면, SNS앱에서 프로필 이미지를 여러 기능에서 사용하는데 Feature마다 이미지를 API에서 가져와서 각각의 NSCache에 저장한다면 메모리 효율성이 안좋아지겠죠?

 

또한, Feature별로 중복된 로직이 들어가게 될 것입니다.

 

그래서 저는 확장성을 위해 Cacher 모듈을 생성하고 CacheManager를 싱글톤으로 생성해줬습니다.

 

3️⃣ 그래서 Cache를 어떻게 사용했는가?

 

우선, 흐름도를 보여드리면 이렇습니다!

 

(중간에 RecordCalendarViewController에서 입력이 들어오면 RecordContainerViewController를 통해 RecordListViewController로 데이터가 전달되고 RecordListViewModel에서 해당 데이터를 처리하는 흐름은 생략했습니다.)

 

Cacher는 아래처럼 구성했습니다.

import Foundation

// MARK: - CacheError

enum CacheError: Error {
  case invalidFileURL
  case invalidData
}

// MARK: - Cacher

final class Cacher {
  private let cache = NSCache<NSString, NSData>()
  private let fileManager: FileManager

  init(fileManager: FileManager) {
    self.fileManager = fileManager
  }

  /// memory와 disk에서 데이터를 가져옵니다.
  /// memory캐시를 진행하고 메모리에 데이터가 없으면 디스크캐시로 넘어갑니다. 그마저 없다면 Error를 방출합니다.
  /// Error는 나중에 API호출로 처리됩니다.
  func fetch(cacheKey: String) throws -> Data? {
    if let memoryData = fetchMemoryData(cacheKey: cacheKey) {
      return memoryData
    }

    if let diskData = try fetchDiskData(cacheKey: cacheKey) {
      return diskData
    }
    throw CacheError.invalidData
  }

  /// memory와 disk에 캐시할 데이터를 저장합니다.
  func set(data: Data, cacheKey: String) throws {
    setMemory(data: data, cacheKey: cacheKey)
    try setDisk(data: data, cacheKey: cacheKey)
  }

  /// memory데이터를 불러옵니다.
  func fetchMemoryData(cacheKey: String) -> Data? {
    return cache.object(forKey: cacheKey as NSString) as? Data
  }

  /// disk데이터를 불러옵니다.
  func fetchDiskData(cacheKey: String) throws -> Data? {
    guard let directoryURL = generateFileURL(cacheKey: cacheKey) else {
      throw CacheError.invalidFileURL
    }
    let fileURL = directoryURL.appending(path: cacheKey)
    let data = try Data(contentsOf: fileURL)
    return data
  }

  /// memory에 데이터를 저장합니다.
  func setMemory(data: Data, cacheKey: String) {
    cache.setObject(data as NSData, forKey: cacheKey as NSString)
  }

  /// disk에 데이터를 저장합니다.
  func setDisk(data: Data, cacheKey: String) throws {
    guard let url = generateFileURL(cacheKey: cacheKey) else {
      throw CacheError.invalidFileURL
    }
    let path = url.path()
    try fileManager.createDirectory(
      atPath: path,
      withIntermediateDirectories: true
    )
    let appendedPath = url.appending(path: cacheKey).path()
    fileManager.createFile(atPath: appendedPath, contents: data)
  }

  /// cache 디렉토리가 존재하는 URL을 구합니다.
  private func generateFileURL(cacheKey: String) -> URL? {
    return fileManager.urls(
      for: .cachesDirectory,
      in: .userDomainMask
    )
    .first?
    .appending(path: cacheKey)
  }
}

 

4️⃣ DiskCache에 FileManager를 사용한 이유?

제가 알고있는 Disk에 데이터를 저장하는 방법은 CoreData, UserDefaults, FileManager가 존재하는데요.

 

이번 저희 앱에서 캐싱을 진행하게 될 때, CoreData에 저장할 만큼 복잡한 구조로된 데이터를 캐싱할 필요가 없다고 판단했습니다.

 

또한, UserDefaults에 저장할 만큼 작은 데이터를 캐싱할 일은 없다고 판단했는데요.

 

이전에 학습스프린트를 진행하면서 객체를 아카이브하여 UserDefaults에 저장하는 과제를 수행할 때, 이미지 크기가 너무 커서 UserDefaults에 저장이 안되는걸 Notifiacation을 통해 용량이 가득찼다는것을 확인했던 경험이 생각나네요 😂

 

결론적으로, FileManager를 선택하게 되었습니다.

 

실제로, 캐싱할만한 요소는 프로필 이미지와 일별 기록데이터가 있습니다.

 

4️⃣ 모든 기록을 캐싱하면 데이터 최신화는?

이전 날짜의 기록은 데이터를 최신화할 필요가 없었습니다.

(아이폰을 들고 어제로 타임머신을 타고가서 기록을 진행한다면... 캐싱된 기록데이터를 받겠죠..?)

 

하지만, 오늘 기록한 데이터는 언제 기록을 진행할지 모르기 때문에 캐싱데이터를 사용하는게 아닌, 계속해서 최신화를 해줘야할 것 같다고 판단했습니다.

 

그래서 오늘 날짜의 데이터는 캐시에 저장만 진행하면서 계속해서 최신화를 진행해줬습니다.

 

Repository에서 사용할 때, 아래 Manager에서 set()함수만 불러서 사용했습니다.

/// CacheManager.swift
public final class CacheManager {
  public static let shared = CacheManager()

  private let cacher = Cacher(fileManager: FileManager.default)

  private init() {}

  public func fetch(cacheKey: String) throws -> Data? {
    try cacher.fetch(cacheKey: cacheKey)
  }

  public func set(cacheKey: String, data: Data) throws {
    try cacher.set(data: data, cacheKey: cacheKey)
  }
}

 

5️⃣ 오늘 저장한 데이터가 최신화 안된 상태로 캐시에 저장되버리면 다음날 최신화 안된 데이터를 불러오게 된다 😱

만약 유저가 운동을 마치고 기록을 요청하는 API가 있는 화면(RecordContainerViewController)까지 도달하지 않고 앱을 종료한뒤, 다음날 앱을 켠다면..?

 

아마 최신기록이 저장되지 않은 데이터만 캐시되어 있어서 업데이트 되지 않은 데이터를 받을 것 같은데요 🧐

 

이를 해결하기 위해 Repository내부에서 API를 통해 데이터를 불러오는 함수에 isToday 매개변수를 추가했습니다.

 

오늘 날짜면 set()하지 않도록 처리해줬습니다 😀

func fetchRecordsList(date: Date, isToday: Bool) -> AnyPublisher<[Record], Error> {
    return Future<Data, Error> { promise in
      Task {
        do {
          let dateRequestDTO = try toDateRequestDTO(date: date)
          let key = makeKey(dateRequestDTO: dateRequestDTO)
          let data = try await provider.request(.dateOfRecords(dateRequestDTO), interceptor: TNKeychainInterceptor.shared)
          if !isToday {
            try cacheManager.set(cacheKey: key, data: data)
          }
          return promise(.success(data))
        } catch {
          promise(.failure(error))
          Log.make().error("\(error)")
        }
      }
    }

 

🤩 캐싱을 적용함으로써 얻게 되는 이점

이전 날짜의 기록을 확인하는 유저들이 여러번 같은 날짜를 확인하더라도 1번의 API 호출로 서버의 비용적인 측면에서 효율이 증가하게 됩니다.

 

백엔드분들께서는 트래픽이 감소하고 비용이 덜 나오게 될 것이라고 해당 기능 도입에 만족해 하시네요 😌

 

로컬에서 불러오기 때문에 거의 차이는 없겠지만 미세하게나마 기록확인 속도가 증가할 것으로 예상됩니다.

 

📚 References

- NSCache와 Cache 개념

- 공식문서 NSCache

- 공식문서 FileManager

- 이전에 챌린지를 진행하면서 정리한 내용

 

 

 

댓글