구조화된 코루틴들에 공통적인 예외 처리기를 설정해야 하는 경우도 존재한다. 코루틴은 이를 위해 CoroutineContext 구성 요소로 CoroutineExceptionHandler라고 하는 예외 처리기를 지원한다.
// CoroutineExceptionHandler 객체 생성
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("[예외 발생] ${throwable}")
}
CoroutineContext 구성 요소 중 하나이기 때문에 CoroutineScope()
함수의 인자로 전달할 수 있다. CoroutineExceptionHandler()
함수를 통해 객체를 생성할 수 있으며 coroutineContext와 throwable을 파라미터로 갖는 람다식을 통해 예외가 발생했을 때 어떻게 처리할지 정할 수 있다.
// CoroutineExceptionHandler.kt
public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineContext, Throwable) -> Unit): CoroutineExceptionHandler =
object : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) =
handler.invoke(context, exception)
}
// ...
public interface CoroutineExceptionHandler : CoroutineContext.Element {
public companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>
public fun handleException(context: CoroutineContext, exception: Throwable)
}
// CoroutineContextImpl.kt
public abstract class AbstractCoroutineContextElement(public override val key: Key<*>) : Element
CoroutineExceptionHandler()
함수의 구현체를 보면 AbstractCoroutineContextElement 추상 클래스를 상속하면서 인자로 CoroutineExceptionHandler를 전달한다. Key를 전달해야 하는데 CoroutineExceptionHandler만 전달해도 되는 이유는 Key가 companion object로 정의되어 있기 때문이다. Kotlin이 companion object를 클래스, 인터페이스 이름으로도 접근 가능하게 처리하기 때문에 CoroutineExceptionHandler.Key가 아닌 CoroutineExceptionHandler만 전달해도 된다.
그리고 CoroutineExceptionHandler 인터페이스를 구현하며 handleException()
을 override한다. 우리가 전달하는 handler 람다식은 handleException()
함수가 호출되면서 실행된다.
fun main() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("[예외 발생] ${throwable}")
}
CoroutineScope(exceptionHandler).launch(CoroutineName("Coroutine1")) {
throw Exception("Coroutine1에 예외가 발생했습니다")
}
delay(1000L)
}
// 실행결과
// [예외 발생] java.lang.Exception: Coroutine1에 예외가 발생했습니다
그리고 CoroutineExceptionHandler은 CoroutineContext 요소 중 하나이므로 자식 코루틴으로 상속된다. 위 예시를 그림으로 나타내면 다음과 같다.
CoroutineScope 함수에 Job 객체가 전달되지 않았으므로 Job()
함수를 통해 기본 Job 객체가 추가되고 runBlocking 코루틴과의 구조화가 깨진다. 그리고 CoroutineScope에 전달된 exceptionHandler는 자식 코루틴인 Coroutine1 코루틴에도 상속된다.
하지만 여기서 궁금해지는 것은 두 개의 exceptionHandler 중 어느 handler가 예외를 처리한 것일까?
fun main() = runBlocking<Unit> {
val exceptionHandler1 = CoroutineExceptionHandler { coroutineContext, throwable ->
println("[예외 발생] $coroutineContext")
println("[예외 발생] ${throwable}")
}
val exceptionHandler2 = CoroutineExceptionHandler { coroutineContext, throwable ->
println("[예외 발생2] $coroutineContext")
println("[예외 발생2] ${throwable}")
}
CoroutineScope(exceptionHandler1).launch(CoroutineName("Coroutine1") + exceptionHandler2) {
throw Exception("Coroutine1에 예외가 발생했습니다")
}
delay(1000L)
}
/* 결과 :
[예외 발생2] [CoroutineName(Coroutine1), Test3Kt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$2@52cde23, CoroutineId(2), "Coroutine1#2":StandaloneCoroutine{Cancelling}@304dde59, Dispatchers.Default]
[예외 발생2] java.lang.Exception: Coroutine1에 예외가 발생했습니다
*/
이전 코드를 약간 변형시켜보았다. 결과에서 알 수 있듯이 작동한 exception handler는 exceptionHandler2인 것을 확인할 수 있다. 예외가 처리되었을 때의 coroutineContext의 Job도 launch 함수를 통해 생성된 StandaloneCoroutine이다. 만약 exceptionHandler1이 예외를 처리했다면 coroutineContext의 Job이 JobImpl로 출력되었어야 한다.
위의 코드를 그림으로 나타낸 것인데 exceptionHandler가 동작했다고 해서 예외 전파가 되지 않는 것은 아니다. CoroutineExceptionHandler가 예외 전파를 제한하지는 않는다. 하지만 동작하는 handler는 exceptionHandler2이다. 최상위 launch 코루틴 빌더 함수로 생성된 코루틴에 설정된 CoroutineExceptionHandler가 동작한다.
지금까지의 예시는 runBlocking을 루트 코루틴으로 설정하지 않은 예시였는데 이번에는 runBlocking이 루트 코루틴일 때의 handler 동작을 알아보자.
fun main() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("[예외 발생] ${throwable}")
}
launch(CoroutineName("Coroutine1") + exceptionHandler) {
throw Exception("Coroutine1에 예외가 발생했습니다")
}
delay(1000L)
}
/*
// 실행결과
Exception in thread "main" java.lang.Exception: Coroutine1에 예외가 발생했습니다
...
/*
runBlocking 코루틴에 exceptionHandler가 포함되어 있는지의 여부와 상관없이, 최상위 launch 코루틴 빌더로 생성된 코루틴에 exceptionHandler가 포함되어 있으니 exceptionHandler가 동작하여 예외가 처리될 것이라고 생각하지만 그렇지 않다.
runBlocking으로 예외가 전파 혹은 runBlocking에서 예외가 발생하면, runBlocking은 예외를 다시 던지기 때문에 CoroutineExceptionHandler가 이를 처리할 기회를 가지지 못한다. 사실 이 문장은 ChatGPT의 힘을 빌려 얻은 정보인데 runBlocking 함수의 코드를 살펴보았을 때, 이는 올바른 정보라고 생각한다.
private class BlockingCoroutine<T>(
parentContext: CoroutineContext,
private val blockedThread: Thread,
private val eventLoop: EventLoop?
) : AbstractCoroutine<T>(parentContext, true, true) {
// ...
@Suppress("UNCHECKED_CAST")
fun joinBlocking(): T {
// ...
// now return result
val state = this.state.unboxState()
(state as? CompletedExceptionally)?.let { throw it.cause }
return state as T
}
}
ChatGPT가 말한 정보는 (state as? CompletedExceptionally)?.let { throw it.cause }
코드에 대한 내용인 것 같다. 여기서 state는 Job(BlockingCoroutine)의 상태값인데 예외가 존재하는 채로 완료되면 return state as T
가 실행되지 않고 throw it.cause
코드가 실행된다.
그래서 runBlocking 코루틴이 루트 코루틴일 때는 CoroutineExceptionHandler가 작동하지 않고 예외만 발생한다.
이와 관련된 내용은 공식문서에서도 찾을 수 있었다.
번역 : 이러한 예제에서
CoroutineExceptionHandler
는 항상GlobalScope
에서 생성된 코루틴에 설치됩니다. 메인runBlocking
의 스코프에서 실행되는 코루틴에 예외 핸들러를 설치하는 것은 의미가 없습니다. 왜냐하면 설치된 핸들러에도 불구하고 자식 코루틴이 예외와 함께 완료되면 메인 코루틴이 항상 취소되기 때문입니다.
공식문서에서는 예시 코드로 GlobalScope를 통해 runBlocking 코루틴과의 구조를 깼는데, 위와 같은 이유로 runBlocking에 exceptionHandler를 설정하는 것이 의미없다고 이야기하고 있다.
fun main() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("[예외 발생] CoroutineContext: ${coroutineContext.job}") // JobImpl로 나와야 할 것 같은데 실제로는 StandaloneCoroutine으로 나옴
println("[예외 발생] ${throwable}")
}
val coroutineScope = CoroutineScope(exceptionHandler) // + Job(parent = this.coroutineContext.job)을 하면 루트 코루틴이 runBlocking 코루틴이 되어 handler가 예외를 처리하지 않고, Exception만 발생.
coroutineScope.launch(CoroutineName("Coroutine1")) {
runBlocking {
throw Exception("내부 runBlocking에서 예외 발생")
}
}
delay(1000L)
}
/* 결과 :
[예외 발생] CoroutineContext: "Coroutine1#2":StandaloneCoroutine{Cancelling}@5d9c9124
[예외 발생] java.lang.Exception: 내부 runBlocking에서 예외 발생
*/
하지만 만약 이렇게 runBlocking 코루틴이 루트 코루틴이 아닌 상태에서, runBlocking이 자식 코루틴으로 사용된다면 exceptionHandler가 잘 동작한다.
SupervisorJob 객체가 부모 Job으로 설정되면 SupervisorJob의 부모에게 예외가 전파되지 않고 SupervisorJob의 다른 자식 코루틴이 취소되지 않는다.
fun main() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("[예외 발생] ${coroutineContext}")
println("[예외 발생] ${throwable}")
}
val supervisedScope = CoroutineScope(SupervisorJob() + exceptionHandler)
supervisedScope.launch(CoroutineName("Coroutine1")) {
throw Exception("Coroutine1에 예외가 발생했습니다")
}
supervisedScope.launch(CoroutineName("Coroutine2")) {
delay(100L)
println("[${Thread.currentThread().name}] 코루틴 실행")
}
delay(1000L)
}
/*
// 결과:
[예외 발생] [Test3Kt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@25620a79, CoroutineName(Coroutine1), CoroutineId(2), "Coroutine1#2":StandaloneCoroutine{Cancelling}@9dddc10, Dispatchers.Default]
[예외 발생] java.lang.Exception: Coroutine1에 예외가 발생했습니다
[DefaultDispatcher-worker-2 @Coroutine2#3] 코루틴 실행
*/
Coroutine1에서 예외가 발생하였고, SupervisorJob을 사용했기 때문에 예외 전파는 이루어지지 않지만 예외에 대한 정보는 전달된다. 하지만 exceptionHandler는 Coroutine1의 exceptionHandler가 동작한다.
handler 사용 시 많이 하는 실수는 handler가 try-catch 문처럼 동작해 예외 전파를 제한한다고 생각하는 것이다.
fun main() = runBlocking<Unit> {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
println("[예외 발생] $coroutineContext")
println("[예외 발생] $throwable")
}
CoroutineScope(Dispatchers.Default).launch(CoroutineName("Coroutine1") + exceptionHandler) {
launch(CoroutineName("Coroutine2")) {
delay(100)
println("Coroutine2 완료")
}
launch(CoroutineName("Coroutine3")) {
throw Exception("Coroutine3에 예외가 발생했습니다")
}
}
delay(1000)
}
/*
// 결과:
[예외 발생] [CoroutineName(Coroutine1), Test3Kt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6a1fabc, CoroutineId(2), "Coroutine1#2":StandaloneCoroutine{Cancelling}@10c2cd2e, Dispatchers.Default]
[예외 발생] java.lang.Exception: Coroutine3에 예외가 발생했습니다
(Coroutine2는 취소)
*/
하지만 위 예시에서도 볼 수 있듯이 단순히 exceptionHandler를 붙였다고 해서 예외 전파가 제한되는 것이 아니라는 것을 알 수 있다. 만약 예외 전파를 제한하는 기능을 지원했다면 Coroutine2가 취소되지 않았어야 한다.
Coroutine1의 exceptionHandler는 동작했지만, 예외는 전파되어 또다른 자식 코루틴인 Coroutine2는 취소되었다는 것을 확인할 수 있다.