brunch

You can make anything
by writing

C.S.Lewis

by 서준수 Nov 11. 2024

코루틴 예외 처리 (Exception Handling)

코틀린 코루틴(11)

코루틴 예외 처리 (Exception Handling)


예외에는 다양한 종류가 있지만 앞서 코루틴에서 발생하는 독특한 예외를 본 적이 있다. 바로 코루틴이 정상적으로 취소될 때 발생하는 CancellationException이다. 코루틴 취소에 관해서는 코루틴 Job에서 처음 살펴보았다.


그때의 코드를 다시 살펴보자.

실행 결과는 Hello, 이다. Kotlin!이 출력되지 않는 것은 코루틴의 구조화된 동시성에 의해서 부모 코루틴이 취소되면 자식 코루틴도 취소되기 때문이다. 여기서 취소로 인해 CancellationException이 발생하는 것은 앞서 코루틴 취소에서 확인해 봤기 때문에 알고 있을 것이다. 하지만 별도의 예외 처리를 하지 않았는데 실제로 콘솔에서 에러 문구를 확인할 수 없다. 그 이유는 취소 시 발생하는 CancellationException은 정상으로 간주하고 무시하기 때문이다.


만약 다른 예외를 발생시키면 어떻게 되는가?

다음과 같이 처리되지 않은 예외가 콘솔에 로깅되고 프로그램이 종료된다.

별도의 예외 처리를 하지 않았기 때문에 당연한 결과로 볼 수 있다. 그렇다면 다음 코드의 실행 결과는 어떻게 될까?

여전히 별도의 예외 처리를 하지 않았다. 달라진 것은 GlobalScope를 사용했다는 것이다. GlobalScope는 특정 컴포넌트의 생명주기에 묶이지 않는 최상위 코루틴을 시작하는 데 사용되는 전역 CoroutineScope이다. 실행 결과는 Hello, 이다. 이때 예외로 인한 에러가 발생하지 않은 이유는 무엇일까?


GlobalScope에서 시작한 코루틴은 runBlocking의 생명주기와 별도로 동작한다. 따라서 이 경우에는 GlobalScope에서 시작된 코루틴이 완료되기 전에 runBlocking 블록이 종료된다. runBlocking이 종료된다는 것은 메인 스레드가 종료된다는 것이고 일반적으로 애플리케이션이 종료된다는 것과 같은 뜻이다. 따라서 GlobalScope에서 시작한 코루틴 내부의 출력문이 실행되기 전에 프로그램이 종료되어 애초에 NullPointerException 발생이 되지 않은 것이다.


이 경우를 주목할 것은 아니었으나 GlobalScope에 대한 이해가 필요하여 조금 상세히 살펴보았다. 그러면 GlobalScope의 코루틴이 모두 실행되도록 기다리면 어떻게 될까? NullPointerException이 발생해야 한다.

실행 결과는 다음과 같다.

GlobalScope의 코루틴이 모두 실행되기 때문에 World!가 출력된 후 Kotlin!도 출력이 되었다. 그 후

NullPointerException이 발생했다. (다만 예외가 서로 다른 스레드에서 발생하고 있다는 차이점이 있다. 이것은 코루틴 디스패처를 알고 있다면 이해할 수 있다.)


주목할 것은 예외 발생 후에 Hello, 가 출력된 것이다. 이것은 예외 발생 후에도 프로그램이 종료되지 않았다는 뜻이다. 별도의 예외 처리를 하지 않았는데 어떻게 이런 결과가 발생했을까?


그것은 GlobalScope를 사용했기 때문이다. GlobalScope에서 시작된 코루틴은 최상위 코루틴이다. 따라서 부모 코루틴이 없다. 이런 경우에 해당 코루틴에서 별도의 예외 처리를 하지 않는 상황에서 예외가 발생하면 기본 예외 처리기가 이를 처리한다. 따라서 (아직 미지의 존재인) 기본 예외 처리기가 예외 처리를 하였기 때문에 프로그램이 무사히 완료되는 것이다.


코루틴 예외 핸들러 (CoroutineExceptionHandler)

별도의 예외 처리를 하지 않는 상황에서 발생한 예외를 처리하는 기본 예외 처리기는

