RxSwift Scheduler

Scheduler 관리의 중요성을 깨닫게 된 기회
Posted on 2022-01-12 by GKSRUDTN99
Swift&Xcode Swift Scheduler RxSwift

안녕하세요, CodeCamper입니다.

오늘은 기능 개발 중에 있었던 문제 해결을 위해, Scheduler를 활용한 사례를 기록하고자 합니다.



#문제

프로젝트 진행 중에, 카카오톡의 채팅방에서 사진과 동영상을 모아보는 기능을 구현하고자 하였습니다.

서버에서 이미지와 동영상들을 파일이 아닌 url로 보내주고 있었기 때문에,

CollectionView와 ImageView를 가지고 있는 Cell을 만들면 이미지들의 경우에는 Kingfisher 라이브러리로 ImageView에 로드시키면 될 것으로 생각했습니다.

하지만 동영상의 경우, 동영상 썸네일을 만들어서 썸네일을 ImageView에 로드시켜야 하는데, 검색을 조금 해본 결과 다음코드를 사용해 썸네일을 만들 수 있었습니다.

let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
let cgImage = try! imageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
imageView.image = cgImage

여기서 url은 동영상 파일의 경로(https://file-ap-2.sendbird.com/aba49287cd204c549cfa0cbd74c34f95.mov)를 사용했습니다.

더불어, 동영상의 총 길이를 Cell 하단에 표시하기 위해 다음 코드도 사용했습니다.

let duration = Int(CMTimeGetSeconds(asset.duration))
let minute = "\(Int(duration / 60))"
let second = duration % 60 < 10 ? "0\(duration % 60)" : "\(duration % 60)"
timeLabel.text = "\(minute):\(second)"

하지만 실제로 실행시켜보니 아래 영상처럼 스크롤 시에 부자연스럽게 끊기는 현상이 생겼습니다.



#고민

이런저런 테스트를 거치며 이미지들만 화면에 표시하는 구간에서는 스크롤이 끊기지 않았고,

영상이 표시되는 Cell이 화면에 표시될 쯤에 스크롤이 끊긴다는 것을 확인했습니다.

동영상 썸네일을 만드는 코드가 RxDataSource의 ConfigureCell Closure안에 있었기 때문에, 동영상 썸네일을 만드는 작업이 문제의 원인이라고 예측했습니다.

첫번째로 생각한 최적화 방법은, ConfigureCell Closure가 실행될 때 마다 let asset = AVURLAsset(url: url)을 통해 동영상 파일을 받아오기 때문에,

네트워크 지연이 생길 수 있다고 생각해서 이전에 만들어 두었던 FileSaveManager를 활용한 해결 방법을 생각했습니다.

let asset = AVURLAsset(url: url)에 들어가는 url은 http로 시작하는 URL도 넣을 수 있지만, 마찬가지로 기기의 로컬 경로도 넣을 수 있었기 때문에

(http URL + 동영상 이름)을 FileSaveManager에 보내면,

FileSaveManager는 URL타입의 Observable를 반환하는데,

이 Observable은 해당파일 이름으로 저장된 파일이 로컬에 이미 있다면 바로 파일의 로컬 경로를 방출하고,

저장되어있지 않다면 로컬에 저장한 뒤 로컬 경로를 방출합니다.

// FileSaveManager.Swift
import Foundation
import RxSwift

class FileSaveManager {
  static let shared = FileSaveManager()

  private init() {}

  /// 이 함수는 파일을 저장하고, 파일의 경로를 방출하는 Observer를 리턴하는 함수입니다.
  ///
  /// /documentDirectory/fileName의 경로에 파일이 이미 존재하면, 저장하지 않고 해당 경로를 방출합니다.
  ///
  /// 만약 경로에 파일이 없다면, urlString을 이용해 파일을 받아와서 저장한 뒤, 저장된 경로를 방출합니다.
  /// 
  /// - Parameter urlString: 파일 저장된 S3 전체 경로 ( ex: )
  /// - Parameter fileName: 파일의 이름 ( ex: Screenshot2123.jpg )
  func saveFile(urlString: String, fileName: String) -> Observable<URL> {
    let saveFileError = NSError(domain: "saveFileFailed", code: 499, userInfo: ["errorDescription": "파일 저장에 실패했습니다."])
    return Observable.create{ observer in
      guard let actualPath = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)).last?.appendingPathComponent(fileName)
      else {
        observer.onError(saveFileError)
        return Disposables.create()
      }

      if FileManager.default.fileExists(atPath: actualPath.path) {
        observer.onNext(actualPath)
        observer.onCompleted()
      }
      else if let url = URL(string: urlString),
              let data = try? Data.init(contentsOf: url) {
        if (try? data.write(to: actualPath, options: .atomic)).isNotNil {
          observer.onNext(actualPath)
          observer.onCompleted()
        } else {
          observer.onError(saveFileError)
        }
      } else {
        observer.onError(saveFileError)
      }

      return Disposables.create()
    }
  }
}

