Kotlin/코틀린 코루틴의 정석

[Kotlin 코루틴] 예외 처리

꾸준함. 2024. 11. 22. 12:45

서론

코루틴의 비동기 작업은 IO 작업을 수행하는 데 쓰이는 경우가 많아 예측할 수 없는 예외가 발생할 가능성이 높으므로 코루틴에 대한 적절한 예외 처리는 안정적인 애플리케이션을 만드는 데 필수적입니다.

코루틴은 이를 위해 예외를 안전하게 처리할 수 있도록 만드는 여러 장치를 갖고 있으며 이번 게시글에서는 이에 대해 간단히 정리하겠습니다.

 

코루틴의 예외 전파

  • 코루틴 실행 도중 예외 발생 시 예외가 발생 코루틴은 취소되고 부모 코루틴으로 예외가 전파됨
    • 만약 부모 코루틴에서도 예외가 적절히 처리되지 않을 경우 부모 코루틴도 취소되고 예외는 다시 상위 코루틴으로 전파됨
    • 위 과정이 반복되면 결국 최상위 코루틴인 루트 코루틴까지 예외가 전파될 수 있음

 

  • 코루틴이 예외를 전파받아 취소되면 해당 코루틴만 취소되는 것이 아니라 코루틴의 특성에 따라 해당 코루틴의 하위에 있는 모든 코루틴에게 취소가 전파됨
  • 만약 예외가 적절히 처리되지 않아 루트 코루틴까지 예외가 전파되고 루트 코루틴이 취소되면 취소 전파 원칙에 따라 하위의 모든 코루틴에 취소가 전파됨

 

https://duongnt.com/launch-exception-handling/

 

 

부연 설명

  • runBlocking 코루틴은 자식 코루틴으로 Coroutine1 코루틴과 Coroutine2 코루틴을 가지며, Coroutine1 코루틴은 자식 코루틴으로 Coroutine3 코루틴을 가지는 구조
  • Coroutine3 코루틴에서 발생하는 예외는 처리되는 부분이 없기 때문에 Coroutine1 코루틴을 거쳐 runBlocking 함수를 통해 만들어지는 루트 코루틴까지 전파됨
    • 이에 따라 루트 코루틴이 취소되는데 그에 따라 하위에 있는 Coroutine2 코루틴에도 취소가 전파됨

 

예외 전파 제한

 

1. Job 객체를 사용한 예외 전파 제한

  • 코루틴의 예외 전파를 제한하기 위한 첫 번째 방법은 코루틴의 구조화를 깨는 것
  • 코루틴은 자신의 부모 코루틴으로만 예외를 전파하는 특성을 가지므로 부모 코루틴과의 구조화를 깬다면 예외가 전파되지 않음
    • 새로운 Job 객체를 만들어 구조화를 깨고 싶은 코루틴을 연결하면 부모 코루틴과의 구조화를 깰 수 있음

 

 

부연 설명

  • Coroutine1 코루틴은 Job()을 사용해 새로운 Job 객체를 부모 Job으로 설정함으로써 Parent Coroutine 코루틴과의 구조화를 깼음
    • 이에 따라 Coroutine3 코루틴이 예외를 발생시켜 부모 코루틴인 Coroutine1 코루틴으로 예외를 전파하더라도 Coroutine1 코루틴은 Parent Coroutine 코루틴으로 예외를 전파하지 않음
    • Parent Coroutine 코루틴에는 예외가 전파되지 않기 때문에 Coroutine2 코루틴은 정상적으로 실행됨

 

* 코루틴의 구조화를 깨면 예외 전파뿐만 아니라 취소 전파도 제한시킴

 

2. SupervisorJob 객체를 사용한 예외 전파 제한

  • 코루틴의 예외 전파를 제한하기 위한 두 번째 방법은 SupervisorJob 객체를 사용하는 것
    • SupervisorJob 객체는 자식 코루틴으로부터 예외를 전파받지 않는 특수한 Job 객체로 하나의 자식 코루틴에서 발생한 예외가 다른 자식 코루틴에게 영향을 미치지 못하도록 만드는 데 사용됨
    • 일반적인 Job 객체는 자식 코루틴에서 예외가 발생하면 예외를 전파받아 취소되지만 SupervisorJob 객체는 예외를 전파받지 않아 취소되지 않음


 

