코루틴 공식 가이드 자세히 읽기 — Part 6

Myungpyo Shim
11 min readJan 24, 2019

--

공식 가이드 읽기 (6 / 8)

Exception handling

Korean [English]

(목차로 돌아가기)

이번 섹션에서는 예외 처리와 취소로 인한 예외에 대해서 다룹니다. 우리는 이미 취소된 코루틴은 중단점에서 CancellationException 예외를 던지며 이 예외는 코루틴 체계에서는 무시 된다는 것을 이미 알고 있습니다. 하지만 만일 취소 동작 중 예외가 발생하거나 혹은 2개 이상의 자식 코루틴들에서 동시에 예외가 발생한다면 어떨까요?

예외 전파 (Exception propagation)

코루틴 빌더들은 예외를 처리하는 방식에 따라 다음과 같이 크게 두 가지 타입으로 나눌 수 있습니다.

  • 예외를 자동으로 전파하거나 (launch, actor)
  • 사용자에게 노출하여 예외처리를 일임 (async, produce)

전자는 Java 의 Thread.uncaughtExceptionHandler 와 유사하게 처리되지 않은 예외로 간주되고, 후자는 마지막으로 예외를 처리하는 예외 처리 핸들러에게 그 처리 방식이 달려있으며 예시로는 await() 이나 receive() 를 들 수 있습니다. (produce 와 receive 는 채널 섹션에서 다룹니다.)

이러한 예외 전파의 특징은 다음과 같이 글로벌 스코프에서 새로운 코루틴을 생성하는 예제로 쉽게 확인해 볼 수 있습니다.

<출력 결과>

Throwing exception from launchException in thread "DefaultDispatcher-worker-1" java.lang.IndexOutOfBoundsException
at com.smp.coroutinesample.exception.ExceptionSample1Kt$main$1$job$1.invokeSuspend(ExceptionSample1.kt:12)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:241)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:594)
at kotlinx.coroutines.scheduling.CoroutineScheduler.access$runSafely(CoroutineScheduler.kt:60)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:740)
Joined failed jobThrowing exception from asyncCaught ArithmeticException

코루틴 예외 핸들러 (CoroutineExceptionHandler)

우리는 위에서 살펴본 것과 같이 처리되지 않은 예외를 콘솔로 출력하는 기본 동작을 변경 할 수 있습니다. 루트 코루틴(부모가 없는 최상위 코루틴)에 CoroutineExceptionHandler 컨텍스트 요소를 설정하면 이 핸들러는 이 루트 코루틴 및 모든 자식 코루틴들을 위한 범용적인 catch 블록과 같이 사용됩니다. 이것은 Thread.uncaughtExceptionHandler 와 유사합니다. 여러분은 CoroutineExceptionHandler 를 이용하여 예외 상황을 복구할 수는 없습니다. 예외 핸들러가 호출 된 코루틴은 이미 발생한 예외로 인해 종료되었습니다. 일반적으로 예외 핸들러는 예외를 로깅하거나 관련 예외 메시지를 사용자에게 보여주고 애플리케이션을 종료하거나 재시작하기 위해 사용됩니다.

JVM 에서는 ServiceLoader 를 통해 CoroutineExceptionHandler 를 등록함으로써 모든 코루틴들을 위한 범용적인 예외 처리 핸들러를 재정의 할 수 있습니다. 이러한 범용 예외 처리 핸들러는 적절한 예외 처리기가 등록되어 있지 않을 경우 최종적으로 사용되는 Thread.defaultUncaughtExceptionHandler 와 유사합니다. Android 에서는 uncaughtExceptionPreHandler 가 범용 코루틴 예외 처리기로 설치되어 있습니다.

CoroutineExceptionHandler 는 사용자에 의해 처리되지 않은 예외에 한해서만 호출됩니다. 특히, 모든 자식 코루틴들(다른 Job 의 컨텍스트를 이용해 생성된 코루틴들)은 예외 처리를 그들의 부모 코루틴에게 위임하며, 그 부모 코루틴은 그 부모 코루틴에게, 이런식으로 루트 코루틴에 도달 할 때까지 예외 처리를 위임합니다. 그래서 이러한 자식 코루틴들에게 설치 된 CoroutineExceptionHandler 는 절대 사용되지 않습니다. 추가적으로, async 코루틴 빌더는 항상 모든 예외들을 잡아(catch) 결과 객체인Deferred 객체를 통해 노출합니다. 그러므로 이러한 코루틴 빌더의 CoroutineExceptionHandler 역시 아무런 효과가 없습니다.

Supervision Scope 에서 실행중인 코루틴들은 위와 같은 규칙을 따르지 않으며 발생한 예외를 부모에게 전달하지 않습니다. 이 문서에서 이어지는 Supervision 관련 부분에서 더 자세하게 다룹니다.

위 예제에서 handler 는 launch 블록에 대해서만 예외 처리가 이루어 집니다.

<출력 결과>

Caught java.lang.AssertionError

취소와 예외 (Cancellation and exceptions)

취소는 예외와 밀접한 관계가 있습니다. 코루틴은 내부적으로 취소를 위해서 CancellationException 을 사용하며, 이러한 예외들은 모든 핸들러들에게 무시 되므로 catch 블록에서 획득할 수 있는 부가적인 디버깅 정보 획득용으로만 사용하는 것이 좋습니다. 코루틴이 Job.cancel()로 인해 취소되면 자신의 실행을 종료하지만 부모 코루틴에게 취소를 요청하지는 않습니다.

<출력 결과>

Cancelling child
Child is cancelled
Parent is not cancelled