그리고 사진을 담는 Cell Class를 만들고, Cell Class에 대응하는 Reactor도 만들어 다음과 같이 바인딩하였습니다.

reactor의 video의 Type은 SBDFileMessage입니다.

import ReactorKit

class ChatPhotoListCell {
//  ...
  func bind(reactor: ChatPhotoListCellReactor) {

    reactor.state.map{ $0.video }.distinctUntilChanged()
    .flatMap{ video, _ -> Observable<URL> in
      return FileSaveManager.shared.saveFile(urlString: video.url, fileName: video.name)
    }
    .flatMap{ url -> Observable<(UIImage, String)> in
      let asset = AVURLAsset(url: url)
      let imageGenerator = AVAssetImageGenerator(asset: asset)
      guard let cgImage = try? imageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) else { return .empty() }
      let duration = Int(CMTimeGetSeconds(asset.duration))
      let minute = "\(Int(duration / 60))"
      let second = duration % 60 < 10 ? "0\(duration % 60)" : "\(duration % 60)"
      return .just((UIImage(cgImage: cgImage), "\(minute):\(second)"))
    }
    .asDriver(onErrorDriveWith: .empty())
    .drive(onNext: { [weak self] image, timeString in
      self?.imageView.image = image
      self?.timeLabel.text = timeString
      self?.timeLabel.isHidden = false
    })
    .disposed(by: disposeBag)
  }
//
}

위 해결방법으로 어느 정도는 해소되었지만, 스크롤 끊김 현상을 완전히 해결할 수는 없었습니다.



#해결

제가 속해있는 팀은 RxSwift와 ReactorKit을 활용한 MVVM 구조를 채택하여 화면들을 구현하고 있지만,
최근 선배 개발자 분이 말씀하시기를,

아직 우리는 RxSwift를 제대로 활용하지 못하고 있는 것 같습니다.
Scheduler를 제대로 활용하지 못해서 대부분의 Task들이 MainScheduler에 할당되어 돌아가고 있는 것 같습니다,

이때 들었던 얘기와 함께 Scheduler에 대해 조금 더 알아보니 RxSwift는 용도에 따라 사용할 수 있도록 여러개의 스케쥴러를 제공하고 있었습니다.

우선, Scheduler는 DispatchQueue라는 Queue에 작업들이 순서대로 들어오면,
Queue에 들어있는 작업들이 어떤 방식(정책)으로 실행될 지 정해줍니다.

Scheduler는 크게 Serial SchedulerConcurrent Scheduler로 나눌 수 있습니다.

두 Scheduler 모두 Queue에 먼저 들어온 작업이 먼저 시작된다는 공통점이 있지만,

Serial Scheduler는 먼저 실행 중이던 작업이 끝나야 다음 작업을 시작하므로 먼저 들어온 작업이 먼저 끝납니다.

