Post

(Kotlin Coroutine) Exception Handling

https://kotlinlang.org/docs/exception-handling.html

Job과 SupervisorJob / coroutineScope와 supervisorScope의 예외 전파 차이

Job과 coroutineScope의 동작

  • 코루틴에서 발생한 exception이 코루틴 내부에서 catch 되지 않고 바깥으로 나갔다면, 부모, 자식 양방향으로 전파된다.
    • child에서 발생하는 경우, parent를 따라 올라가면서 root coroutine 까지 전부 취소된다.
    • parent에서 발생해도, child가 취소된다.
    • 즉, child에서 발생하면 sibling 까지 전부 취소된다. (launch, async로 진행 중이던 코루틴 까지)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
runBlocking {
    println("scope - start")
    launch {
        println("launch1 - start")
        delay(100)
        error("launch1 - ex")
    }

    launch {
        println("launch2 - start")
        delay(200)
        println("never reached")
    }

    delay(1000)
    println("never reached")
}
scope - start
launch1 - start
launch2 - start

[!info] launch1에서 발생한 ex로 인해 launch2와 parent까지 중단되었다.

SupervisorJob과 supervisorScope의 동작

  • UI 컴포넌트 처럼, 하나 실패했다고 root 코루틴까지 올라가면서 parent와 sibling을 다 취소할 필요가 없는 경우. 문제가 발생한 그 코루틴과 child만 취소하면 되는 경우는 supervision을 사용한다.
    • supervisorScope { } 또는 SupervisorJob()
    • supervisorScope 하위의 launch {} 블록에서 Exception 발생 시 부모, 자식 양방향으로 전파하지 않고 부모 → 자식 방향 으로만 전파하게 된다.
    • CoroutineExceptionHandler를 지정했다고 하더라도, supervisorScope가 아니면 제대로 catch되지 않고 전부 취소되므로 이런 use case에서는 supervisorScope를 꼭 사용해주어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
runBlocking {
    supervisorScope {
        println("scope - start")
        launch {
            println("launch1 - start")
            delay(100)
            error("launch1 - ex")
        }

        launch {
            println("launch2 - start")
            delay(200)
            println("launch2 - end")
        }

        delay(1000)
        println("scope - end")
    }
}
scope - start
launch1 - start
launch2 - start
launch2 - end
scope - end

[!info] launch1 - ex가 발생했지만 launch2와 parent는 정상 수행 되었다.

supervisor는 안쪽 스코프로 상속되지 않는다!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
runBlocking {
    supervisorScope {
        // 여기는 supervisorScope다. 
        launch {
            // 과연 여기도 supervisorScope 일까? -- XXXX
            println("parent1 - start")
            launch {
                println("child1 - start")
                delay(100)
                error("child1 - ex")
            }
            launch {
                println("child2 - start")
                delay(200)
                println("child2 - end")
            }
            delay(500)
            println("parent1 - end")
        }
        launch {
            println("parent2 - start")
            delay(1000)
            println("parent2 - end")
        }
    }
}
parent1 - start
parent2 - start
child1 - start
child2 - start
parent2 - end

[!danger] child1 - ex로 인해 parent1과 child2까지 같이 취소되었다. 즉 바깥쪽 supervisorScope의 영향을 받지 않았다.
반면 supervisorScope의 영향을 받는 parent2는 정상 실행 완료 되었다.


supervisorScope를 사용하는 경우 예외 처리

launch block의 ex 처리는 (root 또는 개별) handler를 사용해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
runBlocking {
    supervisorScope {
        println("scope - start")

        launch(CoroutineExceptionHandler { _, _ -> println("### my root coroutine handler") }) {
            println("launch1 - start")
            error("launch1 - ex")
        }

        delay(1000)
        println("scope - end")
    }
}
scope - start
launch1 - start
### my coroutine handler
scope - end

[!info] launch에 handler가 지정되지 않은 경우, parent(root) handler를 호출한다.
물론 parent, sibling 코루틴은 취소되지 않는다.


async block의 ex 처리는 try-catch (또는 root handler)를 사용해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
val handler = CoroutineExceptionHandler { _, throwable ->
    println("CoroutineExceptionHandler handle this : $throwable")
}

