본문 바로가기
Coroutine

[KotlinConf 2019 시리즈] Part 3 : Exceptions in coroutines

by DONXUX 2023. 3. 18.

이 글은 아래의 문서를 바탕으로 정리된 글임을 알려드립니다.

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

 

Exceptions in coroutines

Cancellation and Exceptions in Coroutines (Part 3) — Gotta catch ’em all!

medium.com

목차

개요

앱은 Happy path 말고도 사용자가 예상치 못한 행동을 했을 때에도 적합한 UX를 제공하는 것도 중요합니다. 사용자에게 앱이 크래시 나는 것을 그대로 보여주는 것보단 올바른 메시지를 보여주는 것이 더 좋습니다.

이 문서에서는 코루틴에서 예외가 어떻게 전파되는지, 그리고 처리하는 다양한 방법들을 포함하여 항상 제어할 수 있는 방법을 설명합니다.

코루틴의 작업 실패

코루틴이 예외와 함께 실패되었다면, 예외는 부모에게 전파됩니다. 그리고 부모는 나머지 자식들을 취소 후, 자신을 취소한 다음 자신의 부모에게 예외를 전달하는 과정을 거칩니다. 결국 예외는 루트에 도달하게 되고 CoroutineScope에서 시작한 모든 코루틴이 자연스럽게 취소됩니다. 즉, 예외는 코루틴 계층 전체에 전파됩니다.

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

이러한 전파 방식이 유용할 수 있으나, 특정 작업에서는 그렇지 않을 수 있습니다. 예를 들어, UI 관련 작업을 수행하는 CoroutineScope가 있다고 가정해봅시다. 만약 자식 코루틴에서 예외가 발생하면, UI scope 취소 되어 UI가 전체적으로 응답하지 않게 됩니다. 이러한 경우에는 취소된 코루틴이 예외를 전파하지 않도록 하는 것이 바람직할 것입니다.

 

예외 전파를 원하지 않는다면 Job 대신에 SupervisorJob을 사용해야합니다. SupervisorJob은 부모 Job의 취소 동작에 영향을 받지 않기 때문에 자식 코루틴에서 예외가 발생해도 부모 CoroutineScope는 취소되지 않습니다. 이 방법을 사용하면 UI등에서 사용자 상호작용을 처리하는 등의 상황에서도 예외를 처리하고 취소되는 동작을 보다 세밀하게 제어할 수 있습니다.

SupervisorJob to the rescue

SupervisorJob을 가진 스코프라면 한 자식 코루틴이 실패해서 예외가 발생하도, 다른 자식 코루틴에게 전파되지 않습니다.

CoroutineScope(SupervisorJob())을 사용하여 예외가 전파되지 않도록합니다.

SupervisorJob은 자신이나 다른 자식 코루틴을 취소하지 않습니다.

만약 자식 코루틴 중 하나가 예외를 발생했으나, 직접 처리하지 않는다면 어떻게 될까요? 예외는 기본 스레드의 예외 처리기에 도달하게 됩니다. JVM은 예외를 콘솔에 로그로 남기고, Android는 앱이 크래시됩니다. 예외는 Job의 종류에 상관없이 무조건 던져집니다.

 

coroutineScope와 supervisorScope에도 동일한 동작이 적용됩니다. 이러한 빌더는 (부모로 Job 또는 SupervisorJob을 사용하여) 하위 스코프를 만들고 이를 사용하여 코루틴을 논리적으로 그룹화할 수 있습니다(예를 들면, 병렬 계산을 수행하거나 서로에게 영향을 받을 수 있도록하거나 하지 않도록).

경고 : SupervisorJob은 supervisorScope 또는 ConroutineScope(SupervisorJob())를 사용하여 생성된 범위에 포함될 때에만 설명한 대로 작동합니다.

Job or SupervisorJob

// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(SupervisorJob())
scope.launch {
    // Child 1
}
scope.launch {
    // Child 2
}

이 경우, Child 1이 실패해도 Child 2는 취소되지 않습니다.

 

// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(Job())
scope.launch {
    supervisorScope {
        launch {
            // Child 1
        }
        launch {
            // Child 2
        }
    }
}

이 경우, supervisorScope가 SupervisorJob으로 하위 스코프를 생성하기 때문에, Child 1에서 실패해도 Child 2는 취소되지 않습니다.  반면에 coroutineScope를 사용하면 실패가 전파되어 스코프가 취소됩니다.

누가 부모일까요?

Child 1의 부모는 어떤 Job을 가지고 있을까요?

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

정답은 Job입니다. SupervisorJob이라고 생각할 수도 있지만 아닙니다. Part 1에서 언급했다싶이 새로운 코루틴은 항상 새로운 Job()을 할당받기 때문에 SupervisorJob가 Job으로 덮어쓰여집니다. SupervisorJob은 scope.launch로 생성된 코루틴의 부모이기 때문에 해당 코드에서는 아무런 동작을 하지 않습니다.

Child 1과 Child 2의 부모는 Job입니다.

따라서 Child 1이나 Child 2가 실패한다면 모든 작업이 취소됩니다.