Concurrent Scheduler는 먼저 실행 중이던 작업이 진행 중이어도 다음 작업이 시작될 수 있어 늦게 들어온 작업이 먼저 끝날 수도 있습니다.

마지막으로, RxSwift에서 제공하는 Scheduler들은 다음과 같습니다.

  • MainScheduler(Serial Scheduler)
    • 메인 쓰레드에서 실행되어야 하는 작업들이 사용할 수 있는 스케쥴러입니다.
    • UI를 변경시키는 작업들이 이 스케쥴러를 이용할 수 있습니다.
  • SerialDispatchQueueScheduler(Serial Scheduler)
    • RxSwift가 제공하는 Serial Scheduler Class입니다.
    • MainScheduler는 메인 쓰레드가 사용하는 SerialDispatchQueue 인스턴스를 말합니다.
  • ConcurrentDispatchQueueScheduler(Concurrent Scheduler)
    • RxSwift가 제공하는 Concurrent Scheduler Class입니다.
    • background에서 수행되어야 하는 작업들에 적합합니다.
  • OperationQueueScheduler(Concurrent Scheduler)
    • NSOperationQueue에서 수행되어야 하는 작업이 있을 때 사용하는 Scheduler라고 하는데.. 아직 제대로 이해하지 못했습니다.
    • 추후에 더 알게되면 추가하겠습니다.

print(Thread.isMainThread)와 디버거를 통해 확인해보니,
위에서 동영상을 썸네일을 만드는 작업도 MainScheduler에서 돌아가고 있는 것 같았습니다.

동영상 썸네일을 만드는 작업과 CollectionView의 Scroll을 처리하는 작업이 같은 Scheduler에 들어가 처리되다 보니 위와 같은 문제가 생긴 것이었습니다.

파일을 저장하고 동영상 썸네일을 만드는 작업은 ConcurrentDispatchQueueScheduler에 넣어 Background에서 처리하고,
처리가 완료된 이미지를 ImageView에 로드시키는 작업만 MainScheduler에 넣어 처리한다면,
MainScheduler에 부담이 적어질 것이라고 생각하여, 위의 코드를 다음과 같이 수정하였습니다.

import ReactorKit

class ChatPhotoListCell {
//  ...
  func bind(reactor: ChatPhotoListCellReactor) {

    reactor.state.map{ $0.video }.distinctUntilChanged()
    .observe(on: ConcurrentDispatchQueueScheduler(qos: .background))
    .flatMap{ video, _ -> Observable<URL> in
      return FileSaveManager.shared.saveFile(urlString: video.url, fileName: video.name)
    }
    .observe(on: ConcurrentDispatchQueueScheduler(qos: .background))
    .flatMap{ url -> Observable<(UIImage, String)> in
      let asset = AVURLAsset(url: url)
      let imageGenerator = AVAssetImageGenerator(asset: asset)
      guard let cgImage = try? imageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) else { return .empty() }
      let duration = Int(CMTimeGetSeconds(asset.duration))
      let minute = "\(Int(duration / 60))"
      let second = duration % 60 < 10 ? "0\(duration % 60)" : "\(duration % 60)"
      return .just((UIImage(cgImage: cgImage), "\(minute):\(second)"))
    }
    .asDriver(onErrorDriveWith: .empty())
    .drive(onNext: { [weak self] image, timeString in
      self?.imageView.image = image
      self?.timeLabel.text = timeString
      self?.timeLabel.isHidden = false
    })
    .disposed(by: disposeBag)
  }
//
}

Scheduler를 바꾸고 난 뒤의 영상입니다.

최근에는 코어와 네트워크의 성능이 좋아져서 Scheduler의 중요성을 크게 체감하지 못했었는데,
이번 기회를 통해 Scheduler 관리 방법에 대해 공부하게 되어 좋은 기회였다고 생각합니다.