코루틴 예외 다루기

Exception Handling in coroutine

Myungpyo Shim
NAVER Pay Dev Blog
37 min readJun 9, 2022

--

unsplash.com

안녕하세요. NaverFinancial 앱 개발팀의 심명표 입니다.

(목차로 돌아가기)

이전 포스팅(코루틴 내부 상태 관리 알아보기)에서는 코루틴이 생성되어 소멸에 이르기까지 갖게 되는 다양한 상태 및 상태 간 전환 과정에 대해서 살펴보았습니다. 또한 코루틴의 예외 처리 방식에 대해서도 가볍게 살펴보았습니다...만 실제 코루틴을 사용할 때 적용할 수 있을 만큼 자세히 살펴보진 못했던 것 같습니다. 그래서 이번엔 코루틴의 예외 처리 방식에 대해서 조금 더 깊게 살펴보려고 합니다.

코루틴에 대해서는 꽤 오래간만의 글입니다. 이런저런 핑계로 시간을 내지 못하다가 최근 다음과 같은 코드를 작성하여 UI를 🔥불태운 뒤 마음을 다잡고 코루틴의 예외 처리 방식에 대해서 다시 한번 살펴보게 되었습니다 😊.

다음 코드는 문제가 되었던 코드와 동일한 상황을 연출하는 코드입니다.

코드는 viewModelScope*에서 launch 코루틴 빌더를 이용하여 코루틴을 생성한 뒤 생성 된 코루틴 내부에서 앱 설정 정보(appInfo)와 사용자 정보(userInfo) 두 개의 데이터를 비동기로 요청(async)한 뒤에 모든 데이터를 정상적으로 수신하면 결과 데이터로 UI를 업데이트하는 코드입니다. 이 코드는 async 코루틴 빌더를 사용하고 await() 함수 호출 시 예외 처리를 하였지만 데이터를 동기화하는 과정에서 예외가 발생하면 애플리케이션 크래시로 이어집니다. 여러분은 위 코드의 어느 부분이 문제인지 보이시나요? 쉽게 찾으셨다면 코루틴의 예외 처리 방식에 대해 잘 알고 계십니다 👏. 어느 부분이 문제인지 잘모르시겠다구요? 그렇다면 지금부터 저와 함께 알아봅시다 🙌.

viewModelScope
: Android UI 컴포넌트의 상태 관리 및 액션을 처리하는 ViewModel이 정의하는 코루틴 스코프로 기본적으로 MainThread Dispatcher와 SupervisorJob을 이용합니다.

Structured Concurrency 란?

코루틴의 예외 처리 방식에 대해 살펴보기에 앞서 잠시 Structured Concurrency라는 용어에 대해서 알아봅시다. 이 용어는 코루틴을 소개할 때 항상 빠지지 않고 등장하는 용어이며 코루틴의 큰 장점중 하나로 소개됩니다. 한국어로는 “구조화된 동시성” 정도가 될 것 같은데 Structured Concurrency란 대체 무엇일까요?

Wiki를 찾아보면 다음과 같은 문장으로 시작하고 있습니다.

Structured concurrency is a programming paradigm aimed at improving the clarity, quality, and development time of a computer program by using a structured approach to concurrent programming.

대략 Structured Concurrency란 동시성 프로그래밍 시에 구조적인 접근을 통해 보다 투명하고 안정적인 프로그래밍이 가능하도록 하는 프로그래밍 패러다임이라고 합니다. 첫 문장만으론 아직 한참 높이 떠있는 구름을 잡고 있는 것 같습니다 ☁️☁️☁️. 그럼 바로 이어지는 다음 문장까지 살펴보도록 합시다.

The core concept is the encapsulation of concurrent threads of execution (here encompassing kernel and userland threads and processes) by way of control flow constructs that have clear entry and exit points and that ensure all spawned threads have completed before exit. Such encapsulation allows errors in concurrent threads to be propagated to the control structure’s parent scope and managed by the native error handling mechanisms of each particular computer language.