부연 설명

  • Coroutine1 코루틴과 Coroutine2 코루틴은 부모 Job으로 supervisorJob을 가짐
  • Coroutine3 코루틴에서 발생한 예외는 Coroutine1 코루틴으로 전파되어 Coroutine1 코루틴을 취소시키지만 Coroutine1 코루틴은 supervisorJob으로 예외를 전파하지 않음
  • 따라서 코드 실행 시 supervisorJob의 다른 자식 코루틴인 Coroutine2 코루틴이 정상 실행됨
  • this.coroutineContext[Job]을 사용해 runBlocking이 호출돼 만들어진 Job 객체를 가져오며 SupervisorJob 생성 함수의 인자로 해당 Job 객체를 넘기기 때문에 구조화를 깨지 않는 코드
  • SupervisorJob()을 통해 생성된 Job 객체는 Job()을 통해 생성된 Job 객체와 같이 자동으로 완료 처리되지 않기 때문에 명시적으로 supervisorJob.complete()을 실행해줘야 함

 

2.1 SupervisorJob을 사용할 때 흔히 하는 실수

 

 

부연 설명

  • launch 함수는 context 인자에 Job 객체가 입력될 경우 해당 Job 객체를 부모로 하는 새로운 Job 객체를 만듦
    • 따라서 launch 함수에 SupervisorJob()을 인자로 넘기면 SupervisorJob()을 통해 만들어지는 SupervisorJob 객체를 부모로 하는 새로운 Job 객체가 만들어짐
    • 이런 구조에서 Coroutine3 코루틴에 예외가 발생하면 Coroutine1 코루틴을 통해 Parent Coroutine 코루틴까지 전파돼 Parent Coroutine 코루틴이 취소되며, 동시에 Coroutine2 코루틴도 취소됨
    • Parent Coroutine 코루틴의 예외가 SupervisorJob 객체로 전파되지는 않지만 이는 아무런 역할을 수행하지 못함

 

2.2 supervisorScope를 사용한 예외 전파 제한

  • 코루틴의 예외 전파를 제한하기 위한 세 번째 방법은 SupervisorJob() 대안으로 supervisorScope 함수를 사용하는 것
    • supervisorScope 함수는 SupervisorJob 객체를 가진 CoroutineScope 객체를 생성
    • SupervisorJob 객체는 supervisorScope 함수를 호출한 코루틴의 Job 객체를 부모로 가짐

 

  • supervisorScope를 사용하면 복잡한 설정 없이도 구조화를 깨지 않고 예외 전파를 제한할 수 있으며 supervisorScope 내부에서 실행되는 코루틴은 SupervisorJob과 부모-자식 관계로 구조화되는데 supervisorScope의 SupervisorJob 객체는 코드가 모두 실행되고 자식 코루틴도 모두 실행 완료되면 자동으로 완료 처리됨

 

https://www.lukaslechner.com/why-exception-handling-with-kotlin-coroutines-is-so-hard-and-how-to-successfully-master-it/

 

 

부연 설명

  • runBlocking 함수에 의해 Job 객체가 생성되고 Job 객체는 자식 코루틴으로 supervisorScope 함수에 의해 생성된 SupervisorJob 객체를 가짐
  • SupervisorJob 객체는 다시 Coroutine1 코루틴의 Job과 Coroutine2 코루틴의 Job을 자식 코루틴으로 가지며, Coroutine1 코루틴은 Coroutine3 코루틴을 자식 코루틴으로 가짐
  • 만약 Coroutine3 코루틴에서 예외가 발생하면 해당 예외는 Coroutine1 코루틴까지만 전파되고 supervisorScope의 SupervisorJob 객체로는 전파되지 않음

 

CoroutineExceptionHandler를 사용한 예외 처리

  • 구조화된 코루틴들에 공통적인 예외 처리기를 설정해야 하는 케이스가 있음
  • 코루틴은 이를 위해 CoroutineContext 구성 요소로 CoroutineExceptionHandler라고 하는 예외 처리기를 지원함

 

1. CoroutineExceptionHandler 생성

  • CoroutineExceptionHandler 객체는 CoroutineExceptionHandler 함수를 통해 생성 가능
  • CoroutineExceptionHandler 함수는 예외를 처리하는 람다식인 handler를 매개변수로 가지며 handler는 CoroutineContext와 Throwable 타입의 매개변수를 갖는 람다시긍로 해당 람다식에 예외가 발생했을 때 어떤 동작을 할지 입력해 예외 처리 가능

 

CoroutineExceptionHandler

 

2. CoroutineExceptionHandler 사용

 

 