CoroutineExceptionHandler이다. (최종적으로 처리하는 부분이라기보다는 최초로 처리를 담당하는 부분이라고 생각하면 된다. 이것이 무슨 의미인지는 추후 CoroutineExceptionHandler의 동작 방식을 살펴보면 이해할 수 있다.) 일단 CoroutineExceptionHandler가 무엇인지 예제를 통해서 살펴보자.


먼저 예외 처리를 위해 사용하는 대표적인 구문 try-catch를 사용한 예제를 살펴보자.

NullPointerException 발생 시 catch 문에서 해당 예외 정보를 출력하고 있다. 이것을 CoroutineExceptionHandler을 통해서 처리하도록 변경해 보면 다음과 같다.

try-catch 문 대신에 CoroutineExceptionHandler를 생성하여 GlobalScope의 코루틴의 컨텍스트로 넘겨주고 있다. 그렇다는 것은 CoroutineExceptionHandler가 CoroutineContext 혹은 하위 클래스의 타입을 가지는 객체라는 것을 추측할 수 있다. 실제로 CoroutineExceptionHandler는 CoroutineContext의 Element이다. (CoroutineContext의 Element인 것은 코루틴 디스패처와 같다. 관련된 내용은 코루틴 디스패처에 좀 더 자세히 설명되어 있다.)


위 두 예제의 실행 결과는 모두 다음과 같다.

Caught java.lang.NullPointerException: My NullPointerException

그러면 try-catch를 쓰면 되는데 왜 CoroutineExceptionHandler가 존재하는 것일까? try-catch 문은 특정 코드 블록 내에서 예외를 처리하는 데 사용되지만 CoroutineExceptionHandler는 코루틴의 전역 예외 처리를 위해 존재한다. 지금처럼 코루틴의 컨텍스트로 넘겨주면 해당 코루틴 내에서 발생하는 모든 예외를 처리할 수 있다. 이로 인해 구조화된 동시성으로 인해 발생하는 예외 전파를 효율적으로 처리할 수 있다. (예외 전파라는 표현은 처음 등장하는데 앞서 언급했던 부모 코루틴이 취소되면 자식 코루틴이 취소되는 것이 하나의 예시다. 이것은 추후 좀 더 살펴본다.)


try-catch 문은 예외를 처리하고 예외 발생 후 코루틴이 계속 실행된다. 반면 CoroutineExceptionHandler는 예외가 발생 후 코루틴을 계속 실행하지 않는다. CoroutineExceptionHandler는 잡히지 않은 예외를 처리하는 최후의 수단일 뿐이다. 예외를 로깅하거나 사용자에게 알리는 수단인 것이다.


CoroutineExceptionHandler의 동작 방식

별도의 예외 처리를 하지 않는 상황에서 발생한 예외를 처리하는 기본 예외 처리기는

CoroutineExceptionHandler라고 했다. ExceptionHandling_ex3에서 코루틴의 컨텍스트로 별도의 CoroutineExceptionHandler를 넘겨주지 않았다. try-catch 문도 없다. 즉 아무런 예외 처리를 하지 않은 것이다. 그럼에도 실행 결과를 보면 예외 처리가 이뤄졌다. 그것은 GlobalScope.launch처럼 launch 빌더로 생성된 최상위 코루틴에서 발생한 예외는 CoroutineExceptionHandler를 통해 처리되기 때문이라고 했다. 하지만 아직 의아하다. 원점으로 돌아온 것 같다. CoroutineExceptionHandler를 넘겨주지 않았는데 어떻게 CoroutineExceptionHandler를 통해서 예외 처리를 한다는 것인가?

CoroutineExceptionHandler.kt

CoroutineExceptionHandler의 동작 원리를 알아보기 위해서 CoroutineExceptionHandler.kt 파일을 살펴보자. 해당 파일에는 handleCoroutineException 메서드가 존재한다. 해당 메서드는 예외 처리 중에 또 다른 예외가 발생하거나 CoroutineExceptionHandler가 컨텍스트에 존재하지 않을 때 JVM 환경에서는 ServiceLoader를 통해 발견된 모든 CoroutineExceptionHandler 인스턴스와 Thread.uncaughtExceptionHandler가 호출된다.



ServiceLoader를 통해 찾는