요약하면 동시에 실행되는 스레드들을 캡슐화하여 스레드 시작과 끝 지점을 명확하게 접근, 제어할 수 있도록 함으로써 호출한 스레드(Caller Thread) 종료 전 모든 호출된 스레드(Callee Thread)들이 종료되는 것을 보장하는 컨셉입니다. 또한 이러한 특징을 통해 호출된 스레드들에서 발생하는 예외가 호출 스레드로 제대로 전파될 수 있도록 지원합니다.

여기까지 보니 조금 느낌이 오는 것 같습니다 ☀️☀️☀️. 코루틴은 경량의 스레드라는 별칭을 가지고 있습니다 (물론 진짜 스레드는 아니지만). Wiki 문장에서 스레드를 우리는 코루틴이라 생각하면 됩니다. 우리는 코루틴을 사용할 때 다양한 코루틴 스코프를 정의하게되고 스코프 내에서 코루틴을 생성 및 실행하는데 코루틴 내부에서 또 다른 코루틴을 실행함으로써 코루틴들 간에 부모-자식의 계층 구조를 형성하게 됩니다. 스코프 역시 그 자체로 코루틴으로 구현되기 때문에 이러한 계층 구조에 참여하게 됩니다. 결국 이들은 트리 구조의 계층을 이루며 각 노드(코루틴)는 Job의 형태로 0..1개의 부모 노드와 0..n개의 자식 노드를 참조합니다. 이를 통해 부모 노드는 모든 자식 노드가 종료되어야 종료될 수 있고, 자식 노드가 예외로 종료 되었다면 기본적으로는 루트 노드까지 전파되며 관련 노드들의 실행이 취소되도록 합니다. 만약 코루틴에 이러한 특징이 없었다면 코루틴을 사용하면서 발생되는 예외들을 처리하는 것은 상당히 까다로운 일이었을 것입니다. 하지만 이러한 Structured Concurrency라는 특징은 코루틴 사용 시 예외 처리 방식에 있어서 몇 가지 기억해 두어야 할 규칙들을 만들어 냈습니다.

  • launch { } 타입 빌더(ex> launch { }, actor { }, …)로 생성 된 코루틴이 루트 코루틴인 경우 코루틴 계층 구조 내에서 발생한 예외는 루트 코루틴의 예외 핸들러에 의해 처리됨.
  • async { } 타입 빌더(ex> async { }, produce { }, …)로 생성 된 코루틴이 루트 코루틴인 경우 코루틴 계층 구조 내에서 발생한 예외는 호출자에게 예외 처리를 맡기며 루트 코루틴의 예외 핸들러는 동작하지 않음 (호출자가 await(), receive() 등의 데이터 수신 함수를 호출할 때 예외가 발생).
  • CoroutineContext에 SupervisorJob이 설정되었거나 supervisorScope에서 루트로 실행된 코루틴은 CoroutineExceptionHandler 등으로 자체 예외 처리가 필요함.

이러한 규칙들은 코루틴 스테이트 머신에서 예외로 인해 종료중인 상태를 나타내는 CANCELLING 부분을 살펴보면 보다 쉽게 이해할 수 있습니다.

예외로인한 코루틴 종료 과정 살펴보기

코루틴은 다음과 같은 스테이트 머신을 갖고 있으며 자세한 내용은 이전 포스팅에서 다루었습니다.

Lifecycle of a coroutine

코루틴의 예외 처리 과정에 대해서 좀 더 자세히 알아보기 위해서 살펴보아야 할 상태는 CANCELLING 상태이며, 이 상태는 코루틴이 예외에 의해 종료될 경우 마지막으로 거쳐가는 상태입니다. 만약 부모 코루틴은 예외 발생 없이 정상적으로 실행을 마친 상태에서 자식 코루틴들의 종료를 기다리는 상태(COMPLETING)에 있다 하더라도 자식 코루틴 중에 일부가 부모로 예외 발생을 통지하게 되면 부모 코루틴은 CANCELLING 상태로 전환됩니다. 이러한 방식으로 코루틴의 예외 전파는 루트 코루틴*까지 전파되어 실질적인 예외 처리는 루트 코루틴에서 수행됩니다.

