Error Handling in RxSwift

복잡한 네트워크 에러 처리 요구사항 만족시키기

Posted by Kim Heebeom on August 10, 2019

Rx에서 가장 다루기 힘든 녀석이 에러가 아닐까 생각합니다. 어느 상황에서는 스트림이 깨지지 않고 유지되어야 할 테고, 어느 상황에는 스트림을 종료해야 하는데 처음 Rx를 접하면 쉽지 않은 작업입니다.

에러를 다루는 기본적인 예시부터 복잡한 에러 처리 예시까지 살펴보면서 천천히 알아보겠습니다.

에러 핸들링

  • Rx에서 에러를 다루는 방법은 크게 두 가지가 있다.
    • Catch: 일반적인 프로그래밍 언어의 try catch와 비슷한 의미를 가진다.
    • Retry: 무조건 혹은 특정 상황에 재시도한다.

Catch를 이용한 에러 처리

  • catch 계열에는 두 가지 주요 연산자가 있다.
    • catchError
    • catchErrorJustReturn
catchError
func catchError(_ handler:) -> RxSwift.Observable<Self.E>
  • 클로저를 매개변수로 받아서 새로운 형태의 Observable로 변환한다.
  • 아래 예시는 error 이벤트를 받아서 조건에 따라 next 이벤트로 반환한다.
    Observable<Weather>
      .concat([.error(MyError.requestError1),
              .just(.hot)])
      .catchError { error -> Observable<Weather> in
          print(.catchError, error)
          if case MyError.requestError1 = error {
              return .just(.cloudy)
          }
          return .just(.cold)
      }
      .subscribe(
          ...
      ).disposed(by: disposeBag)
        
      // 출력
      // catchError: requestError1
      // onNext: cloudy
      // onCompleted
    
catchErrorJustReturn
func catchErrorJustReturn(_ element:) -> RxSwift.Observable<Self.E>
  • 에러를 무시하고 지정된 값으로 반환할 때 사용한다.
  • catchError와 달리 에러 종류에 대한 구분이 필요 없을 때 사용하면 훨씬 간결하게 표현할 수 있다.
    Observable<Weather>
      .concat([.error(MyError.requestError1),
              .just(.hot)])
      .catchErrorJustReturn(.cloudy)
      .subscribe(
          ...
      ).disposed(by: disposeBag)
        
      // 출력
      // onNext: cloudy
      // onCompleted
    

Retry를 이용한 에러 처리

  • retry는 에러 발생 시 즉각 기존 구독을 dispose하고 새로운 구독을 생성해서 전체 작업을 반복한다.
  • 비교적 복잡한 에러 처리에 사용할 때 retryWhen 연산자를 사용하자.
retry
func retry(_ maxAttemptCount:) -> Observable<E>
  • retry의 매개변수는 재시도 횟수가 아니라 최대시도 횟수이다.
  • retry(2)를 넣으면 에러가 발생할 경우 한 번 재시도하고 한 번 더 에러가 발생하는 순간 error 이벤트를 그대로 전달한다.
    Observable<Weather>
      .concat([.just(.hot),
              .just(.sunny),
              .error(MyError.requestError1),
              .just(.dry)])
      .retry(2)
      .subscribe(
          ...
      ).disposed(by: disposeBag)
        
      // 출력
      // onNext: hot
      // onNext: sunny
      // onNext: hot
      // onNext: sunny
      // onError: requestError1
      // onDisposed
    
  • catchErrorretry를 함께 사용한 예제
    Observable<Weather>
      .concat([ApiController.shared.currentWeather_retry_첫번째_에러(),
               .just(.hot)])
      .retry(2)
      .catchError { error -> Observable<Weather> in
          if case MyError.requestError1 = error {
              return .just(.cloudy)
          }
          return .just(.cold)
      }
      .subscribe(
          ...
      ).disposed(by: disposeBag)
        
      // 출력
      // onNext: hot
      // onNext: sunny
      // onNext: hot
      // onDisposed
    


