이미지 출처 : Unsplash의 Alexander Mils
Android에서도 AsyncTask 대신 Coroutine으로 비동기 처리하는 사례는 많으며, Rx대신 Coroutine으로 마이그레이션도 쉽게 볼 수 있습니다.
그런데, 여러분은 Coroutine 에러 처리는 잘 챙기고 있으신가요?
몇 가지의 케이스와 함께 Coroutine을 더 안전하게 사용하는 방법을 살펴보겠습니다.
테스트 환경
샘플 코드
결론부터 말하면 Coroutine launch 사용 시 에러를 위한 CoroutineExceptionHandler는 필수에 가깝습니다.
앱에서 API 호출 시 자주 접하는 네트워크 에러로는 API 응답을 받아 처리하는 서버의 오류와 스마트폰 자체의 네트워크 이슈로 발생하는 오류 2가지가 있습니다.
먼저 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에는 다음과 같이 결과가 전달됩니다.
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 */
}
}
}
이 경우는 둘 다 크래시 발생으로 앱이 강제 종료됩니다.
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
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
다음으로는 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 */
}
}
}
이 경우는 둘 다 크래시 발생으로 앱이 강제 종료됩니다.
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)
FATAL EXCEPTION: main
Process: com.pluu.diffrxcoroutinesample, PID: 24398
여기에서는 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에 다음과 같은 결과가 전달됩니다.
비동기 호출이 성공이더라도 이후의 로직에서도 에러가 발생할 수 있습니다. 데이터가 의도와 다른 형태로 유입될 수도 있기 때문입니다.
Case2에서는 API 응답 후 로직 처리 시 에러 발생과 유사하게 강제로 throw 발생으로 확인했습니다.
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를 쓰는 방법도 있습니다. 자세한 내용은 공식 문서를 살펴봐 주세요.
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")
}
}
}
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
Subscribe to this blog via RSS.
LazyColumn/Row에서 동일한 Key를 사용하면 크래시가 발생하는 이유
Posted on 30 Nov 2024