루트 코루틴 : 코루틴 스코프에서 최상위 코루틴으로 부모가 없는 코루틴.

이 부분에 대한 코드는 JobSupport* 클래스의 finalizeFinishingState() 함수의 다음 부분에서 확인할 수 있습니다. 지금부터 코루틴의 예외 처리 과정을 코루틴 내부 코드를 통해 먼저 살펴본 뒤에 다양한 유형의 예외 발생 시나리오들을 테스트하며 실제 동작을 확인해 보겠습니다. 조금 지루할 수 있지만 코드 레벨에서 대략적인 동작 방식을 먼저 이해하면 이어지는 예외 발생 시나리오들을 더욱 쉽게 이해할 수 있습니다 😉.

JobSupport 클래스는 코루틴의 스테이트 머신을 관리하는 역할을 수행하는 클래스이며 모든 코루틴 클래스는 JobSupport 클래스를 상속합니다.

위 다이어그램은 JobSupport 클래스와 관계된 클래스들과의 관계를 나타낸 다이어그램입니다. 우리가 코루틴을 생성할 때 어떤 빌더를 사용하느냐에 따라서 다양한 타입의 코루틴이 생성됩니다. 다음은 자주 사용되는 빌더에 대한 예시입니다.

  • launch 빌더 : StandaloneCoroutine
  • async 빌더 : DeferredCoroutine
  • coroutineScope 빌더 : ScopedCoroutine

이제 JobSupport 클래스의 finalizeFinishingState() 함수를 살펴봅시다.

finalizeFinishingState() 함수는 코루틴이 종료 상태로 전환되기 전에 실행되는 함수로 제일 중요한 부분은 4번 라인이며 예외로 인한 종료 시 진입하게 됩니다.
cancelParent() 함수는 현재 코루틴에서 발생한 예외를 부모 코루틴으로 전달함으로써 예외 처리를 요청하는 함수입니다. 이 함수가 true를 반환한다면 전달 된 예외는 부모 코루틴에의해서 처리된다는 의미이고, false를 반환한다면 해당 예외는 부모에 의해 처리될 수 없으니 현재 코루틴이 처리해야 한다는 것을 의미합니다.
handleJobException() 함수는 전달되는 예외를 가능한 예외 처리 방식으로 실제 처리하는 역할을 수행합니다. 이에 대해서는 아래에서 자세하게 살펴보겠습니다.

cancelParent()와 handleJobException()은 || (Logical OR) 연산자로 연결되어 있으므로 cancelParent() 가 true일 경우 다음 조건을 확인할 필요가 없기 때문에handleJobException()은 실행되지 않습니다.

결과적으로 부모로부터 자식에 이르는 코루틴 계층 구조가 있다고 했을 때, 최상위에 위치하는 루트 코루틴을 제외한 나머지 자식 코루틴들은 일반적으로는 cancelParent()에서 true를 반환받고 handleJobException() 함수를 실행하지 않습니다. 오류를 전파 받은 루트 코루틴만이 cancelParent() 함수에서 false를 반환받고, handleJobException() 함수를 실행하게 됩니다.

cancelParent()

먼저 cancelParent() 함수 구현을 살펴보도록 하겠습니다.

먼저 ScopedCoroutine일 경우(isScopedCoroutine = true)에는 부모 코루틴에 예외 전파 없이 곧바로 true를 반환함으로써 현재 코루틴도 예외를 처리하지 않게 합니다. 이렇게 처리하는 이유는 ScopedCoroutine이 무엇인지 알아보면 예상해 볼 수 있는데, 대표적인 ScopedCoroutine으로는 coroutinScope { }, supervisorScope { }, runBlocking { } 등의 코루틴 빌더들로부터 생성되는 코루틴들을 그 예로 들 수 있습니다. 이러한 ScopedCoroutine 들은 코루틴들의 실행 범위를 제한하기 위한 목적의 코루틴들로 스코프 내에서 실행 된 코루틴들에서 발생한 예외를 스코프 외부로 그대로 전달하는 동작만 수행합니다.

