기본적인 Coroutine과 Rx 에러 처리 비교

기본적인 Coroutine과 Rx 에러 처리 비교

Apr 5, 2023. | By: pluulove

이미지 출처 : UnsplashAlexander Mils

Android에서도 AsyncTask 대신 Coroutine으로 비동기 처리하는 사례는 많으며, Rx대신 Coroutine으로 마이그레이션도 쉽게 볼 수 있습니다.

그런데, 여러분은 Coroutine 에러 처리는 잘 챙기고 있으신가요?

몇 가지의 케이스와 함께 Coroutine을 더 안전하게 사용하는 방법을 살펴보겠습니다.


테스트 환경

  • Coroutine 1.6.4
  • RxJava 3.0.7 / RxAndroid 3.0.0

샘플 코드

  • https://github.com/Pluu/DiffRxCoroutineSample
  • 샘플에서는 DI 및 Repository 정의는 생략했습니다.

핵심은…

결론부터 말하면 Coroutine launch 사용 시 에러를 위한 CoroutineExceptionHandler는 필수에 가깝습니다.

Case1. 네트워크 에러

앱에서 API 호출 시 자주 접하는 네트워크 에러로는 API 응답을 받아 처리하는 서버의 오류와 스마트폰 자체의 네트워크 이슈로 발생하는 오류 2가지가 있습니다.

Case1-1. RxJava

먼저 Rx부터 네트워크 에러 동작을 살펴보겠습니다. Rx는 api.getUser() API 호출 후, subscribe에서 onSuccess/onError 파라미터를 필수로 지정하도록 정의되어 있습니다.

interface GitHubService {
  @GET("/users/Pluu")
  fun getUser(): Single<User>

  @GET("/error")
  fun tryNetworkError(): Maybe<Any?>
}

class MainViewModel : ViewModel() {
  fun tryRxNetworkError() {
    api.getUser()
      .subscribeOn(Schedulers.io())
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe(
        { success -> /** success action */ },
        { throwable -> /** error action */ }
      )
  }
  
  fun tryRxGetUser() {
    api.getUser()
      .subscribeOn(Schedulers.io())
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe(
        { success -> /** success action */ },
        { throwable -> /** error action */ }
      )
  }
}

네트워크 에러 시 onError에는 다음과 같이 결과가 전달됩니다.

  • Http Code가 존재하지만, 응답이 정상이 아닌 경우
    • 예: retrofit2.adapter.rxjava3.HttpException: HTTP 404
  • Http Code가 존재하지 않는 경우 (네트워크가 끊어진 상태)
    • 예: java.net.UnknownHostException: Unable to resolve host “api.github.com”: No address associated with hostname

Case1-2. RxJava onError 미정의

RxJava의 subscribe에 onError를 정의하지 않는 작성법도 존재합니다.

class MainViewModel : ViewModel() {
  fun tryRxNetworkError() {
    api.getUser()
      .subscribeOn(Schedulers.io())
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe { success -> 
        /** success action */ 
      }
  }

  fun tryRxGetUser() {
    api.getUser()
      .subscribeOn(Schedulers.io())
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe { success -> 
        /** success action */ 
      }
  }
}

이 경우는 둘 다 크래시 발생으로 앱이 강제 종료됩니다.

  • Http Code가 존재하지만, 응답이 정상이 아닌 경우

FATAL EXCEPTION: main Process: com.pluu.diffrxcoroutinesample, PID: 23807

io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException: The exception was not handled due to missing onError handler in the subscribe() method call. Further reading: https://github.com/ReactiveX/RxJava/wiki/Error-Handling | retrofit2.adapter.rxjava3.HttpException: HTTP 404

  • Http Code가 존재하지 않는 경우 (네트워크가 끊어진 상태)

io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException: The exception was not handled due to missing onError handler in the subscribe() method call. Further reading: https://github.com/ReactiveX/RxJava/wiki/Error-Handling | java.net.UnknownHostException: Unable to resolve host “api.github.com”: No address associated with hostname

Case1-3. Coroutine launch 사용

다음으로는 AndroidX의 ViewModel에서 ViewModelScope를 사용하여 Coroutine suspend 함수를 호출 시의 동작을 살펴보겠습니다.

interface GitHubService {
  @GET("/users/Pluu")
  suspend fun suspendGetUser(): User

  @GET("/error")
  suspend fun suspendTryNetworkError(): Any?
}

class MainViewModel : ViewModel() {
  fun tryCoroutineNetworkError() {
    viewModelScope.launch {
      val success = api.suspendTryNetworkError()
      /** success action */ 
    }
  }
  
  fun tryCoroutineGetUser() {
    viewModelScope.launch {
      val success = api.suspendGetUser()
      /** success action */
    }
  }
}

이 경우는 둘 다 크래시 발생으로 앱이 강제 종료됩니다.

  • Http Code가 존재하지만, 응답이 정상이 아닌 경우
Process: com.pluu.diffrxcoroutinesample, PID: 23586
retrofit2.HttpException: HTTP 404 
 at retrofit2.KotlinExtensions$await$2$2.onResponse(KotlinExtensions.kt:53)
 at retrofit2.OkHttpCall$1.onResponse(OkHttpCall.java:161)
  • Http Code가 존재하지 않는 경우 (네트워크가 끊어진 상태)
