Coroutine
안녕하세요~ 오늘은 Coroutine 을 구현할 때 예외 처리에 대한 방법을 알아보도록 해볼려고 해요
흔히들 kotlin 에서는 try catch
문을 활용해서 예외를 핸들링 하거나
runCatching
문을 통해서 Result<T>
객체를 기반으로 처리를 많이 하게 되는데요.
Coroutine 에서는 직접적으로 예외를 catch 해서 하는 방식보다 다양한 방식으로 예외처리하는 유틸들을 제공한답니다.
하나씩 알아보도록 해보죠~! 🤗
Coroutine Hierarchy
예외에 대해 직접적으로 살펴보기전에 우리는 Coroutine 의 계층 구조를 알아볼 필요성이 있습니다.
Coroutine 에서의 구조는 계층 구조를 띄고 있습니다.
부모로부터 호출된 coroutine 은 자식의 성격을 띄고, 자식또한 마찬가지로 자식들을 더 만들수 있는 구조죠.
그렇기 때문에 예외가 발생하게 된다면 기본적으로는 부모로 전파하는 방식이 됩니다.
위 처럼 자식으로부터 예외가 발생하면 그대로 부모에게 전파하는 구조로 되어 있습니다.
그렇기 때문에 Coroutine 에서의 계층 구조는 아래와 같은 성격을 띄고 있습니다 ✏️
- Parent Job 은 Child Job 이 종료하기 전까지는 종료되지 않습니다
- Parent Job 이 Cancel 되면 하위에 속한 Child Job 들은 모두 종료됩니다
- Child Job 에서 Exception 이 발생하면 Parent Job 까지 전파합니다
Coroutine Exception Handler
우선 예제코드와 함께 예외를 발생하는 계층 구조 코루틴을 만들어보겠습니다
private fun logging(message: String) {
println("[${Thread.currentThread().name}] $message")
}
private suspend fun childException(scopeName: String) {
delay(10)
logging("($scopeName) Hi I'm exception")
throw IllegalStateException("Not supported yet")
}
private suspend fun childNormal(scopeName: String) {
delay(100)
logging("($scopeName) Hi I'm normal")
}
private suspend fun childScope() {
coroutineScope {
val failed = launch { childException("depth2") }
val success = launch { childNormal("depth2") }
joinAll(success, failed)
}
}
private suspend fun rootScope() {
coroutineScope {
val childScope1 = launch { childScope() }
val childScope2 = launch { childNormal("depth1") }
joinAll(childScope1, childScope2)
}
}
fun main() = runBlocking {
val root = CoroutineScope(Dispatchers.Default).launch {
rootScope()
}
root.join()
delay(1000)
}
위와 같이 강제로 예외를 발생하는 함수를 만들고 호출해보면 아래와 같이 됩니다
depth2 에 해당하는 예외에 대한 전파가 root 까지 일어나서 전파하게 되죠.
여기서 눈여겨 볼 것은
예외를 발생하는 함수는 delay 를 10ms 를 주었고, 정상적인 함수는 delay 를 100ms 를 주었습니다.
그 결과, 정상적인 함수는 제대로 일을 완료하지 못하고 종료되어 버렸죠
Coroutine 에 대한 예외 전파가 발생하여 다른 Child Coroutine 들도 정상적으로 수행을 하지 못하게 되는 것이죠.
보통 Coroutine 을 사용하지 않는 경우에는 childScope 에 runCatching
문을 통해서 예외를 잡을 수 있을 것이라 생각합니다.
private suspend fun childException(scopeName: String) {
delay(10)
logging("($scopeName) Hi I'm exception")
throw IllegalStateException("Not supported yet")
}
private suspend fun childNormal(scopeName: String) {
delay(100)
logging("($scopeName) Hi I'm normal")
}
private suspend fun childScope() {
coroutineScope {
runCatching {
val failed = launch { childException("depth2") }
val success = launch { childNormal("depth2") }
joinAll(success, failed)
}.onFailure {
logging("exception handled : $it")
}
}
}
하지만 놀랍게도 그러지 못합니다.
왜냐하면 예외를 통제할 수 있는 권한이 없기 때문입니다.
async 를 통해서 별도의 coroutine life cycle 을 가져갔기 때문에 이미 실패한 예외에 대한 전파가 되었습니다.
만일 예외를 핸들링하고 싶다면 coroutineScope 바깥에 runCatching 을 걸어줌으로써 해결할 수 있습니다
따라서 childNormal 에 대한 cancell 트리거링이 되었고, Job 에 대한 cancellationException 만 catch 했음을 볼 수 있네요
이를 해결할 수 있는 방법은 CoroutineExceptionHandler
입니다.
private fun exceptionHandler(): CoroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
logging("exception handled : $exception")
}
fun main() = runBlocking {
val root = CoroutineScope(Dispatchers.Default + exceptionHandler()).launch {
rootScope()
}
root.join()
delay(1000)
}
예외 핸들러를 만들고, Coroutine Context 에 포함시킴으로써 예외를 핸들링할 수 있습니다.
이를 최종적으로 그림으로 나타내면 아래와 같습니다.
자식에서 발생한 예외로 인하여 최상단 Coroutine 하위에 속한 모든 Coroutine 들이 cancel 되어 normal 을 실행하지 못한 모습이죠
그렇다면 궁금한게 생깁니다.
만일 별도 life cycle 로 예외가 전파되든 말든 일부 자식이라도 normal 을 가동하게 하려면 어떻게 해야할까요? 🤔
SupervisorJob
이 경우에는 SupervisorJob
을 이용해서 해결할 수 있습니다.
SupervisorJob
은 자식에서 발생한 예외를 부모로 Coroutine 으로 전파하지 않습니다.
따라서 자식에 있는 Coroutine 들이 여러개 있더라도,
본인이 속한 Coroutine 만 실패하고, 다른 동일한 레벨의 자식 Coroutine 은 정상 수행하게 됩니다.
private suspend fun childException(scopeName: String) {
delay(10)
logging("($scopeName) Hi I'm exception")
throw IllegalStateException("Not supported yet")
}
private suspend fun childNormal(scopeName: String) {
delay(100)
logging("($scopeName) Hi I'm normal")
}
private suspend fun childScope() {
coroutineScope {
val failed = launch { childException("depth2") }
val success = launch { childNormal("depth2") }
joinAll(success, failed)
}
}
private suspend fun rootScope() {
supervisorScope {
val childScope1 = launch(exceptionHandler()) { childScope() }
val childScope2 = launch { childNormal("depth1") }
joinAll(childScope1, childScope2)
}
}
coroutineScope 를 supervisorScope 로 수정하고 수행하면 아래와 같습니다.
이것을 그림으로 나타내면 아래와 같습니다.
CoroutineB-2 에서 발생한 예외가 B-1 로 영향을 끼치지 못한 모습이죠.
정리하며
많이 어렵죠..?
Coroutine 에서의 예외 핸들링 방법에 대해 알아보았습니다. 오늘 제가 정리한 글을 요약하면..?
- CoroutineExceptionHandler 를 CoroutineContext 에 주입해서 예외 핸들링할 수 있다
- try - catch 는 반드시 CoroutineScope 바깥에 있어야 자식 Coroutine 에서 발생한 예외를 핸들링할 수 있다
- SupervisorJob 은 자식 Coroutine 에서 예외가 발생하면 부모로 전파하지 않고, 예외가 발생한 자식 Coroutine 까지만 영향을 끼친다
- 단 그렇기 때문에 자식 Coroutine 마다 별도로 ExceptionHandler 를 정의하여 처리하는 것이 좋다
참고
'Developer > Kotlin & Java' 카테고리의 다른 글
[Kotlin] Kotlin Annotation 에 대해 톧아보기 (0) | 2023.04.01 |
---|---|
[Kotlin] Coroutine - 5. 코루틴의 Channel 의 모든 것 (0) | 2023.03.05 |
[Kotlin] Coroutine - 3. 코루틴의 Flow 활용 (2) | 2022.11.27 |
[Kotlin] Coroutine - 2. CoroutineScope & Context & Dispathcer 을 파헤쳐보자 (2) | 2022.03.01 |
[Kotlin] Coroutine - 1. 코루틴에서 동시성이란? (5) | 2022.02.05 |