7번 라인을 보면 부모 코루틴이 없는 경우(null || NonDisposableHandle), 보통 스코프에서 루트 코루틴인 경우에는 isCancellation을 반환합니다. 원래라면 false를 반환하여 현재 코루틴에서 예외 처리가 이루어지도록 해야 하지만 “취소 예외”인 경우 예외 처리가 되지 않도록 isCancellation을 반환합니다 (취소 예외는 코루틴 계층 구조에서 일부 혹은 전체 스코프를 취소하기 위해 사용되며 정상적인 상황으로 간주됩니다).

11번 라인에 도달한 경우 현재 코루틴은 Scoped Coroutine도 아니며 Root Coroutine도 아니므로 부모 코루틴의 핸들인 parent 객체에 childCancelled() 함수를 호출하여 예외를 전파합니다. 다음은 핸들 코드입니다.

6번 라인의 childCancelled() 함수를 보면 부모 코루틴 job을 이용하여 예외를 전달하고 있습니다. 바로 이 부분에서 특정 상황에서는 부모로 전파가 안될 수도 있습니다 ⁉️. 대표적인 경우는 이미 알고 계신 분도 있겠지만 이 job이 SupervisorJob인 경우입니다. SupervisorJob인 경우는 부모 코루틴의 취소 동작 없이 바로 false를 반환합니다. 따라서 부모 코루틴으로 예외를 전파하려던 코루틴은 아래에서 살펴볼 handleJobException() 함수를 통해 직접 예외를 처리해야 합니다.

handleJobException()

이번엔 cancelParent() 함수가 false를 반환할 경우 현재 코루틴에서 예외처리를 위해서 호출되는 handleJobException() 코드를 살펴봅시다.

앞서 코루틴은 크게 launch 타입 코루틴async 타입 코루틴으로 나눌 수 있다고 하였습니다. launch 빌더를 이용해 코루틴을 생성하면 기본 코루틴인 구현체인 StandaloneCoroutine을 생성하게 되며 이 코루틴은 7~19번 라인에서 볼 수 있듯이 다음과 같은 우선순위에 따라 예외를 처리합니다.

  • CoroutineContext에 정의 된 CoroutineExceptionHandler
  • [Additional]
    ServiceLoader를 통해 찾을 수 있는 모든 CoroutineExceptionHandler
  • 현재 스레드의 uncaughtExceptionHandler
  • 스레드가 공유하는 defaultUncaughtExceptionHandler

위 우선순위에 따른 예외 전파에 대해서는 아래에서 샘플 코드로 확인해 보겠습니다.

22번 라인을 보면 handleJobException() 함수가 false를 반환하는데 이것이 JobSupport 클래스에 정의된 코루틴의 기본 구현입니다. 따라서 async 빌더로 생성한 DeferredCoroutine 등은 StandaloneCoroutine과 같이 필요한 예외 핸들링 구현을 하지 않았기 때문에 기본 구현인 false를 반환하고 다른 방식으로 예외를 처리하게 됩니다. DeferredCoroutine의 경우 코루틴 실행 후 반환받은 핸들인 Deferred<T>에 await() 함수를 호출하면 코루틴이 예외로 인해 종료되었을 경우 발생했던 예외가 다시 발생하게 됩니다.

지금까지 코루틴에서 예외가 처리되는 방식을 코루틴 내부 코드를 통해 살펴보았습니다. 기본적으로 예외는 리프 노드(Leaf Node) 에서 루트 노드(Root Node) 방향으로 전파되고 루트 노드에서 최종적으로 처리됩니다. 이러한 기본적인 흐름에서 벗어나는 경우는 코루틴이 async { }, producer { } 같은 빌더를 통해 생성된 DeferredCoroutine인 경우나 부모 코루틴의 Job이 SupervisorJob인 경우였습니다. 다음 이미지는 지금까지 살펴본 내용을 나타내는 이미지 입니다.