만약 어떤 코루틴이 CancellationException 이외의 예외를 만나면 그 예외를 부모 코루틴에게 전달하여 부모 코루틴도 취소하게 됩니다. 이 동작방식은 재정의 될 수 없으며, 구조화된 동시성을 위한 안정된 코루틴 계층을 제공하기 위해 사용됩니다. CoroutineExceptionHandler 구현은 자식 코루틴들에 의해 사용되진 않습니다.

지금까지 예제들에서 CoroutineExceptionHandler 는 항상 GlobalScope 에서 생성된 코루틴에 설정되었습니다. 예외 핸들러를 메인함수의 runBlocking { } 스코프에서 설정하는 것은 다소 모순되어 보이는데, 메인 코루틴은 그 자식 코루틴이 예외로 인해 종료되면 핸들러가 설치되어 있더라도 항상 취소 될 것이기 때문입니다.

최초 발생한 예외는 모든 자식 코루틴이 종료된 후에야 부모 코루틴에 의해서 처리됩니다. 이는 다음 예제에서 확인해 볼 수 있습니다.

<출력 결과>

Second child throws an exception
Children are cancelled, but exception is not handled until all children terminate
The first child finished its non cancellable block
Caught java.lang.ArithmeticException

예외 통합 (Exception aggregation)

When multiple children of a coroutine fail with an exception, the general rule is “the first exception wins”, so the first exception gets handled. All additional exceptions that happen after the first one are attached to the first exception as suppressed ones.

만약 두 개 이상의 자식 코루틴들에서 예외가 발생한다면 어떻게 될까요? 기본적으로는 처음 발생한 예외가 우선합니다. 그러므로 처음 발생한 예외가 예외 처리 매커니즘에 의해 처리되게 됩니다. 그 이후에 발생하는 모든 예외들은 첫번째 예외에 숨겨져 추가되게 됩니다.

<출력 결과>

Caught java.io.IOException with suppressed [java.lang.ArithmeticException]

finally 블록에서 발생한 예외는 exception.supressed 로 확인할 수 있습니다.
(이 코드는 supressed exceptions 를 제공하는 JDK7 이상에서만 동작합니다.)

취소 예외(Cancellation exception)는 예외 전파에 있어서 투명하며 기본적으로 언래핑 됩니다. 즉, 다음 예제와 같이 취소 이외의 예외가 발생하여 코루틴이 취소 될 경우 CoroutineExceptionHandler 가 예외를 처리 할 때는 취소 예외는 바로 언래핑되고, 최소 발생한 예외가 핸들러의 exception 파라미터로 전달됩니다.

<출력 결과>

Rethrowing CancellationException with original cause
Caught original java.io.IOException

감독 (Supervision)

앞서 학습한 것처럼 취소는 전체 코루틴 계층 속에서 부모-자식 코루틴들간의 관계에서 양방향으로 전파됩니다. 이번엔 단방향 취소가 필요한 경우에 대해서 살펴봅시다.

이러한 경우에 대한 좋은 예로는 동일 스코프에 정의 된 Job 을 공유하는 UI 컴포넌트가 적절할 것 같습니다. 만일 UI 의 자식 태스크 중 하나가 실패했다고 하더라도 전체 UI 를 취소(or Kill)할 필요는 없습니다. 하지만 UI 컴포넌트가 종료되면 (그 Job 도 취소됨) 모든 자식들의 결과물도 필요 없게 되므로 자식들의 Job 도 취소되어야 합니다.

또 다른 예로는 여러개의 자식 작업을 갖는 서버 프로세스를 들 수 있는데, 이러한 서버 프로세스는 자식 작업들의 실행과 실패를 감독하고 실패한 자식 작업에 대해서는 다시 시작될 수 있도록 해야 합니다.

감독 작업 (Supervision job)

이러한 목적을 위해서 SupervisorJob 이 사용될 수 있습니다. 이것은 일반적인 Job 과 유사하지만 예외로 인한 취소가 아래 방향(부모 -> 자식)으로만 전파된다는 점이 다릅니다.
다음 예제를 통해 그 동작을 확인해 볼 수 있습니다.

<출력 결과>

First child is failing
First child is cancelled: true, but second one is still active
Cancelling supervisor
Second child is cancelled because supervisor is cancelled

감독 범위 (Supervision scope)

범위를 지정하여 동시성 제어(Scoped Concurrency)를 하기 위해서는 coroutineScope 를 사용하는데 이러한 스코프에서 단방향으로 전파되는 취소 매커니즘을 사용하려면 supervisorScope 을 사용하면 됩니다. 이것은 취소를 단방향으로 전파하며 오직 자신이 실패했을때만 모든 자식을 취소합니다. 또한 coroutineScope 과 동일하게 자시의 종료 전에 모든 자식들의 종료를 기다립니다.

<출력 결과>

Child is sleeping
Throwing exception from scope
Child is cancelled
Caught assertion error

감독중인 코루틴들에서의 예외 (Exceptions in supervised coroutines)

일반적인 Job 과 Supervisor Job의 중요한 차이점은 예외 처리 방식에 있습니다. 모든 자식은 예외 처리 매커니즘을 통해서 자신의 예외를 처리해야 합니다. 이러한 차이점은 자식의 실패가 부모로 전파되지 않는다는 사실로부터 발생합니다. 즉, supervisorScorp {}안에서 실행 된 코루틴들은 그들의 스코프에 설정 된 CoroutineExceptionHandler 를 사용한다는 것을 의미합니다(루트 코루틴이 설정하는 것과 동일하게).

<출력 결과>

Scope is completing
Child throws an exception
Caught java.lang.AssertionError
Scope is completed

--

--