CoroutineExceptionHandler 인스턴스의 범위는 JVM 클래스패스(Classpath) 내 접근 가능한 모든 서비스 제공자(Service Provider)에 해당한다. 클래스패스는 자바 프로그램이 실행될 때 필요한 클래스 파일(.class)과 라이브러리(jar 파일 등)의 위치를 지정하는 경로다. 자바 프로그램이 실행될 때 클래스 로더(ClassLoader)가 클래스패스에 지정된 경로에서 클래스와 리소스를 찾는다.


일반적으로는 ExceptionHandling_ex3처럼 CoroutineExceptionHandler가 별도로 등록되지 않았다면 기본적으로 등록된 핸들러가 없을 수 있다. 따라서 GlobalScope에서 발생한 예외는 기본 스레드의

uncaughtExceptionHandler에 의해 처리될 가능성이 높다. 그래서 지금처럼 예외 발생에 대한 로깅만 하고 넘어가는 것이다.


여기까지 문제없이 이해했다면 앞서 언급한 다음 문장을 코드로 검증해 볼 수 있다.

별도의 예외 처리를 하지 않는 상황에서 발생한 예외를 처리하는 기본 예외 처리기는 CoroutineExceptionHandler이다.

실행 결과는 다음과 같다.

Caught in try-catch java.lang.NullPointerException: My NullPointerException

실행 결과를 보면 launch 내부에서 try-catch로 별도의 예외 처리를 하고 있고 따라서 CoroutineExceptionHandler에서는 예외 처리를 하지 않는다는 것을 알 수 있다.


예외 처리 관점에서 다시 보는 CancellationException

다음 문장은 try-catch를 통해서 이미 검증했지만 좀 더 명확하게 CoroutineExceptionHandler를 통해서 확인할 수 있다.

취소 시 발생하는 CancellationException은 정상으로 간주하고 무시한다.

다시 try-catch 문을 통해서 CancellationException이 발생하는 것을 확인해 보자. (try-catch 문이 없을 때 예외 로깅없이 취소 동작이 수행된다. 이것이 CancellationException을 다른 예외와 다르게 무시하고 있다는 의미다.)

실행 결과는 다음과 같다.

Start
Cancel
Caught CancellationException

CoroutineExceptionHandler를 통해서 CancellationException을 처리해 보자.

실행 결과는 다음과 같다.

Start
Cancel

애초에 try-catch 문이 없는 상태에서도 CoroutineExceptionHandler에 의해서 무시되고 있었을 것이라는 것을 이제는 추측할 수 있다. 좀 더 명확히 컨텍스트에 CoroutineExceptionHandler를 넘겨주어 확인했지만 역시나 같은 결과이다.


예외 전파

CoroutineExceptionHandler를 코루틴의 컨텍스트로 넘겨주어 해당 코루틴 내에서 발생하는 모든 예외를 처리할 수 있다고 했다. 이로 인해 구조화된 동시성으로 인해 발생하는 예외 전파를 효율적으로 처리할 수 있다고 했다. 이때 예외는 CancellationException 이외의 예외를 의미한다. 구조화된 동시성에서 언급했던 내용에 대해 잠시 복습을 하자.

1) 부모 코루틴은 자식 코루틴이 종료될 때까지 기다려준다.
2) 부모 코루틴이 취소되면 자식 코루틴도 모두 취소된다.

이 두 가지 내용을 예외 발생의 관점에서 재해석해 보면 어떨까?


1) 부모 코루틴은 자식 코루틴이 종료될 때까지 기다려주기 때문에 자식 코루틴에서 예외가 발생하면 모든 자식 코루틴이 종료된 후에 예외를 처리한다.


2) 부모 코루틴이 취소되면 자식 코루틴도 취소되는 것은 취소 예외가 부모 코루틴에서 자식 코루틴으로 전파된 것이라고 할 수 있다.


이것을 다시 예외 관점으로 정리해 보자.


1) 자식 코루틴에서 예외가 발생하면 모든 자식 코루틴이 종료된 후에 부모 코루틴에서 예외를 처리한다.