FATAL EXCEPTION: main
Process: com.pluu.diffrxcoroutinesample, PID: 24398

Case1-4. Coroutine launch + CoroutineExceptionHandler

여기에서는 Coroutine Context에서 처리되지 않은 에러를 핸들링하기 위한 CoroutineExceptionHandler 사용해보겠습니다.

class MainViewModel : ViewModel() {
  fun tryCoroutineNetworkError() {
    val ceh = CoroutineExceptionHandler { _, t ->
      /** error action */
    } 
    viewModelScope.launch(ceh) {
      val success = api.suspendTryNetworkError()
      /** success action */ 
    }
  }
  
  fun tryCoroutineGetUser() {
    val ceh = CoroutineExceptionHandler { _, t ->
      /** error action */
    } 
    viewModelScope.launch(ceh) {
      val success = api.suspendGetUser()
      /** success action */
    }
  }
}

네트워크 에러 시 CoroutineExceptionHandler에 다음과 같은 결과가 전달됩니다.

  • Http Code가 존재하지만, 응답이 정상이 아닌 경우
    • 예: retrofit2.HttpException: HTTP 404
  • Http Code가 존재하지 않는 경우 (네트워크가 끊어진 상태)
    • 예: java.net.UnknownHostException: Unable to resolve host “api.github.com”: No address associated with hostname

Case2. API 응답 성공 후 에러

비동기 호출이 성공이더라도 이후의 로직에서도 에러가 발생할 수 있습니다. 데이터가 의도와 다른 형태로 유입될 수도 있기 때문입니다.

Case2에서는 API 응답 후 로직 처리 시 에러 발생과 유사하게 강제로 throw 발생으로 확인했습니다.

Case2-1. RxJava onSuccess에서 에러 발생

RxJava에서는 비동기 호출 성공 시 onSuccess 내부에서도 에러가 발생할 수 있습니다.

class MainViewModel : ViewModel() {
  fun tryRxViewModelError() {
    api.getUser()
      .subscribeOn(Schedulers.io())
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe(
        { success -> 
            throw IllegalStateException("Force exception on ViewModel") },
        { throwable -> /** error action */ }
      )
  }
}

이 경우에도 크래시 발생으로 앱이 강제 종료됩니다.

FATAL EXCEPTION: main
Process: com.pluu.diffrxcoroutinesample, PID: 28289
java.lang.IllegalStateException: Force exception on ViewModel
 at com.pluu.diffrxcoroutinesample.presentation.MainViewModel$tryRxViewModelError$1.accept(MainViewModel.kt:50)

이 케이스의 해결법은 몇 가지 존재하겠지만, 전역적인 예외 핸들링인 RxJavaPlugins.setErrorHandler를 쓰는 방법도 있습니다. 자세한 내용은 공식 문서를 살펴봐 주세요.

Case2-2. Coroutine launch 내부에서 에러 발생

Coroutine은 Rx와 다르게 API와 그 이후 처리가 모두 Coroutine scope내에 존재하므로 에러가 발생하더라도 이전처럼 CoroutineExceptionHandler를 전달하면 에러를 핸들링할 수 있습니다.

class MainViewModel : ViewModel() {
  fun tryCoroutineViewModelError() {
    val ceh = CoroutineExceptionHandler { _, t ->
      /** error action */
    }
    viewModelScope.launch(ceh) {
      val success = api.suspendGetUser()
      throw IllegalStateException("Force exception on ViewModel")
    }
  }
}

Case3. LiveData 수신 후 에러

LiveData를 옵저빙한 블록 내부에서 실패한 경우에는 블록이 정상적으로 종료되지 않아 2번째부터는 올바르게 옵저버가 호출되지 않습니다.

===== 1번째 호출 후 에러 =====
--> GET https://api.github.com/users/Pluu
tagSocket(86) with statsTag=0xffffffff, statsUid=-1
<-- 200 https://api.github.com/users/Pluu (362ms, unknown-length body)
Success: User(name=pluulove)
Receive: User(name=pluulove)
Error [Thread=main]: Force exception on UI
Error Receive = Force exception on UI
===== 2번째 호출 후 에러 =====
--> GET https://api.github.com/users/Pluu
<-- 200 https://api.github.com/users/Pluu (14ms, unknown-length body)
Success: User(name=pluulove)
===== 3번째 호출 후 에러 =====
--> GET https://api.github.com/users/Pluu
<-- 200 https://api.github.com/users/Pluu (18ms, unknown-length body)
Success: User(name=pluulove)

결론

많은 프로젝트들이 모듈화/코드 분리/계층 도입에 큰 노력을 귀 기울이고 있지만, 그만큼 에러 대응도 필수적이라고 볼 수 있습니다. 이번 블로그에서는 간단한 해결법만 살펴봤지만, 어느 정도까지 커버를 할지에 따라서 작업 난이도는 달라질 것입니다.

  RxJava
success 정의 & error 정의
RxJava
success 정의 & error 미정의
Coroutine launch만 사용 Coroutine launch 사용
CoroutineExceptionHandler 정의
서버 에러 O X X O
네트워크 끊어짐 O X X O

comments powered by Disqus

Currnte Pages Tags

Android Kotlin

About

Pluu, Android Developer Blog Site

이전 블로그 링크 :네이버 블로그

Using Theme : SOLID SOLID Github

Social Links