부연 설명

  • CoroutineScope 함수의 인자로 앞서 만든 exceptionHandler가 들어가며 이를 통해 생성된 CoroutineScope를 사용해 launch 함수가 호출돼 Coroutine1 코루틴이 실행됨
  • CoroutineScope 함수가 호출되면 기본적으로 Job 객체가 새로 생성되므로 runBlocking이 호출돼 생성되는 Job 객체와의 구조화가 깨지며, Coroutine1 코루틴은 CoroutineScope 객체로부터 CoroutineContext를 제공받아 exceptionHandler가 상속됨
  • 정리하면 exceptionHandler가 CoroutineScope에도 설정돼 있고 Coroutine1 코루틴에도 설정되어 있음
    • Coroutine1의 예외가 CoroutineScope에 전파되고 CoroutineScope에 exceptionHandler가 설정되어 있기 때문에 해당 exceptionHandler에서 예외를 처리한 것을 확인할 수 있음

 

3. 처리되지 않은 예외만 처리하는 CoroutineExceptionHandler

  • CoroutineExceptionHandler 객체는 처리되지 않은 예외만 처리함
  • 만약 자식 코루틴이 부모 코루틴으로 예외를 전파하면 자식 코루틴에서는 예외가 처리된 것으로 봐 자식 코루틴에 설정된 CoroutineExceptionHandler 객체는 동작하지 않음

 

 

부연 설명

  • Coroutine1 코루틴이 runBlocking 코루틴으로 예외를 전파했고 코루틴은 예외가 전파되며 예외를 처리한 것으로 간주
    • CoroutineExceptionHandler 객체는 이미 처리된 예외에 대해서는 동작하지 않음

 

  • 구조화된 코루틴상에 여러 CoroutineExceptionHandler 객체가 설정되어 있더라도 마지막으로 예외를 전파받는 위치 즉, 예외가 처리되는 위치에 설정된 CoroutineExceptionHandler 객체만 예외를 처리함
    • 따라서 CoroutineExceptionHandler 객체가 동작하도록 만들기 위해서는 CoroutineExceptionHandler 객체가 설정된 위치를 오류가 처리되는 위치로 만들어야 함

 

4. CoroutineExceptionHandler가 예외를 처리하도록 만들기

  • 앞서 언급했다시피 예외가 마지막으로 전파되는 위치에 CoroutineExceptionHandler 객체를 설정하면 예외 처리기가 동작하도록 만들 수 있음
  • CoroutineExceptionHandler 객체가 예외를 처리하게 하는 가장 간단한 방법은 CoroutineExceptionHandler 객체를 루트 Job과 함께 설정하는 것
    • Job()을 호출하면 새로운 루트 Job을 만들 수 있으므로 이를 사용하면 CoroutineExceptionHandler 객체가 설정되는 위치를 코루틴에서 오류가 처리되는 위치로 만들 수 있음

 

 

4.1 SupervisorJob과 CoroutineExceptionHandler 함께 사용하기

  • SupervisorJob은 자식 코루틴으로부터 예외를 전파받지 않지만 어떤 예외가 발생했는지에 대한 정보를 자식 코루틴으로부터 전달받음
  • 따라서 만약 SupervisorJob 객체와 CoroutineExceptionHandler 객체가 함께 설정되면 예외가 처리됨

 

 

부연 설명

  • supervisedScope는 SupervisorJob 객체와 CoroutineExceptionHandler 객체가 설정된 CoroutineScope 객체
  • CoroutineScope에서 코루틴이 실행되면 SupervisorJob 객체의 자식이 돼 예외가 발생하더라도 SupervisorJob 객체에는 전파되지 않지만 SupervisorJob은 예외에 대한 정보는 전달받으므로 supervisedScope에 설정된 CoroutineExceptionHandler 객체가 해당 오류를 처리하게 됨

 

5. CoroutineExceptionHandler는 예외 전파를 제한하지 않는다

  • CoroutineExceptionHandler는 예외가 마지막으로 처리되는 위치에서 예외를 처리할 뿐, try catch문과 달리 예외 전파를 제한하지 않음

 

try catch 문을 사용한 예외 처리

  • 코루틴에서 예외가 발생했을 때 코틀린에서 일반적으로 예외를 처리하는 방식과 같이 try catch문을 통해 예외를 처리할 수 있음

 

 

부연 설명

  • Coroutine1 코루틴에서 예외가 발생하지만 예외가 try catch문을 통해 처리되고 있기 때문에 부모 코루틴인 runBlocking 코루틴으로 예외가 전파되지 않음
  • 이 때문에 코드의 실행 결과를 보면 catch 문에서 예외가 처리돼 예외 메시지가 출력되는 것을 확인할 수 있고 runBlocking 코루틴으로 예외가 전파되지 않아 Coroutine2 코루틴도 정상적으로 실행된 것을 확인 가능

 

1. 코루틴 빌더 함수에 대한 try catch 문은 코루틴의 예외를 잡지 못한다

  • 코루틴에 대한 예외 처리를 위해서는 코루틴 빌더 함수의 람다식 내부에서 try catch 문을 사용해야 한다는 것을 명심하고, 코루틴 빌더 함수에 try catch 문을 사용하지 않도록 주의해야 함

 

 

