선생님, 개발을 잘하고 싶어요.

[코틀린] 중첩된 Coroutine 에러 처리 본문

개발/android 개발

[코틀린] 중첩된 Coroutine 에러 처리

알고싶은 승민 2021. 12. 12. 20:00

에러는 어디서 처리하는가?

다음 코드는 getImage가 에러를 던진다면 어떻게 될까요?

fun main() = runBlocking {
    try {
        val deferredImage = scope.async { getImage("path") }

        deferredImage.await()
    } catch (e: Exception) {
        // ignore all exceptions
    }
}

kotlin 문서를 참조하면 async 내부에서 에러가 발생하는 경우 await 시점에 잡히기 때문에 getImage에서 발생한 에러는 위의 catch 구문에 의해서 처리되게 됩니다.

 

그렇다면 다음 코드는 어떻게 동작할까요?

scope.launch {
    try {
        val deferredImage1 = async { getImage("path") }
        val deferredImage2 = async { getImage("path") }
        
        awaitAll(deferredImage1, deferredImage2)
    } catch (e: Exception) {
        // ignore all exceptions
    }
}

이번에도 getImage에서 에러가 발생합니다. 그런데 어찌 된 일인지 catch 문에서 에러를 처리하지 않고 프로세스가 죽어버리게 됩니다. 무슨 일 일까요?

 

Root Coroutine에 대한 이해

launch나 async는 새로운 coroutine을 만들어내는 coroutine builder입니다. 여기서 유의 깊게 볼 사실은 첫 번째 코드에서는 async 함수가 scope에서 새로운 coroutine을 만들기 위해서 호출되었다는 것이고 두 번째 코드에서는 scope.launch로 이미 만들어진 coroutine의 자식으로서 coroutine을 만드는 데 사용되었다는 것입니다.

 

첫 번째 경우 async가 생성하는 coroutine을 부모 coroutine이 없는 root coroutine이라고 부릅니다.

두 번째 경우는 다른 coroutine의 자식인 coroutine입니다. 

아래 링크 블로그 참조

분홍색 coroutine을 root coroutine, 초록색 coroutine을 child coroutine이라고 합시다.

아래 링크 블로그 참조

child에서 발생한 에러가 어떻게 parent와 상호작용 하는지 나타내는 이미지입니다.

child에서 에러가 발생하고 처리되지 않으면 그 child를 cancel 하고 에러를 부모에게 propagate 합니다.

child로부터 에러를 받은 부모의 기본 동작은 관리 중인 다른 child를 모두 cancel 한 이후 해당 에러를 그 부모에게 propagate 하는 식으로 동작합니다.

 

문제가 발생한 두 번째 코드 같은 경우에는 이런 상황이라고 볼 수 있습니다.

val deferredImage = async { getImage("path") }

여기에서 async가 만들어낸 coroutine은 에러를 처리하지 않는 걸 확인할 수 있습니다. 따라서 이 coroutine은 cancel 되고 바로 부모에게 에러가 propagate 됩니다. 그리고 부모는 이 propagate 된 에러를 받아 다른 children를 정리하고 다시 상위로 에러를 propagate 하게 됩니다.

따라서 이 과정에서 try/catch는 호출되지 않게 되는 것이죠.

 

문제 해결과 SupervisorJob

이런 문제를 해결하기 위해서는 기존 Coroutine의 Cancel 메커니즘을 변경해야 합니다.

child의 cancel이 부모에게 전달되었을 때, 다른 child를 cancel하지 않도록 하면 됩니다. 이러한 조건을 만족시키는 것이 바로 SupervisorJob입니다.

아래 링크 블로그 참조

SupervisorJob은 child가 에러로 죽어도 이를 다를 children에게 알리지 않습니다. 알아서 처리하죠.

그래서 두 번째 코드를 다음과 같이 변경하면 됩니다.

scope.launch {
    supervisorScope {
        try {
            val deferredImage1 = async { getImage("path") }
            val deferredImage2 = async { getImage("path") }

            awaitAll(deferredImage1, deferredImage2)
        } catch (e: Exception) {
            // ignore all exceptions
        }
    }
}

 

참고로 여러 coroutine을 조합하여 하나의 결과를 생산하는 코드에서는 CoroutineScopeBuilder를 사용하여 코드를 작성하는 편이 관리가 용이합니다.

private fun getImages(): List<Image> {
    supervisorScope {
        try {
            val deferredImage1 = async { getImage("path") }
            val deferredImage2 = async { getImage("path") }

            awaitAll(deferredImage1, deferredImage2)
        } catch (e: Exception) {
            // ignore all exceptions
        }
    }
}

참고 자료

Coroutine exception handling: 코틀린 공식 문서, Exception propagation에 대한 내용이 나옵니다.

Coroutines: fisrt things first: 구글 안드로이드 미디엄 포스트 CoroutineScope, Job, CoroutineContext 같은 기본 개념을 다룹니다.

Exceptions in coroutines: 구글 안드로이드 미디엄 포스트 에러 처리에 대한 직관적이지 않은 부분을 소개합니다. 특히 이런 문제를 돌아가기 위해 필요한 SupervisorJob을 다룹니다.

Error Handling Coroutine: 구글링 하다 찾은 에러 처리에 대한 내용 위의 포스팅과 참고 자료로 이해한 내용을 다시 한번 써머리 하는 느낌으로 보기 좋습니다.

 

Comments