Shunz Android Dev Note

코틀린 코루틴에서의 취소 및 예외 처리 #3 - Exception Handling 본문

Kotlin

코틀린 코루틴에서의 취소 및 예외 처리 #3 - Exception Handling

_Shun_ 2023. 12. 8. 23:22

이 포스팅은 아래 게시글을 번역 및 일부 수정하여 작성하였습니다.

https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c

 


 

개발자인 우리는 보통 앱이 만족스러울만큼 잘 동작하게 만들기 위하여 많은 시간을 소비합니다. 그러나 예상대로 일이 진행되지 않을 때마다 적절한 사용자 경험을 제공하는 것도 마찬가지로 중요합니다. 한편으로, 앱이 크래쉬가 발생한는것을 보는 것은 사용자에게 좋지 않은 경험이며, 다른 한편으론 어떤 행동이 성공하지 못했을 때 사용자에게 올바른 메시지를 보여주는 것은 필수적입니다.

 

Exception을 적절하게 처리하는 것은 사용자가 앱을 어떻게 인식하는지에 큰 영향을 미칩니다. 이 포스팅에서는 코루틴에서 어떻게 Exception이 전파되는지와 어떻게 제어를 할 수 있는지에 대해 설명합니다.

 

만약, 영상으로 시청하고 싶으시다면 KotlinConf`19에서 발표한 영상을 참고하세요.

https://youtu.be/w0kfnydnFWI

 

나머지 글을 문제없이 따라가기 위해서는 시리즈 1을 읽고 이해하는 과정이 필요합니다.
https://android-devpia.tistory.com/10

 

 

갑자기 코루틴이 실패 했어요! 어떻게 해야 할까요? 😱

코루틴이 Exception으로 실패하면 해당 Exception을 부모에게 전파합니다. 그러면 부모는 1) 나머지 자식을 취소하고, 2) 자신을 취소하고, 3) Exception을 부모에게 전파합니다.

 

Exception은 계층 구조의 최상단에 도달하고 CoroutineScope가 시작한 모든 코루틴도 취소됩니다.

코루틴의 예외는 코루틴 계층 전체에 전파됩니다.

 

Exception을 전파하는 것이 어떤 경우에는 이해가 될 수 있지만 바람직하지 않은 경우도 있습니다. 사용자 상호 작용을 처리하는 UI 관련 CoroutineScope를 상상해 보세요. 자식 코루틴이 Exception을 던지면 UI 스코프가 취소되고, 취소된 스코프가 더 이상 코루틴을 시작할 수 없기 때문에 전체 UI 구성 요소가 응답하지 않게 될것입니다.

 

이러한 동작을 원하지 않는다면 어떻게 할 수 있을까요? 대안책으로, Job을 구체화한 SupervisorJob 이란 클래스를 사용하면 됩니다. SupervisorJob 에 대해서는 계속해서 알아보겠습니다.

 

 

구조를 위한 SupervisorJob

SupervisorJob은 child의 실패를 다른 children으로 영향을 미치지 않습니다. SupervisorJob은 자신이나 다른 자식들을 취소시키지 않을 것입니다. 게다가 SupervisorJob은 Exception을 전파하지도 않고, Child가 일상적으로 처리하도록 내버려 둘 것입니다.

 

다음 이미지와 같이 코루틴이 실패할 때 취소를 전파하지 않도록 아래와 같은 코드를 사용하여 CoroutineScope를 생성할 수 있습니다.

val uiScope = CoroutineScope(SupervisorJob())

SupervisorJob은 Child가 실패한 경우 다른 Children으로 실패를 전파하지 않습니다.

 

Exception이 처리되지 않고 CoroutineContext에 CoroutineExceptionHandler(나중에 살펴볼)가 없는 경우, 기본 쓰레드의 ExceptionHandler에 도달합니다. JVM에서는 Exception이 콘솔에 기록되고, Android에서는 Dispatcher와 상관없이 앱에 Crash가 발생할 것입니다.

 

💥 잡히지 않는 예외는 Job의 종류에 상관없이 항상 버려집니다.(thrown)

 

동일한 동작이 scope builders인 coroutineScope와 supervisorScope에도 적용이 됩니다. 이들은 논리적으로 코루틴을 그룹화(e.g. 병렬 계산을 수행하거나 서로 영향을 받지 않기를 원하는 경우)할 수 있는 sub-scope(Job 또는 SupervisorJob을 부모로 사용)를 만듭니다.

 

경고 : SupervisorJob은 supervisorScope을 사용하거나 "CoroutineScope(SupervisorJob())"과 같이 작성된 Scope일 경우에만 설명된 대로 작동합니다.

 

Job or SupervisorJob?

언제 Job 혹은 SupervisorJob을 사용해야 할까요? 부모와 형제자매 노드를 취소하지 않으려면 SupervisorJob 또는 supervisorScope를 사용하세요. :-)

 

예제 코드를 참고 해보세요.

// SupervisorJob을 활용해서 CoroutineScope를 생성하는 코드입니다.
val scope = CoroutineScope(SupervisorJob())
scope.launch {
    // Child 1
}
scope.launch {
    // Child 2
}

 

이 경우, "Child 1"이 실패하는 경우 scope와 "Child 2"가 모두 취소되지 않습니다.

 

다른 예제입니다.

// CoroutineScope를 통해 생성한 scope입니다.
val scope = CoroutineScope(Job())
scope.launch {
    supervisorScope {
        launch {
            // Child 1
        }
        launch {
            // Child 2
        }
    }
}

 

이 경우, SupervisorScope가 SupervisorJob과 함께 sub scope를 만들 때 "Child 1"이 실패하면 "Child 2"가 취소되지 않습니다. 대신 구현코드에서 coroutineScope를 사용하면 실패가 전파되어 Scope도 취소됩니다.

 

퀴즈를 맞춰볼까요? 우리 부모님은 누구일까요?

다음 코드에서, "Child 1" Job의 부모는 누구일까요?

val scope = CoroutineScope(Job())
scope.launch(SupervisorJob()) {
    // new coroutine -> can suspend
   launch {
        // Child 1
    }
    launch {
        // Child 2
    }
}

 

"Child 1"의 부모인 Job은 Job 유형입니다.제대로 이해하셨기를 바랍니다! SupervisorJob은 scope.launch로 만들어진 코루틴의 부모입니다. 그래서 말 그대로, SupervisorJob은 그 코드에서 아무것도 하지 않습니다.

child1과 child2의 부모는 Job 타입으로 SupervisorJob이 아닙니다.

 

따라서, child1이나 child2가 실패한 경우, 실패가 scope에 까지 전파가 되어 scope에서 시작된 모든 작업이 취소가 됩니다.

 

SupervisorJob은 SupervisorScope 또는 "CoroutineScope(SupervisorJob())"을 사용하여 작성된 스코프의 일부일 때만 설명한 대로 동작합니다. SupervisorJob을 Coroutine Builder의 매개 변수로 통과시키는 것은 취소 할 때 생각했던 효과를 기대하지 못할 것입니다.

 

Exception과 관련하여 자식이 예외를 발생시키면 SupervisorJob은 예외를 계층 위로 전파하지 않고 해당 코루틴에서 처리하도록 허용합니다.

 

내부 동작

Job이 내부적으로 어떻게 동작하는지 궁금하다면 JobSupport.kr 파일에 있는 childCancelled 함수와 notifyCancelling 함수를 참고해보세요.

 

SupervisorJob 구현부에서, childCancelled 메서드는 취소를 전파하지 않지만 예외도 처리하지 않는다는 의미로 false만 반환합니다.

 

 

예외 처리

코루틴은 예외 처리를 위해 일반적인 Kotlin 구문을 사용합니다: try/catch 또는 runCatching(내부적으로 try/catching을 사용하는)과 같은 빌트인 헬퍼 메서드.

 

우리는 전에 잡히지 않는 예외는 항상 던져질 것이라고 언급 했습니다. 그러나 다른 코루틴 빌더들은 다른 방식으로 예외를 대합니다.

 

Launch

launch와 함께 생성된 코루틴에서는 예외가 발생하는 즉시 예외가 던져집니다. 따라서, 다음 예제와 같이 예외를 던질 수 있는 코드를 try/catch 안에 넣을 수 있습니다.

scope.launch {
    try {
        codeThatCanThrowExceptions()
    } catch(e: Exception) {
        // 예외 핸들링
    }
}

 

launch와 함께, 예외는 발생하는 즉시 던져집니다.

 

Async

async{}가 루트 코루틴으로 사용된 경우, 예외는 자동으로 발생하지 않고 .await()를 호출할 때 발생합니다.

 

루트 코루틴일때마다 async{}에서 발생하는 예외를 다루기 위해서는 .await() 호출을 try/catch 안에서 래핑해야 합니다.

fun main() = runBlocking {
    supervisorScope {
        val deferred = async {
            codeThatCanThrowExceptions()
        }
        try {
            deferred.await()
        } catch(e: Exception) {
            println("여기서 예외 캐치! : " + e.message)
        }
    }
}

fun codeThatCanThrowExceptions() {
    throw IllegalStateException("예외 메시지입니다.")
}
출력

여기서 예외 캐치! : 예외 메시지입니다.

 

이 경우, async{}를 호출하면 예외가 발생하지 않으므로 try...catch로 감쌀 필요가 없습니다.

 

async{} 가 루트 코루틴으로 사용 되었을때, 예외는 .await()를 호출하는 시점에 발생됩니다.

 

또한 SupervisorScope를 사용하여 async와 await를 호출합니다. 앞에서 설명했듯이 SupervisorJob은 코루틴이 예외를 처리 할 수 있도록 해줍니다. 이는 Job을 사용한 경우에 예외가 자동으로 전파가 되어 catch 블록이 호출되지 않는것과 대조적입니다.

 

fun main() = runBlocking {
    coroutineScope {
        try {
            val deferred = async {
                codeThatCanThrowExceptions()
            }
            deferred.await()
        } catch(e: Exception) {
            // 예외가 발생시 여기에서 캐치후 완전히 처리되지 않고, scope로 전파가 됩니다.
            println("여기서 예외 캐치! : " + e.message)
        }
    }
}

fun codeThatCanThrowExceptions() {
    throw IllegalStateException("예외 메시지입니다.")
}
출력

여기서 예외 캐치! : 예외 메시지입니다.
Exception in thread "main" java.lang.IllegalStateException: 예외 메시지입니다.
	at MainKt.codeThatCanThrowExceptions(Main.kt:20)
	at MainKt$main$1$1$deferred$1.invokeSuspend(Main.kt:9)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
	at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:280)
	at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
	at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
	at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
	at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:38)
	at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
	at MainKt.main(Main.kt:5)
	at MainKt.main(Main.kt)

 

또한, 다른 코루틴이 만든 코루틴에서 발생하는 예외는 코루틴 빌더에 관계없이 항상 전파됩니다. 예를 들어 다음과 같습니다. 코드 설명은 이어서 확인 할 수 있습니다.

fun main() = runBlocking {
    val scope = CoroutineScope(Job())
    scope.launch {
        async {
            // 여기 async 블록에서 예외를 던지면, launch 블록에서 예외가 발생합니다.
            // .await() 호출을 하지 않았음에도 말이죠.
            codeThatCanThrowExceptions()
        }
    }
    delay(1000)
    return@runBlocking
}

fun codeThatCanThrowExceptions() {
    throw IllegalStateException("예외 메시지입니다.")
}
출력

Exception in thread "DefaultDispatcher-worker-2" java.lang.IllegalStateException: 예외 메시지입니다.
	at MainKt.codeThatCanThrowExceptions(Main.kt:15)
	at MainKt$main$1$1$1.invokeSuspend(Main.kt:7)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@3bd3e812, Dispatchers.Default]

 

이 경우, async가 예외를 던지면, scope의 자식인 launch {} 코루틴(CoroutineContext에 있는 Job과 함께)에서 예외를 바로 전달받게 됩니다. 그 이유는 위에서 설명 했듯이, 서로 다른 코루틴 빌더가 중첩되어 있는 경우, 가장 안쪽의 코루틴에서 예외가 발생하면 자동으로 상위로 전파를 시키기 때문입니다.

 

⚠️ coroutineScope 빌더나 다른 코루틴이 만든 코루틴에 던져진 예외는 try/catch에 잡히지 않습니다!

 

SupervisorJob 섹션에서는, CoroutineExceptionHandler의 존재에 대해 언급합니다. 그 내용을 살펴 보겠습니다.

 

CoroutineExceptionHandler

CoroutineExceptionHandler는 CoroutineContext의 선택적인 요소로서, 캐치되지 않은 예외를 처리 할 수 있습니다.

 

CoroutineExceptionHandler를 정의하는 방법은 다음과 같습니다. 이 객체는 예외가 발생될때마다, 예외가 발생한 CoroutineContext와 예외 자체에 대한 정보를 담고 있습니다.

val handler = CoroutineExceptionHandler {
    context, exception -> println("Caught $exception")
}

 

다음과 같은 요건을 충족할 경우 예외가 발생합니다.

  • 언제? : 예외는 자동으로 예외를 던지는 코루틴(async가 아닌 launch와 동작할때)에 의해 발생합니다.
  • 어디에서? : CoroutineScope 또는 루트 코루틴(CoroutineScope의 직접적인 자식 또는 supervisorScope)의 CoroutineContext에 있는 경우.

위에서 정의한 CoroutineExceptionHandler를 사용한 몇 가지 예를 보겠습니다. 다음 예제에서는 예외가 handler에 의해서 잡힙니다.

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { context, exception ->
        println("Caught $exception")
    }

    val scope = CoroutineScope(Job())
    scope.launch(handler) {
        launch {
            throw Exception("Failed coroutine")
        }
    }
    delay(1000)
    return@runBlocking
}
출력

Caught java.lang.Exception: Failed coroutine

 

다른 예를 보겠습니다. 만약 handler를 내부 코루틴에서 사용한다면 예외를 캐치하지 못합니다.

위의 코드에서 handler의 위치만 바꿔보겠습니다.

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { context, exception ->
        println("Caught $exception")
    }

    val scope = CoroutineScope(Job())
    scope.launch {
        launch(handler) {
            throw Exception("Failed coroutine")
        }
    }
    delay(1000)
    return@runBlocking
}
출력

Exception in thread "DefaultDispatcher-worker-2" java.lang.Exception: Failed coroutine
	at MainKt$main$1$1$1.invokeSuspend(Main.kt:11)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@1a390bf0, Dispatchers.Default]

 

handler가 올바른 CoroutineContext에 위치하지 않았기 때문에, 예외를 제대로 캐치 하지 못하였습니다. 내부 launch{} 코루틴이 예외를 부모로 전파 시켰지만, 부모 코루틴은 핸들러 정보를 알수가 없기 때문에 제대로 예외를 캐치하지 못하는 경우입니다.

 

 

결론

여러분의 앱에서 예외를 우아하게 처리하는것은 사용자가 좋은 경험을 가지게 만들기 위해 매우 중요합니다.

 

예외가 발생했을 때 취소를 전파하지 않으려면 SupervisorJob을 사용하고, 그렇지 않으면 Job을 사용해야 함을 꼭 기억해야 합니다.

 

잡히지 않은 예외는 전파 될 것이므로, 훌륭한 UX를 제공하기 위해 그 예외들을 모두 잡아보세요!