Coroutine의 Exception Handling
Coroutine은 비동기 프로그래밍을 위한 일시중단가능한 경량 스레드이기 때문에 코루틴 내부에서 코루틴이 수행될 수 있으며, 그 깊이는 무한해질 수 있다. 하지만, 코루틴 내부에서 수행되는 자식 코루틴에 애러가 생겼을 때 별도의 Exception Handler을 설정해주지 않으면 자식 코루틴은 부모 코루틴까지 취소시키게 된다. 부모 코루틴이 취소되면 당연히 부모의 자식으로 있는 코루틴이 모두 취소된다.
예를 들어 아래와 같은 구조의 코루틴이 수행된다고 해보자
만약 Child Coroutine1에서 애러가 생겨서 Coroutine이 취소되었다고 하면 아래와 같은 일이 일어난다.
- Child Coroutine1의 취소가 부모 Coroutine에게 전파된다. 이로 인해 부모 Coroutine이 취소된다.
- 부모 Coroutine의 취소가 Child Coroutine2와 Child Coroutine3으로 전파된다.
위의 현상은 각 Child Coroutine들이 다른 Dispatcher에서 수행되어도 상관없다. 부모 코루틴이 종료되면 자식 Coroutine은 모두 취소된다.
이는 실제 작업에서 매우 심각한 버그를 야기하는데, 자식 코루틴 하나가 취소되었을 때 자식 코루틴과 연결된 부모 코루틴은 물론 부모의 부모 코루틴까지 모두 취소되어 작업 자체가 중단되어버리기 때문이다. 실제 서비스에서는 여러 서버와 연결해서 작업을 하기 때문에 서버 중 하나에서 오류가 발생하면 모든 코루틴이 취소되어버린다.
단방향 Exception 전파를 위한 SupervisorJob
이러한 문제를 방지하기 위해 애러의 전파 방향을 자식으로 한정짓는 것이 바로 SupervisorJob이다. SupervisorJob은 CoroutineContext로 다른 CoroutineContext들과 혼합해서 사용된다.
SupervisorJob을 자식 Coroutine(Child Coroutine)에 사용하면 애러가 부모 Coroutine(Parent Coroutine)로 전파되지 않는다.
어떻게 전파되지 않는지 아래 코드를 통해 살펴보자.
suspend fun main() {
val supervisor = SupervisorJob()
CoroutineScope(Dispatchers.IO).launch {
val firstChildJob = launch(Dispatchers.IO + supervisor) {
throw AssertionError("첫 째 Job이 AssertionError로 인해 취소됩니다.")
}
val secondChildJob = launch(Dispatchers.Default) {
delay(1000)
println("둘 째 Job이 살아있습니다.")
}
firstChildJob.join()
secondChildJob.join()
}.join()
}
위 코드의 결과는 <그림3>과 같다.
<그림3>에서 firstChildJob에서 AssertionError가 생겼음에도 secondChildJob가 끝까지 수행된 것을 볼 수 있다. 이것은 바로 firstChildJob가 CoroutineContext로 SupervisorJob()을 받아서이다. 이 때문에 애러는 부모로 전파되지 않는다.
위의 코드를 시각적으로 표현하면 다음과 같다.
1. firstChildJob에서 Coroutine이 AssertionError로 취소된다.
2. 하지만 부모 Coroutine에는 전파가 되지 않는다. firstChildJob은 SupervisorJob의 감독을 받아 부모로 취소가 전파되지 않기 때문이다.
3. 따라서 secondChildJob이 끝까지 완료되어 "둘 째 Job이 살아있습니다"가 출력된다.
만약 supervisorJob이 없었다면 결과가 어떻게 바뀔까? 이를 확인하기 위해 아래 코드를 돌려보자.
suspend fun main() {
CoroutineScope(Dispatchers.IO).launch {
val firstChildJob = launch(Dispatchers.IO) {
throw AssertionError("첫 째 Job이 AssertionError로 인해 취소됩니다.")
}
val secondChildJob = launch(Dispatchers.Default) {
delay(1000)
println("둘 째 Job이 살아있습니다.")
}
firstChildJob.join()
secondChildJob.join()
}.join()
}
위 코드는 이전 코드에 비해 firstChildJob에 SupervisorJob을 넘기는 것을 뺐다. Supervisor Job가 없으므로 아래 그림과 같이 부모 Coroutine으로 취소가 전파되어 자식에게도 전파된다.
따라서 다음과 같이 결과가 나온다.