suspend fun aa() = supervisorScope {
    val a = async(handler) {  // handler 적용되지 않는다. launch와 다르다.
        println("pre A")
        error("A")
    }
    try {
        a.await()
    } catch (e : Exception) {
        println("try-catch handle this : $e")
    }
    
    val b = async {
        kotlin.runCatching {
            delay(100)
            println("pre B")
            error("B")
        }.onFailure {
            handler.handleException(this.coroutineContext, it)
        }
    }
    val c = async { runCatching {
        delay(200)
        println("pre C")
        error("C")
    } }
    val cr = c.await()
    cr.exceptionOrNull()?.let { handler.handleException(this.coroutineContext, it) }

    val d = async {
        println("pre D")
        error("D")
    }

    delay(1000)
    println("END")
}
pre A
try-catch handle this : java.lang.IllegalStateException: A
pre B
CoroutineExceptionHandler handle this : java.lang.IllegalStateException: B
pre C
CoroutineExceptionHandler handle this : java.lang.IllegalStateException: C
pre D
END
  • A는 handler가 붙어있으나, handler를 타지 않는다.
    • async는 await 할 때 ex가 전파되는 구조로, async에 handler를 등록해도 적용되지 않는다.
    • async builder always catches all exceptions and represents them in the resulting Deferred object, so its CoroutineExceptionHandler has no effect either.
    • 게다가 생각해보니 async-await는 parent에서 실행 결과를 받아 볼 수 있어야 하기 때문에, 실패한 경우에 대한 처리가 parent에 반드시 들어갈 수 밖에 없다. (실패해서 실행 결과가 없으면 parent도 진행을 못하니)
  • B는 코루틴에서 Exception이 발생하는 그 즉시 handler를 호출한다 (launch 처럼)
  • C는 await 후 handler를 호출한다. (try - catch 방식)
  • D는 부모에서 await 하지 않았으므로 error가 일어나지 않은 것 처럼 처리된다.

[!info] catch 되지 않은 exception은 parent(root) handler로 전달된다.


supervisorScope를 사용하지 않는 경우 예외 처리

launch든 async든 exception 처리에 try-catch를 사용해서는 안된다.

[!warning] supervisorScope가 아닌 환경에서 try-catch를 쓰면 안되는 이유는, child에서 ex가 발생하면 parent 코루틴이 항상 취소되기 때문이다.
문제는 parent 코루틴이 어느 라인에서 종료될지 정확히 예측 할 수 없다는데 있다. 이 것이 언제는 catch를 타고, 언제는 타지 않는 동작의 원인이다.
예제는 async 사용했지만 launch(join)도 동일하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
runBlocking {
    println("scope - start")

    val deferred1 = async {
        println("async1 - start")
        delay(200)
        error("async1 - ex")
    }

    delay(100)
    try {
        deferred1.await()
        println("try - end")  // never reached
    } catch (e: Exception) {
        println("catch - async1")
    }
    delay(1000)
    println("never reached")
}
scope - start
async1 - start
catch - async1

[!info] async1 - ex가 발생하는 시점에 parent는 await 하고 있어 parent가 종료되기 전 catch로 진입해 catch - async1이 출력되었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
runBlocking {
    println("scope - start")

    val deferred1 = async {
        println("async1 - start")
        delay(200)
        error("async1 - ex")
    }

    delay(100)
    try {
        deferred1.await()
        println("try - end")
    } catch (e: Exception) {
        println("catch - async1 - start")
        delay(500)
        println("catch - async1 - end")
    }
    delay(1000)
    println("never reached")
}
scope - start
async1 - start
catch - async1 - start

[!info] async1 - ex가 발생하는 시점에 parent는 await 하고 있어 parent가 종료되기 전 catch로 진입해 catch - async1 - start이 출력되었다.
그러나 그 다음 delay(500) 수행하는 와중에 parent가 종료되어 catch - async1 - end 라인은 수행되지 않았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
runBlocking {
    println("scope - start")

    val deferred1 = async {
        println("async1 - start")
        delay(100)
        error("async1 - ex")
    }

    delay(200)
    try {
        deferred1.await()
        println("try - end")
    } catch (e: Exception) {
        println("catch - async1")  // never reached
    }
    delay(1000)
    println("never reached")
}
scope - start
async1 - start

[!info] async1 - ex가 발생하는 시점에 parent는 delay(200) 하고 있어 try 진입 전 parent가 종료되었다.


launch든 async든 ex 처리는 root handler를 사용해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
CoroutineScope(CoroutineExceptionHandler { _, _ -> println("### my root coroutine handler") }).launch {
    println("scope - start")

    launch(CoroutineExceptionHandler { _, _ -> println("never reached") }) {
        println("launch1 - start")
        error("launch1 - ex")
    }

    delay(100)
    println("scope - end")
}
Thread.sleep(200)
scope - start
launch1 - start
### my root coroutine handler

[!info] supervisorScope를 사용하지 않는 경우, 예외는 parent로 전파되고, 이에 대한 처리도 parent(결국 root까지)에 위임한다.
그래서 이러한 자식 코루틴들에게 설치 된 CoroutineExceptionHandler 는 절대 사용되지 않는다.


참고

runBlocking에 지정된 handler는 무시된다

  • 사유
  • runBlockingtry-catch로 감싸는 것이 맞다.

async와 launch의 Exception 전파 시점 차이

This post is licensed under CC BY 4.0 by the author.