위에서부터 순서대로 일반적인 경우의 오류 전파 과정, async 빌더를 이용해 생성 된 DeferredCoroutine 일 경우 오류 전파 과정, SupervisorCoroutine 일 경우 오류 전파 과정을 나타냅니다.

다양한 코루틴 예외 사례 살펴보기

지금까지 코드로 살펴본 코루틴의 예외 처리 방식을 다양한 사례를 통해서 관찰해 보겠습니다. 먼저 다음과 같은 코루틴 계층 구조를 기본 템플릿으로 사용하겠습니다.

코루틴 스코프 내에 최상위에 위치한 Coroutine A(루트 코루틴), 그리고 Coroutine A가 실행 한 Coroutine B, 마지막으로 Coroutine B가 실행 한 Coroutine C-1, Coroutine C-2 가 있습니다. 이를 코드로 작성하면 다음과 같이 나타낼 수 있습니다.

log() 함수는 현재 코루틴 이름을 로그 앞에 출력합니다.
만약 Thread 및 코루틴 ID를 로그에 출력하고 싶은 경우 로그에 현재 스레드 이름(Thread.currentThread().name)을 출력하고, 테스트 코드 실행 시 VM Options 으로 -Dkotlinx.coroutines.debug를 전달하면 됩니다.

Coroutine C-1은 1s마다 로그 메시지를 출력하며 3회 반복합니다.
Coroutine C-2는 500ms마다 로그 메시지를 출력하며 3회 반복합니다.
실행 결과는 다음과 같습니다.

지금부터 위 템플릿을 기본으로 하여 코드 일부분을 변경하거나 추가해가며 다양한 예외 상황을 확인해 보겠습니다. 앞서 코루틴 내부 코드로 살펴본 다음의 코드를 다시 한번 머릿속에 상기하며 계속 진행해 봅시다.

위 코드는 부모 코루틴에 예외 발생을 전파하고(cancelParent) true가 반환되면 부모가 예외를 처리하는 것이므로 handleJobException은 실행되지 않고, false가 반환되면 부모가 예외를 처리하지 않으므로 handleJobException 호출을 통해 현재 코루틴이 예외를 처리하는 것을 의미함.

이제 다음과 같이 코루틴 C-2에서 예외가 발생하는 상황을 만들어 보겠습니다.

위 코드의 실행 결과는 다음과 같습니다.

예상했던 것처럼 Coroutine C-2가 실행 중 예외를 발생시키고 이는 Coroutine A까지 전파되며 Coroutine A에도 특별한 예외 핸들러가 설정되어 있지 않기 때문에 UncaughtException으로 처리됩니다.
이를 그림으로 나타내면 다음과 같습니다.

위 이미지에서 녹색 블록은 launch 빌더를 통해 실행된 코루틴을 나타내며 JobExceptionHandling 블록은 앞서 코드로 살펴본 JobSupport의 예외 처리 로직입니다. 모든 코루틴은 JobSupport에 예외 처리 로직이 구현되어 있지만 실제 처리는 루트 코루틴(여기서는 Coroutine A)에 의해 처리되므로 나머지 코루틴들의 JobExceptionHandling 블록들은 흐리게 표시되었습니다.

🌟 이쯤에서 잠시 루트 코루틴이 handleJobException() 함수를 통해 예외 처리를 수행할 때 예외 처리 핸들러의 우선순위를 한번 확인해 보겠습니다.
다음 코드는 3가지 방식의 예외 핸들러가 구현되어 있으나 모두 주석 처리된 상태입니다.

  • 5번 라인은 코루틴을 실행할 스레드 인스턴스에 예외 핸들러를 설정합니다.
  • 13번 라인은 모든 스레드의 기본 예외 핸들러를 설정합니다.
  • 26번 라인은 Coroutine A의 코루틴 컨텍스트에 예외 핸들러를 설정합니다.

