[EasyCloset] 🗳️ File Manager의 파일 입출력 Combine 으로 비동기 처리

Mason Kim·2023년 6월 23일
0

FileManager의 Combine을 통한 비동기 처리

배경

  • 사용자가 추가한 옷의 이미지를 로컬에 파일로 저장하기 위해 FileManager를 사용
  • 아래와 같이 처음에 구현한 FileManger 코드에서는 이미지를 가져올 때 파일 입출력을 main Thread 에서 그냥 돌리고 있었음
  • 이미지가 크거나, 여러 요청이 동시 다발적으로 들어오게 되면 경우에는 문제가 발생할 수 있을 것이라 판단
    image
func save(image: UIImage, id: UUID) throws {
  guard let data = image.pngData(),
        let filePath = filePath(of: id) else { return }
  try data.write(to: filePath)
}

func load(withID id: UUID) -> UIImage? {
  guard let filePath = filePath(of: id) else { return nil }
  do {
    let data = try Data(contentsOf: filePath)
    return UIImage(data: data)
  }
...

1차 리팩터링 - DispatchQueue + Completion Handler

  • DispatchQueue의 global() 큐를 통해 백그라운드 스레드에서 돌리고, 결과값을 completion Handler 에서 처리하게끔 변경함
  • write 작업은 qos: .utility 로 지시하고, read 작업은 기본 qos로 지시함
func save(image: UIImage, id: UUID, completion: ((FileManagerError?) -> Void)? = nil) {
    guard let data = image.pngData(),
          let filePath = filePath(of: id) else { return }

    DispatchQueue.global(qos: .utility).async {
      do {
        try data.write(to: filePath)
        completion?(nil)
      } catch {
        completion?(.failToWrite(error: error))
      }
    }
  }

  func load(withID id: UUID, completion: @escaping (UIImage?) -> Void) {
    guard let filePath = filePath(of: id) else {
      completion(nil)
      return
    }

    DispatchQueue.global().async {
      do {
        let data = try Data(contentsOf: filePath)
        let image = UIImage(data: data)
        completion(image)
      } catch {
        completion(nil)
      }
    }
  }
...

2차 문제 발생

  • storage의 데이터인 entity 배열을 받아와서 각각의 entity에 이미지를 매핑해주고, DispatchGroup을 이용해서, 여러 이미지의 로딩이 다 완료되었을 때 completion을 호출하도록 처리
  • 하지만 이렇게 구현 했을 때는 기존에 ViewModel 의 input, output에 대해 Combine으로 바인딩 한 부분과도 잘 맞지 않고, 코드가 직관적이지 않아지는 단점이 발생
  • 이에 Combine으로 리팩터링하기로 결정

Repository 에서 Clothes 목록을 가져오는 메서드 (파일매니저 메서드를 호출)

// Repository 에서 Clothes 목록을 가져오는 메서드
func fetchClothesList(completion: @escaping (ClothesList?) -> Void) {
    guard let realm = realm else {
      completion(nil)
      return
    }
 
    // Realm 에서 먼저 데이터를 가져오고,
    let clothesEntities = realm.objects(ClothesEntity.self)
    var clothesList = ClothesList(clothesByCategory: [:])

    let dispatchGroup = DispatchGroup()
    let serialQueue = DispatchQueue(label: "serialQueue")

    clothesEntities.forEach { entity in
      var model = entity.toModelWithoutImage()

      dispatchGroup.enter()

      // 각각의 옷 모델에 image를 매핑해준다.
      ImageFileStorage.shared.load(withID: model.id) { image in
        if let image = image {
          model.image = image
        }
        // 딕셔너리에 동시 접근 때문에 발생하는 문제를 serialQueue로 방지
        serialQueue.async {
          clothesList.clothesByCategory[model.category, default: []].append(model)
        }
        dispatchGroup.leave()
      }
    }

    // 모든 이미지의 로딩이 완료된 시점을 dispatchGroup으로 notify 함.
    dispatchGroup.notify(queue: .main) {
      completion(clothesList)
    }
  }

2차 리팩터링 - Combine

  1. 이미지를 로컬 파일에서 가져오는 로직을 Combine으로 구현
  • Future만 구현하고 subscribe(on:)을 해줬을 떄는 백그라운드 스레드로 변경되지 않았음 -> Future의 특성 때문
  • Future는 생성되는 "즉시" 실행되기 때문에 stream을 바꾸기 전에 이미 호출한 스레드로 실행이 되는 형태
  • 반면 Deferred 는 구독이 시작하는 순간에 클로저를 호출하기 때문에 Future를 Deferred로 감싸줌으로서, 호출을 지연할 수 있게 되어 stream을 백그라운드 스레드로 변경할 수 있게 됨

    참고: https://stackoverflow.com/questions/62264708/execute-combine-future-in-background-thread-is-not-working

// ImageFileManager 의 이미지를 로딩하는 부분을 Combine으로 리팩터링
func load(withID id: UUID) -> AnyPublisher<UIImage, FileManagerError> {
    return Deferred { // Deferred를 사용함으로서 Future가 즉시 바로 실행되지 않게 함
      Future { promise in
        guard let filePath = self.filePath(of: id) else {
          promise(.failure(.invalidFilePath))
          return
        }
        
        do {
          let data = try Data(contentsOf: filePath)
          guard let image = UIImage(data: data) else {
            promise(.failure(.invalidData))
            return
          }
          promise(.success(image))
        } catch {
          promise(.failure(.failToWrite(error: error)))
        }
      }
    }
    .subscribe(on: DispatchQueue.global()) // 백그라운드 스레드로로 upstream을 변경
    .eraseToAnyPublisher()
  }
  1. 모델을 가져오고 이미지를 매핑하는 로직을 Combine으로 구현
  • realm 에서 먼저 entity 를 로딩하여 model 로 매핑 한 후, ImageFileManager에서 이미지를 로딩하여 넣어주고 반환하는
    ClothesStorage의 로직을 Combine으로 리팩터링 (아직 Repository 객체 분리 전이라 Storage끼리 서로 참조하고 있는 형태)
func fetchClothesList() -> AnyPublisher<ClothesList, StorageError> {
  return Future { [weak self] promise in
    guard let self = self else { return }
    
    guard let realm = realm else {
      promise(.failure(.realmNotInitialized))
      return
    }
    
    // 반영한 모델들을 다 합한 결과를 future로 내뱉음.
    let clothesEntities = Array(realm.objects(ClothesEntity.self))
    let clothesModelsWithoutImage = clothesEntities.map { $0.toModelWithoutImage() }
    
    addingImagePublishers(to: clothesModelsWithoutImage)
      .sink { clothesModels in
        // 이미지가 모두 반영 된 ClothesList
        let clothesList = clothesModels.toClothesList()
        promise(.success(clothesList))
      }
      .store(in: &cancellables)
  }
  .eraseToAnyPublisher()
}

private func addingImagePublishers(to clothesModels: [Clothes]) -> AnyPublisher<[Clothes], Never> {
  // ImageFileStorage를 호출해 이미지를 로딩해서 clothes에 넣는 것을 처리하는 Publisher들
  let clothesWithImagePublishers: [AnyPublisher<Clothes, Never>] = clothesModels.map { model in
    ImageFileStorage.shared.load(withID: model.id)
      .replaceError(with: UIImage())
      .map { image in
        var clothes = model
        clothes.image = image
        return clothes
      }
      .eraseToAnyPublisher()
  }
  
  // 위에서 만든 단일의 Clothes를 방출하는 여러 Publisher들을 모아서 [Clothes] 를 방출하는 하나의 Publisher로 만듬
  return Publishers.MergeMany(clothesWithImagePublishers)
    .collect()
    .eraseToAnyPublisher()
}
profile
iOS developer

0개의 댓글