SupervisorJob의 동작은 scope의 일부로 구성될 때만 유효합니다. 즉, supervisorScope 또는 CoroutineScope(SupervisorJob())을 사용하여 만들어진 스코프에 포함된 경우에만 적용됩니다. 코루틴 빌더의 매개변수로 SupervisorJob을 전달하면 원하는 취소 효과가 발생하지 않습니다.

자식 코루틴 중 하나가 예외를 throw하더라도, 해당 SupervisorJob은 예외를 상위 계층으로 전파하지 않고 자식 코루틴에서 예외 처리를 처리하게 됩니다.

내부 구현

만약 Job이 어떻게 동작하는지 궁금하다면, JobSupport.kt 파일에서 childCancelled 및 notifyCancelling 함수의 구현을 살펴보면됩니다. SupervisorJob의 구현에서 childCancelled 메소드는 단순히 false를 반환합니다. 이는 취소를 전파하지 않지만 예외를 처리하지도 않음을 의미합니다.

예외 처리

코루틴은 예외 처리를 위해 try/catch나 runCatching과 같은 헬퍼 함수를 사용합니다. 이전에는 처리되지 않은 예외가 항상 throw 된다고 설명했습니다. 그러나 각 코루틴 빌더는 예외를 처리하는 방식이 다릅니다.

Launch

launch를 사용하면 예외가 발생하는 즉시 throw 됩니다. 따라서 다음과 같이 try/catch로 예외를 throw 할 수 있는 코드를 감쌀 수 있습니다.

scope.launch {
    try {
        codeThatCanThrowExceptions()
    } catch(e: Exception) {
        // Handle exception
    }
}

Async

async가 루트 코루틴(CoroutineScope 인스턴스 혹은 supervisorScope의 자식 코루틴)으로 사용될 때는 예외가 자동으로 throw 되지 않습니다. 대신 .await()를 호출할 때 예외가 throw 됩니다. 루트 코루틴으로 사용될 때 async에서 throw되는 예외를 처리하려면, .await() 호출을 try/catch로 감싸야합니다.

supervisorScope {
    val deferred = async {
        codeThatCanThrowExceptions()
    }
    try {
        deferred.await()
    } catch(e: Exception) {
        // Handle exception thrown in async
    }
}

 

여기서, async와 await를 호출하는 데 supervisorScope를 사용하는 것이 보일겁니다. 이전에 말했듯이 SupervisorJob은 코루틴이 예외를 처리하도록 허용합니다. 반면 Job은 예외를 계층 구조 상위로 자동 전파하기 때문에, catch 블록이 호출되지 않습니다.

coroutineScope {
    try {
        val deferred = async {
            codeThatCanThrowExceptions()
        }
        deferred.await()
    } catch(e: Exception) {
        // Exception thrown in async WILL NOT be caught here 
        // but propagated up to the scope
    }
}

 

또한, 다른 코루틴에서 생성된 예외는 빌더에 관계없이 항상 전파됩니다. 예를 들어

val scope = CoroutineScope(Job())
scope.launch {
    async {
        // If async throws, launch throws without calling .await()
    }
}

이 경우, async에서 예외가 발생하면 직접적인 부모 코루틴이 launch인 경우 즉시 throw 됩니다. 이유는 CoroutineContext에서 Job이 있는 async가 예외를 자동으로 부모(launch)에게 전파하기 때문입니다. 따라서 launch에서 예외가 throw 됩니다.

 

⚠️ coroutineScope 빌더나 다른 코루틴에서 생성된 코루틴에서 발생하는 예외는 try/catch에 잡히지 않습니다.

CoroutineExceptionHandler

CoroutineExceptionHandler는 CoroutineContext의 옵션 요소로, uncaught 예외를 처리할 수 있도록 해줍니다. 다음과 같이 CoroutineExceptionHandler를 정의할 수 있으며, 예외가 잡힐 때 예외가 발생한 CoroutineContext 및 예외 정보를 알 수 있습니다.

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

다음 요구 사항을 만족하면 예외가 잡힙니다.

  • When : 예외가 자동으로 throw되는 코루틴에서 throw됩니다(launch에서 동작하며, async에서는 동작하지 않음).
  • Where : CoroutineScope 또는 루트 코루틴(CoroutineScope의 인스턴스 또는 supervisorScope 자식 코루틴)의 CoroutineContext에 있을 때
val scope = CoroutineScope(Job())
scope.launch(handler) {
    launch {
        throw Exception("Failed coroutine")
    }
}

 

아래와 같이 핸들러가 내부 코루틴에 설치된 경우에는, catch 되지 않습니다.

val scope = CoroutineScope(Job())
scope.launch {
    launch(handler) {
        throw Exception("Failed coroutine")
    }
}

앞에서 언급했듯이, 내부 launch는 예외가 발생하자마자 부모에게 전파합니다. 즉, 내부 코루틴에 전달된 핸들러는 무시됩니다. 그리고 루트 코루틴에는 핸들러를 전달하지 않았기 때문에 예외가 throw 됩니다.

 

앱에서 예외를 우아하게 처리하는 것은 좋은 UX를 위한 중요한 과정입니다. 예상치 못한 상황이 발생해도 사용자에게 만족스러운 경험을 선사해야합니다. 예외가 발생했을 때 취소를 전파하지 않으려면 SupervisorJob을 사용하고, 그렇지 않으면 Job을 사용하세요.