위 세 가지 핸들러 코드의 주석을 모두 해제하고 실행하면 프로그램은 더 이상 오류로 종료되지 않고 CoroutineExceptionHandler 예외 로그를 출력합니다. 이것은 26번 라인에서 추가한 코루틴 예외 핸들러에 의한 동작입니다. 26번 라인의 Coroutine A 예외 핸들러를 주석 처리하고 다시 한번 실행해 봅시다. 이번엔 5번 라인에서 설정한 코루틴 실행 스레드의 예외 핸들러 메시지인 UncaughtExceptionHandler 메시지를 출력하며, 5번 라인도 주석 처리하고 실행하면 13번 라인에서 설정한 스레드 기본 예외 핸들러가 DefaultUncaughtExceptionHandler 메시지를 출력하게 됩니다.

위 결과로 확인할 수 있는 코루틴 예외 핸들러 우선순위는 다음과 같습니다.

  • 코루틴 컨텍스트에 설정 된 CoroutineExceptionHandler
  • Thread.uncaughtExceptionHandler
  • Thread.defaultUncaughtExceptionHandler

여기서 미리 언급했던 예외 핸들러 중 한 가지가 빠져있습니다. 그것은 ServiceLoader에 등록 된 예외 핸들러 입니다. 이 예제 코드를 보시면 SimpleCoroutineExceptionHandler 라는 CoroutineExceptionHandler 구현체를 정의하고 프로젝트 루트의 resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler 파일을 생성하여 위 핸들러를 ServiceLoader를 통해 접근할 수 있도록 등록합니다. 이렇게 등록하면 코루틴 컨택스트에 설정 된 예외 핸들러가 있을 경우 그것이 실행 되고, 그 이외의 경우에는 다른 핸들러와 함께 ServiceLoader에 등록 된 예외 핸들러들도 실행 됩니다.

Async Coroutine Builder

다시 앞서 살펴보던 코루틴 기본 템플릿으로 돌아와서 Coroutine A를 launch가 아닌 async 빌더로 교체한 뒤 실행해 봅시다.

코드의 실행 결과는 앞선 예제와 달리 특별한 예외 출력 없이 결과 출력 중 중단됩니다.

이 경우 예외는 Coroutine C-2에서 발생하여 Coroutine B를 거쳐 Coroutine A까지 전파됩니다. Coroutine A는 루트 코루틴이므로 부모가 없기 때문에 cancelParent() 함수에서 false를 반환하고 handleJobException() 함수를 실행합니다. 여기까지는 앞선 예제의 동작과 동일합니다. 하지만 Coroutine A는 async 빌더로 실행된 코루틴이므로 DeferredCoroutine이며 이 경우 StandaloneCoroutine과 달리 JobSupport 기본 구현을 사용합니다. 기본 구현은 단순히 false를 반환하는 것이며 발생한 코루틴의 예외는 처리되지 않은 것으로 표시됩니다 (handled = false).

async 빌더로 코루틴 실행 시 반환된 Deferred<T>에 await()를 호출하면 처리되지 않았던 예외가 발생합니다.

이를 그림으로 표현하면 다음과 같습니다. Coroutine A는 루트 코루틴이지만 예외 처리 핸들러를 호출하지 않습니다 (4번 과정이 생략됨).

만약 다음과 같이 Coroutine A가 아닌 Coroutine B를 async 빌더로 실행한다면 결과는 어떻게 바뀔까요? 여전히 await() 함수는 호출하지 않았으니 예외 발생 없이 동일한 로그를 출력하며 종료될까요?

위 코드의 실행 결과는 다음과 같습니다.

async 빌더를 사용했지만 이 경우에는 예외가 발생하여 프로그램을 종료합니다. 그 이유는 무엇일까요? 다음 그림을 보면 그 이유를 더 쉽게 알 수 있습니다.

Coroutine B는 async 빌더로 실행된 DeferredCoroutine이지만 루트 코루틴은 아닙니다. 그렇기 때문에 Coroutine C-2에서 발생되어 Coroutine B로 전파된 예외는 다시 Coroutine A로 전파됩니다. 결국 Coroutine A의 예외 처리에 의해 프로그램이 종료됩니다 (물론 Coroutine A에 예외 핸들러가 등록되었다면 핸들러가 동작합니다).