2) 취소 예외가 부모 코루틴에서 자식 코루틴으로 전파된 것이라고 할 수 있다. 자식 코루틴의 예외를 부모 코루틴에서 처리한다고 하였으니 자식 코루틴의 예외도 부모 코루틴으로 전파된다고 추측할 수 있다.


이 두 가지 내용을 코드를 통해 확인해 보자.

실행 결과는 다음과 같다.

Hello
Coroutine 1
Coroutine 1 was cancelled
Coroutine 2 was cancelled
Caught in CoroutineExceptionHandler java.lang.NullPointerException
End

GlobalScope에서 코루틴이 시작된다. 내부에는 두 개의 자식 코루틴이 존재한다. 자식 코루틴은 Coroutine 1, Coroutine 2라고 부르겠다.


Coroutine 1에서 NullPointerException이 발생했다. 그러면 예외로 인해 해당 코루틴을 취소하여 종료한다. 그 후 바로 해당 예외가 처리하지 않고, Coroutine 2가 종료된 것을 볼 수 있다. Coroutine 2는 매우 긴 시간 동안 delay 된 상태인데 취소되었다. 구조화된 동시성으로 인해 부모 코루틴은 자식 코루틴이 종료될 때까지 기다린다고 한 것과 다른 양상처럼 보인다. 그러나 이것은 Coroutine 1의 예외 발생이 부모 코루틴으로 전파되어 부모 코루틴이 해당 예외를 처리하기 위해서 Coroutine 2를 취소하기 때문이다. 이렇게 모든 자식 코루틴이 종료될 때까지 기다린 후에 부모 코루틴에서 CoroutineExceptionHandler를 통해 예외 처리가 이뤄졌다.


정리하면 아래와 같다.

1) Coroutine 1이 NullPointerException을 던진다.
2) 부모 코루틴이 이 예외를 잡는다.
3) 부모 코루틴이 예외를 처리하기 위해 Coroutine 2를 취소한다.
4) Coroutine 2이 취소되어 finally 블록이 실행된다.
5) 예외가 처리된다.

여기서 하나 더 주목할 점은 예외 처리 후 부모 코루틴도 취소되었다는 것이다. Goodbye가 출력되지 않은 것으로 확인할 수 있다. CoroutineExceptionHandler은 예외 처리 후 코루틴을 계속 실행하지 않는다고 했다. 이것으로 알 수 있는 점이 있다. CoroutineExceptionHandler는 자식 코루틴에서 사용되지 않는다. 왜냐하면 자식 코루틴에서 발생한 예외도 부모 코루틴으로 전파되고 부모 코루틴에서 해당 예외를 처리하기 때문이다.


CoroutineExceptionHandler가 자식 코루틴에서 사용되지 않는다고 했는데 결과적으로 자식 코루틴의 예외를 처리하는 데 사용된 것이 모순처럼 보일 수 있다. 하지만 실제로 예외는 부모 코루틴으로 전파되었고 예외 처리 주체는 부모 코루틴이다. 부모 코루틴은 CoroutineExceptionHandler로 예외 처리 후 종료된다. 부모 코루틴이 종료되면 자식 코루틴도 종료된다. 이런 방식으로 자식 코루틴의 예외가 부모 코루틴으로 전파되고, 부모 코루틴에서 그에 대한 처리 결과인 코루틴 취소를 자식 코루틴으로 전파하기 때문에 모순으로부터 벗어날 수 있다.


요약하자.

- 취소 시 발생하는 CancellationException은 정상으로 간주하여 CoroutineExceptionHandler에서 무시한다.
- 구조화된 동시성에 의해서 부모 코루틴이 취소되면 자식 코루틴도 취소된다.
- GlobalScope는 특정 컴포넌트의 생명주기에 묶이지 않는 최상위 코루틴을 시작하는 데 사용되는 전역 CoroutineScope이다.
- GlobalScope 내 코루틴에서 별도의 예외 처리를 하지 않는다면 예외 발생 시 기본 예외 처리기가 이를 처리한다.
- CoroutineExceptionHandler는 코루틴의 전역 예외 처리를 위해 존재한다.
- 자식 코루틴에서 예외가 발생하면 모든 자식 코루틴이 종료된 후에 부모 코루틴에서 예외를 처리한다.
- 자식 코루틴의 예외는 부모 코루틴으로 전파된다.
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari