https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c
개발자들은 항상 happy path 만을 갈고 닦기를 좋아하지만,
항상 사용자들은 우리의 예상을 벗어난다.
만약 사용자들의 액션이 성공하지 않은 경우, 적절한 에러 메시지를 보여주는 것은 필수 불가결하다.
예외를 어떻게 적절하게 처리하느냐는 사용자가 애플리케이션을 바라보는 데 있어 아주 중요한 요소이다.
코루틴에서 어떤식으로 에러를 제대로 매니징 하는지 알아보자.
CoroutineScope 안에서 어떠한 코루틴이 실패로 끝나서 예외를 던진다면
0) 자식 코루틴 실패 -> 1) 해당 스코프 안의 다른 자식 코루틴 취소 -> 2) 부모 코루틴 취소 -> 3) 위로 전달
과 같은 과정을 거치게 된다.
위로 예외를 전달하는게 어떤 경우에는 말이 될 수 있지만, 이런 처리가 바람직하지 않은 경우들이 종종 있다.
UI 관련된 CoroutineScope 에 대해서 생각해보자.
만약 child coroutine 이 예외를 던져서 UI Scope 가 캔슬이 되었다면
모든 UI 컴포넌트들이 무응답 상태에 빠지게 된다.
만약 이런식의 일괄 취소 동작을 원하지 않는다면 어떻게 해야할까?
SupervisorJob 이라는 애를 사용하자.
자식 코루틴들을 생성하는 CoroutineScope 에 들어가는 CoroutineContext 에다가 SupervisorJob 을 박아주는 것이다.
Supervisor Job to the rescue
SupervisorJob 을 사용하면, 자식의 실패가 다른 자식들에 영향을 주지 않는다.
또한 위로 예외를 전달하지도 않는다.
단지 자식 코루틴이 스스로 던진 예외를 처리하도록 내버려둔다.
val uiScope = CoroutineScope(SupervisorJob()) -- CoroutineContext 로 넣어준 것 염두에 두자
이렇게 uiScope 라는 애를 만들어서 사용해볼 수 있다.
만약 exception 이 처리가 안되었고, CoroutineContext 가 CoroutineExceptionHandler 가 없다면 default thread 의 ExceptionHandler 에 도달한다. JVM 에서는 이 오류가 콘솔에 로깅이 될 것이고, 안드로이드에서는 어떤 Dispatcher 에서 수행되든간에 앱 크래시가 날 것이다.
💥 Uncaught exceptions will always be thrown regardless of the kind of Job you use
위에서 설명한 것들은 scope builder 두가지
- coroutineScope
- supervisorScope
에도 동일하게 적용된다.
얘네들은 sub-scope 을 생성할 것이며 (각각, Job, SupervisorJob 을 부모로 가진 채로)
당신은 코루틴들을 이 sub-scope 내부에서 생성하게 된다.
그러면 언제 Job / SupervisorJob 써야 하는건가?
SupervisorJob 은 당신이 failure 가 났을 때 부모 그리고 이웃 코루틴들을 취소하고 싶지 않을 때 사용한다.
예시:
// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(SupervisorJob())
scope.launch {
// Child 1
}
scope.launch {
// Child 2
}
-> 위 예시에서 child 1 의 실패는, child 2 에 영향을 주지 않는다.
-> SupervisorJob 의 동작에 의하여.
또 다른 예시
// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(Job())
scope.launch {
supervisorScope {
launch {
// Child 1
}
launch {
// Child 2
}
}
}
-> supervisorScope 는 sub-scope 를 만들고, child 1 의 오류는 child 2 에 영향을 주지 않는다.
-> 만약 coroutineScope 빌더를 사용했다면, child 1 의 오류는 child 2 를 취소시키고, 부모 위로까지 전달될 것임.
그렇다면 이런 상황은 어떨까?
Job이랑 SupervisorJob 을 이렇게 섞어서 사용하면 어떨까?
val scope = CoroutineScope(Job())
scope.launch(SupervisorJob()) {
// new coroutine -> can suspend
launch {
// Child 1
}
launch {
// Child 2
}
}
child 1 의 부모는 어떤 Job 일지 맞춰보자.
정답은 그냥 Job 이다.
SupervisorJob 은 scope.launch 로 만들어진 코루틴의 부모인데,
사실상 위 코드에서는 아무것도 하지 않는다.
따라서, child 1 또는 child 2 가 실패하면, failure 는 sopce 에 도달할 것이고, 그 scope 에서 시작된 모든 작업들은 취소된다.
기억하기!
SupervisorJob 은 오직 scope 의 일부일떄만 제대로 작동한다.
- supervisorScope 라는 스코프 빌더를 사용하거나
- CoroutineScope(SupervisorJob()) 과 같이 CoroutineContext 로서 정의될때.
coroutine builder 의 인자로 SupervisorJob 을 넘겨주는 것은 아무런 소용이 없다고~
내부가 궁금한가?
SupervisorJob 구현을 보면 childCancelled 메서드는 단지 false 를 반환한다.
즉, cancellation 을 전달하지 않고, 예외 또한 처리하지 않는다.
Dealing with Exceptions
launch / async - 어떤 코루틴 빌더를 사용하느냐에 따라 에러 처리가 다르다.
launch 의 경우 실행 즉시 예외가 던져진다.
따라서 try-catch 로 예외를 던지는 코드를 감싸주면 된다.
scope.launch {
try {
codeThatCanThrowExceptions()
} catch(e: Exception) {
// Handle exception
}
}
With launch, exceptions will be thrown as soon as they happen
Async 가 root coroutine (coroutines that are a direct child of a CoroutineScope instance or supervisorScope) 일때는, 예외는 자동으로 던져지지 않는다. await() 를 호출할때서야 비로소 예외가 던져진다.
supervisorScope {
val deferred = async {
codeThatCanThrowExceptions()
}
try {
deferred.await()
} catch(e: Exception) {
// Handle exception thrown in async
}
}
이런 케이스에서는 async 는 절대로 예외를 던지지 않는다.
await() 가 async 내부에서 일어난 예외를 던지게 된다.
supervisorJob 을 사용하고 있는 것을 보자.
즉, child 가 자체적으로 터뜨린 예외를 처리하도록 내버려 둔다.
따라서 catch 블럭이 실행이 될 것이고, async 가 던지는 예외를 잡아서 삶아먹을 수 있다.
coroutineScope 를 사용하면,
우리의 예상대로 자식의 예외가 다른 자식, 그리고 부모에게 전파된다.
coroutineScope {
try {
val deferred = async {
codeThatCanThrowExceptions()
}
deferred.await()
} catch(e: Exception) {
// Exception thrown in async WILL NOT be caught here
// but propagated up to the scope
}
}
-> 위의 경우 coroutineScope 이기 때문에 async 가 던진 예외는 catch 에서 잡히지 않고 스코프 위로 전달된다.
덤으로, 다른 코루틴 내부에서 생성된 코루틴에서는
코루틴 빌더가 뭐냐에 따라 상관 없이 항상 예외가 위로 전달된다.
가령,
val scope = CoroutineScope(Job())
scope.launch {
async {
// If async throws, launch throws without calling .await()
}
}
이런게 있는 경우,
scope.launch 라는 빌더로 만들어진 코루틴 안에서
async 라는 애로 코루틴을 다시 만든 것인데
이 때 async 에서 예외가 발생한다면 await() 호출하지 않아도 예외가 던져진다.
다시 말해, async 가 root coroutine 이 아니기 때문에 await() 에서 예외가 터지지 않는다.
⚠️ Exceptions thrown in a coroutineScope builder or in coroutines created by other coroutines won’t be caught in a try/catch!
CoroutineExceptionHandler 라는 애가 예전부터 언급됐는데
왜 이제서야 나오느냐?
이유는 얘의 역할이 catch 되지 않는 예외를 다루도록 해주는 애기 때문이다.
아까부터 계속 부모 위로 예외가 전달 된다고 했는데,
그럴때 실행되는 예외 처리기가 CoroutineExceptionHandler 라고 할 수 있다.
Exceptions will be caught if these requirements are met:
- When ⏰: The exception is thrown by a coroutine that automatically throws exceptions (works with launch, not with async).
- Where 🌍: If it’s in the CoroutineContext of a CoroutineScope or a root coroutine (direct child of CoroutineScope or a supervisorScope).
val handler = CoroutineExceptionHandler {
context, exception -> println("Caught $exception")
}
val scope = CoroutineScope(Job())
scope.launch(handler) {
launch {
throw Exception("Failed coroutine")
}
}
// 틀린 예시
val scope = CoroutineScope(Job())
scope.launch {
launch(handler) { // 빌더에 박으면 어떡하나?
// CoroutineContext 에 인스톨 되어야 핸들러 제대로 작동한다.
// 요 런치는 예외 일어나자 마자 바로 부모한테 전달하는데, 부모는 핸들러를 모르기 때문이다.
throw Exception("Failed coroutine")
}
}
요약)
SupervisorScope, supervisorScope 는 자식 예외 처리 씹는다.
자식이 알아서 해야한다.
async 가 root coroutine 에서 생성된다면 await 호출시 예외 던져진다
그 외에는 async 실행시 곧바로 예외 던져진다
launch 는 실행시 곧바로 예외 던져진다
위로 위로 전달된 예외는 scope 에 내장된 CoroutineExceptionHandler 로 들어가서 처리될수도.
'Android' 카테고리의 다른 글
[책] 코틀린 코루틴 - Mutex vs Single thread dispatcher (0) | 2024.05.31 |
---|---|
5. Kotlin Coroutine - Cancellation (0) | 2024.05.22 |
4. Kotlin Coroutine - CoroutineScope/Context/Job (0) | 2024.05.21 |
Android - Coroutines best practices (0) | 2024.05.15 |
3. Kotlin Coroutine - Concurrency issues (0) | 2024.05.15 |