제일 처음에 문제 상황으로 제시했던 ViewModel 코드를 기억하시나요? 그 코드는 이번 예제와 동일한 문제를 가지고 있었습니다. 두 개 이상의 데이터 동기화를 비동기로 수행하기 위해서 async 빌더를 사용했지만 이들이 ViewModelScope의 루트 코루틴은 아니었습니다. 그러면 async 빌더로 코루틴 생성 시 항상 루트 코루틴으로 생성해야 하는 것일까요? 이어지는 내용에서 그 해답을 찾을 수 있습니다.

Supervision

SupervisorJob 또는 supervisorScope는 예외가 한방향으로 만 전파되도록 합니다. 앞서 코드 레벨에서 살펴보았듯이 SupervisorJob의 경우 부모 코루틴으로 예외를 전파하는 childCancelled() 함수가 예외 전파 없이 바로 false를 반환함으로써 호출 코루틴이 예외를 처리하도록 합니다.

다음과 같이 Coroutine C-2를 생성할 때 코루틴 컨텍스트의 Job을 SupervisorJob으로 설정해 봅시다.

위 코드의 실행 결과는 다음과 같습니다.

실행 결과에서 볼 수 있듯이 Coroutine C-2에서 예외가 발생하였지만 부모 코루틴(Coroutine B)으로 예외가 전파되지 않고 Coroutine C-2의 예외 핸들러에 의해 예외 스택 트레이스를 출력하고 종료됩니다. 또한 부모 코루틴으로 예외가 전파되지 않았으므로 Coroutine-C2의 형제 노드인 Coroutine-C1은 계속 실행되고 있습니다.

이를 나타내는 그림은 다음과 같습니다.

그림에서 볼 수 있듯이 Coroutine C-2의 예외는 어느 곳에도 전파되지 않고 자체 예외 핸들러에 의해 처리됩니다.

만약 다음과 같이 Coroutine C-2가 아닌 Coroutine B가 SupervisorJob으로 생성된다면 어떨까요? 예외 전파 범위를 더 정확히 파악하고자 Coroutine B도 B-1, B-2로 분리하였습니다.

실행 결과는 다음과 같습니다.

C-1, C-2 코루틴은 실행이 멈추고,

  • C-2 는 예외 발생에 의해
  • C-1 은 C-2 예외 발생으로 인한 B-2로부터 취소 요청에 의해

B-1 코루틴은 계속 실행됩니다. 이를 그림으로 나타내면 다음과 같습니다.

그런데 잠깐 ⁉️⁉️⁉️. Coroutine B-2를 SupervisorJob으로 실행했는데 Coroutine C-2가 예외로 종료될 때 부라더 Coroutine C-1이 왜 취소가 되었을까요?

사실 Supervison에 대해 설명하는 직전 예제와 이번 예제는 동일한 문제점을 가지고 있습니다 😏. 그것은 코루틴의 Structured Concurrency를 보장하기 위한 규칙을 어기고 있다는 것입니다. 코루틴들은 Caller와 Callee가 서로 계층구조를 이루는데, 이것은 각각의 코루틴이 Job이라는 형태로 서로 연결되어 있기 때문에 가능합니다 (관련된 예전 글). 이번 예제에서 코루틴 B-2를 실행 시 SupervisorJob을 사용하기 위해서 launch 빌더의 context 파라미터로 SupervisorJob을 생성하여 건네주었습니다. 이 부분에서 함정에 빠질 수 있는데 launch 빌더로 넘겨준 Job은 생성 된 코루틴의 부모 Job으로 설정됩니다. 결국 Coroutine B-2의 부모 Job은 Coroutine A 의 Job이 되어야 했지만 이 관계를 끊고 직접 제어 할 새로운 Job을 설정한 것 입니다.

이를 그림으로 나타내면 다음과 같습니다.

Coroutine B-2를 생성할 때 launch 빌더로 부모 컨텍스트의 Job을 별도로 전달함으로써 RootCoroutine으로 예외를 전파할 수 있는 Job 연결이 끊어지게되고, 따라서 Coroutine A로 예외를 전파할 수 없게 됩니다.