부연 설명

  • launch는 코루틴을 생성하는 데 사용되는 함수일 뿐, 람다식의 실행은 생성된 코루틴이 CoroutineDispatcher에 의해 쓰레드로 분배되는 시점에 일어남 
    • 즉, 해당 try catch 문은 launch 코루틴 빌더 함수 자체의 실행만 체크하며, 람다식은 예외 처리 대상이 아님
    • 따라서 Coroutine1 코루틴을 만드는 launch 코루틴 빌더 함수를 try catch 문으로 감싸지만 해당 try catch 문은 Coroutine1 코루틴에서 발생하는 예외를 잡지 못함

 

async의 예외 처리

 

1. async의 예외 노출

  • async 코루틴 빌더 함수는 다른 코루틴 빌더 함수와 달리 결괏값을 Deferred 객체로 감싸고 await 호출 시점에 결괏값을 노출함
    • 이런 특성 때문에 코루틴 실행 도중 예외가 발생해 결괏값이 없다면 Deferred에 대한 await 호출 시 예외가 노출됨
    • 따라서 async 코루틴 빌더를 호출해 만들어진 코루틴에서 예외가 발생할 경우에는 await 호출부에서 예외 처리가 될 수 있도록 해야 함

 

 

2. async의 예외 전파

  • async 코루틴 빌더 함수도 예외가 발생하면 부모 코루틴으로 예외를 전파하므로 이를 적절히 처리해야 함
  • supervisorScope를 사용해 예외 전파를 제한시킴으로써 예외가 전파되지 않도록 만들 수 있음
  • async 코루틴 빌더를 사용할 때는 전파되는 예외와 await 호출 시 노출되는 예외를 모두 처리해 줘야 함

 

 

전파되지 않는 예외

 

1. 전파되지 않는 CancellationException

  • 코루틴은 CancellationException 예외가 발생해도 부모 코루틴으로 전파되지 않음

 

 

부연 설명

  • Coroutine2 코루틴은 CancellationException 예외를 발생시키고 부모 코루틴으로 Coroutine1 코루틴을 가지며, Coroutine1 코루틴은 runBlocking 코루틴을 부모로 가짐
  • Coroutine2 코루틴에서 발생하는 예외가 일반적인 예외가 아닌 CancellationException이 발생했으므로 Coroutine2 코루틴만 취소시키고 예외가 전파되지 않는 것을 확인 가능

 

2. 코루틴 취소 시 사용되는 JobCancellationException

  • CancellationException은 코루틴의 취소에 사용되는 특별한 예외
    • Job 객체에 대해 cancel 함수를 호출하면 CancellationException의 서브 클래스인 JobCancellationException을 발생시켜 코루틴을 취소시킴
    • CancellationException은 특정 코루틴만 취소되는 데 사용되며, 코루틴 코드 상에서 다양하게 응용돼 사용됨

 

 

3. withTimeOut 사용해 코루틴의 실행 시간 제한하기

  • 코루틴 라이브러리는 제한 시간을 두고 작업을 실행할 수 있도록 만드는 withTimeOut 함수를 제공
    • withTimeOut 함수는 매개변수로 실행 제한 시간을 밀리초 단위로 표현하는 timeMillis와 해당 시간 내에 실행돼야 할 작업인 block을 가짐
    • withTimeOut 함수는 작업이  주어진 시간 내에 완료되지 않으면 TimeoutCancellationException을 발생시키며 TimeoutCancellationException은 CancellationException의 서브 클래스
    • 따라서 TimeoutCancellationException이 발생하더라도 예외가 부모 코루틴으로 전파되지 않고 예외가 발생한 코루틴만 취소시킴

 

 

부연 설명

  • Child Coroutine 코루틴의 실행 시간을 1초로 제한시키고, 내부에서 2초의 시간이 걸리는 작업을 실행했으므로 TimeoutCancellationException이 발생
    • 이에 따라 Child Coroutine 코루틴을 취소시키지만 해당 예외는 Parent Coroutine 코루틴으로는 전파되지 않음
    • 따라서 Parent Coroutine 코루틴의 작업은 취소되지 않음

 

비고

 

1. withTimeOutOrNull

  • 실행 시간을 초과하더라도 코루틴이 취소되지 않고 결과가 반환돼야 하는 경우 withTimeOutOrNull 함수를 사용할 수 있음
    • withTimeOtOrNull 함수를 사용하면, 실생 시간을 초과하면 코루틴이 취소되지 않고 null이 반환됨

 

 

참고

코틀린 코루틴의 정석 (조세영 저)

반응형