retryWhen
func retryWhen(_ notificationHandler:) -> Observable<E>
  • notificationHandlerTriggerObservable 타입이다.
  • trigger observable은 Observable 또는 Subject 모두가 될 수 있다. 또한 임의로 retry를 trigger 하는 데 사용된다.
  • 어떻게 사용하는지 감이 안 잡힌다. 예시를 보자.
    var maxAttempts = 4
      // 
      .retryWhen { e -> Observable<Int> in
          // trigger 역할을 하는 Observable
          return e.enumerated().flatMap { (arg) -> Observable<Int> in
              let (attempt, error) = arg
              let delay = Double(pow(Double(2), Double(attempt)))
              if attempt >= maxAttempts - 1 {
                  return Observable.error(error)
              }
              return Observable<Int>
                  .timer(delay, scheduler: MainScheduler.instance)
          }
      }
    

Timeout 활용하기

timeout
func timeout(_ dueTime: RxTimeInterval, scheduler: SchedulerType) -> Observable<Element>
  • 일정 시간 동안 이벤트가 발생하지 않으면 에러를 발생시킨다.
      ApiController.shared.currentWeather_timeout_세번째_에러_네번째_성공()
          .timeout(2, scheduler: MainScheduler.instance)
          .retry(4)
          .subscribe(
              ...
          ).disposed(by: disposeBag)
    

네트워크 에러 처리 같은 좀 더 복잡한 케이스는?

  • API 또는 HTTP 에러가 발생한 상황을 가정하자. 우리는 아래 방식으로 설계하려고 한다.
    • 매 요청의 타임아웃 3s
    • 에러가 발생할 때마다 딜레이를 증가시키며 최대 10번 요청
    • 그래도 에러가 떨어지면 기본값 반환
var maxAttempts = 10
ApiController.shared.currentWeather()
    .timeout(3, scheduler: MainScheduler.instance)
    .retryWhen { error -> Observable<Int> in
        return error.enumerated().flatMap { (arg) -> Observable<Int> in
            let (attempt, error) = arg
            let delay = Double(pow(Double(2), Double(attempt)))
            if attempt >= maxAttempts - 1 {
                return Observable.error(error)
            }
            return Observable<Int>
                .timer(delay, scheduler: MainScheduler.instance)
                .take(1)
        }
    }
    .catchErrorJustReturn(.cloudy)
    .subscribe(
        ...
    ).disposed(by: disposeBag)

스트림을 깨뜨리지 않고 에러 처리하기

  • Observable chain의 시작 부분에서 에러가 발생했을 때 별도의 관리를 하지 않는 경우 그대로 구독자에게 전달된다.
  • Observable이 에러 이벤트를 방출했을 때 에러 구독이 확인되고 이로 인해 모든 구독이 dispose 된다는 뜻이다.
    • 에러 이벤트 이후의 모든 이벤트는 무시된다.
  • merge, zip, concat로 여러 Observable을 붙여서 사용하는 경우가 많다. 하나의 Observable에서 에러가 방출되더라도 무시하고 스트림을 이어가려면 위 연산자들 전에 에러를 핸들링을 해줘야만 한다.
      func aRetry(_ e: Observable<Error>) -> Observable<Int> {
          ...
      }
    
      func bRetry(_ e: Observable<Error>) -> Observable<Int> {
          ...
      }
    
      let aObservable = ApiController.shared.currentWeather()
          .retryWhen(aRetry)
    
      let bObservable = ApiController.shared.currentWeather2()
          .catchErrorJustReturn(.cloudy)
    
      let cObservable = ApiController.shared.currentWeather3()
          .retryWhen(bRetry)
    
      Observable.merge(aObservable, bObservable, cObservable)
          .subscribe(
              ...
          ).disposed(by: disposeBag)
    
    • bObservable에서 에러가 방출되더라도 전체 스트림에는 영향을 주지 않는다.