코루틴 계층구조가 깨짐으로써 Structured Concurrency를 보장하지 못하게 되었습니다.
CoroutineA -> CoroutineB-2 로의 순방향 전파는 내부적으로 자식 노드를 이용하여 동작하지만, CoroutineB-2에서 CoroutineA로의 역방향 전파는 불가.

하지만 SupervisorJob은 생성자로 parentJob을 전달하여 코루틴 계층 구조에 SupervisorJob이 참여하게 할 수 있는데 이 경우에도 다음과 같이코루틴 B-2에 부모 Job을 설정하는 것이 도움이 될까요?

코루틴 계층 구조에 SupervisorJob은 참여하게 되었지만 코루틴 생성자로 전달되는 Job은 생성되는 코루틴의 Job이 아닌 생성되는 코루틴의 부모 Job이 되므로 SupervisorJob은 코루틴 B-2의 Job이 아닌 부모 Job이 됩니다.

그림으로 나타내자면 다음과 같은 모습입니다.

그림을 보면 Coroutine B-2 생성 시 전달한 SupervisorJob이 Coroutine B-2의 부모 Job이 되고, SupervisorJob의 부모 Job은 Coroutine A의 Job이 되었습니다. 이때, Coroutine C-2에서 예외가 발생하게 되면 Coroutine B-2는 launch 타입 코루틴이며 SupervisorJob도 아니므로 부모 잡(SupervisorJob)으로 childCancelled() 함수 호출을 통해 예외를 전달하고 부모 잡은 SupervisorJob이므로 false를 반환하기 때문에 Coroutine B-2의 예외 핸들링 로직에 의해 오류 스택 트레이스를 출력하게 됩니다. 물론 Coroutine C-2의 형제 코루틴인 Coroutine C-1도 이 과정에서 취소됩니다.

그렇다면 위 예제의 올바른 구현은 무엇일까요? 지금의 경우에는 Supervision 적용을 하고 싶던 Coroutine B-2를 제거하고 supervisorScope { } 로 Coroutine C-1, Coroutine C-2를 묶는다면 코루틴 계층을 해치지 않으면서 목적을 달성할 수 있습니다.

위 코드의 출력결과는 다음과 같습니다.

출력 결과를 보면 Coroutine C-2가 예외로 취소된 이후에도 Coroutine C-1은 계속 작업을 수행하고 있는 것을 확인할 수 있습니다.

제일 처음에 제시했던 ViewModel 코드는 어떻게 개선할 수 있을까요? 비슷하게 예제를 작성해 보자면 다음과 같이 작성할 수 있을 것 같습니다.
sumRangeSlow() 함수는 from부터 to까지 정수를 모두 더해 반환해 주는 함수인데 특징으로는 매우 느리다는 것과 80을 초과하는 수는 처리하지 못하는 부분이 있습니다.

예제 실행 결과는 다음과 같습니다.

실행 결과를 보면 C-2의 결과가 80을 넘는 시점에 예외가 발생했음에도 C-1은 계속 실행되고 있는 모습을 볼 수 있으며, 마지막에 await 호출을 통해 C-1과 C-2의 결과를 합산하려 할 때 C-2를 취소시킨 예외가 발생하는 것을 확인할 수 있습니다.

또한 코루틴의 Structured Concurrency를 해치지 않았기 때문에 만약 C-1, C-2가 종료되기 전에 Coroutine B를 취소하면 C-1, C-2도 함께 취소됩니다.

지금까지 코루틴의 예외 처리 방식에 대해 코루틴 내부 코드를 통해 알아보고 여러 가지 예외 발생 시나리오를 예제로 만들어 실행해 봄으로써 각각의 상황에 적절한 예외 처리가 무엇인지 확인해 보았습니다.

이 글이 코루틴을 사용하면서 예외 상황에 대한 각종 처리를 할 때 도움이 된다면 좋겠습니다.

끝.

--

--