Post

coroutine 알아보기 (4) - 예외 처리와 Job의 상태변화

coroutine 예외 처리와 Job의 상태변화

개요

root 코루틴과 자식 코루틴에서 예외처리를 어떻게 하는지 알아봅니다.

root 코루틴 생성

먼저 이전에 살펴보았던 예제를 봅니다.

1
2
3
4
5
6
7
8
9
10
11
fun main(): Unit = runBlocking {
    val job1 = launch {
        delay(1_000L)
        printWithThread("Job 1")
    }
    
    val job2 = launch {
        delay(1_000L)
        printWithThread("Job 2")
    }
}

위 코드는 코루틴이 총 3개입니다. runBlocking으로 만들어진 코루틴 안에 launch로 다시 2개의 코루틴이 만들어졌습니다.

이때, 최상위 코루틴이 root 코루틴이자 부모 코루틴이 되고, launch로 만들어진 2개의 코루틴은 자식코루틴이 됩니다.

  • 부모 코루틴(root) : runBlocking
  • 자식 코루틴 : launch, launch

만약 새로운 root 코루틴을 만들고 싶다면 launch를 사용해 코루틴을 만들때 새로운 영역에 만들면됩니다. 코루틴 영역이라는 의미가 있는 CoroutineScope 함수를 이용해 새로운 영역을 만들고, 이 영역에서 launch를 호출하면됩니다.

1
2
3
4
5
6
7
8
9
10
11
fun main(): Unit = runBlocking {
    val job1 = CoroutineScope(Dispatchers.Default).launch {
        delay(1_000L)
        printWithThread("Job 1")
    }

    val job2 = CoroutineScope(Dispatchers.Default).launch {
        delay(1_000L)
        printWithThread("Job 2")
    }
}

위 처럼 만들어 준다면 각각이 root 코루틴이 됩니다.

  • 부모 코루틴(root) : runBlocking, launch, launch

launch와 async의 예외처리

launch와 async의 예외처리를 각각 어떻게 처리하는지 설명합니다.

launch의 예외 동작

launch함수는 예외가 발생하자마자, 해당 예외를 출력하고 코루틴이 종료됩니다.

1
2
3
4
5
6
7
8
9
10
fun main(): Unit = runBlocking {

    // maint thread가 아닌 다른 thread에서 root 코루틴을 생성한다.
    val job = CoroutineScope(Dispatchers.Default).launch {
        throw IllegalArgumentException()
    }
    delay(1_000L)
}

// Exception in thread "DefaultDispatcher-worker-1" java.lang.IllegalArgumentException

async의 예외 동작

반면, async 함수는 예외가 발생하더라도 예외를 출력하지 않습니다.

1
2
3
4
5
6
7
8
9
// 예외가 발생되지않음
fun main(): Unit = runBlocking {

    // maint thread가 아닌 다른 thread에서 root 코루틴을 생성한다.
    val job = CoroutineScope(Dispatchers.Default).async {
        throw IllegalArgumentException()
    }
    delay(1_000L)
}

async 함수에서 발생한 예외를 확인하고 싶다면, await() 함수를 사용해야 합니다. async는 launch와 다르게 값을 반환하는 코루틴에 사용되기에, 예외 역시 값을 반환할 때 처리할 수 있도록 설계된 것입니다.

1
2
3
4
5
6
7
8
9
10
11
fun main(): Unit = runBlocking {

    // maint thread가 아닌 다른 thread에서 root 코루틴을 생성한다.
    val job = CoroutineScope(Dispatchers.Default).async {
        throw IllegalArgumentException()
    }
    delay(1_000L)
    job.await() // 여기서 exception을 잡을 수 있음.(main thread에서 잡을 것임)
}

// Exception in thread "main" java.lang.IllegalArgumentException

자식 코루틴의 예외는 전파된다

이번에는 새로운 영역에 root 코루틴을 만들지 않고, runBlocking 코루틴의 자식 코루틴의 자식 코루틴을 만들어봅니다.

1
2
3
4
5
6
7
8
fun main(): Unit = runBlocking {
    val job = async { // 자식 코루틴. (launch와 결과 동일)
        throw IllegalArgumentException()
    }
    delay(1_000L)
}

// Exception in thread "main" java.lang.IllegalArgumentException

위 코드를 실행해보면 async 키워드를 사용했음에도 불구하고 exception이 발생하는 것을 확인할 수 있습니다. 그 이유는 코루틴 안에서 발생한 예외가 부모 코루틴으로 전파되기 때문입니다.

즉, runBlocking안에 있는 async 코루틴에서 예외가 발생하면, 그 예외는 부모 코루틴으로 이동되고, 부모 코루틴도 취소하는 절차에 들어가게 됩니다. runBlocking 의 경우 예외가 발생하면 해당 예외를 출력하기에 async 의 예외를 받아 즉시 출력하는 것입니다.

SupervisorJob : 부모 코루틴에게 예외를 전파하지 않는 방법

당연하게도, 부모 코루틴에게 예외를 전파하지 않는 방법또한 존재합니다. 바로 SupervisorJob()를 사용하는 것입니다.

1
2
3
4
5
6
fun example6(): Unit = runBlocking {
    val job = async(SupervisorJob()) { // SupervisorJob: 자식 코루틴의 예외를 전파하지않음.
        throw IllegalArgumentException()
    }
    delay(1_000L)
}

async 함수에 SupervisorJob() 을 넣어주면 async 자식 코루틴에서 발생한 예외가 부모 코루틴으로 전파되지 않고, job.await() 을 해야 예외가 발생하는 원래 행동 패턴으로 돌아가게 됩니다.

다시 launch 로 돌아와 만약 코루틴에서 발생하는 예외를 핸들링 하고 싶다면 가장 간단한 방법은 try catch 구문을 사용하는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
fun main(): Unit = runBlocking {
    val job = launch() { // async로 변경해도 동일하다.
        try {
            throw IllegalArgumentException()
        } catch (e: IllegalArgumentException) {
            printWithThread("정상 종료")
        }
    }
}

// [main @coroutine#2] 정상 종료

try catch 구문을 활용하면, 발생한 예외를 잡아 코루틴이 취소되지 않게 만들 수도 있고, 적절한 처리를 한 이후 다시 예외를 던질 수도 있습니다.

CoroutineExceptionHandler

만약 try catch 대신, 예외가 발생한 이후 에러를 로깅하거나, 에러 메시지를 보내는 등의 공통된 로직을 처리하고 싶다면 CoroutineExceptionHandler라는 객체를 활용해볼 수 있습니다.

이 CoroutineExceptionHandler 객체는 코루틴의 구성 요소와 발생한 예외를 파라미터로 받을 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun main(): Unit = runBlocking {

    // CoroutineExceptionHandler : launch에만 적용 가능. 부모 코루틴이 있으면 동작하지않음
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        printWithThread("예외")
        throw throwable
    }


    val job = CoroutineScope(Dispatchers.Default).launch(exceptionHandler) {
        throw IllegalArgumentException()
    }

    delay(1_000L)
}

// [DefaultDispatcher-worker-2] 예외

다만, CoroutineExceptionHandler는 launch 에만 적용 가능하고, 부모 코루틴이 있으면 동작하지 않는다는 점을 주의해야 합니다.

취소와 예외의 차이

위에서 코루틴의 예외에 대해서 살펴보았습니다. 그런데 생각해 보면 취소도 CancellationException을 던지는 방식으로 동작했습니다. 코루틴 입장에서 취소와 예외의 차이점에 대해서 설명합니다.

코루틴은 코루틴 내부에서 발생한 예외에 대해 다음과 같이 처리하고 있습니다.

  1. 발생한 예외가 CancellationException 인 경우
    • 취소로 간주하고 부모 코루틴에게 전파하지 않음
  2. 다른 예외가 발생한 경우
    • 실패로 간주하고 부모 코루틴에게 전파함

그리고 코루틴은 예외가 발생하면, 해당 예외가 CancellationException 이건 다른 종류의 예외이건 내부적으로 “취소됨 상태”로 간주합니다.

image.png

만약 예외가 발생하지 않고, 정상적으로 처리가되었다면 아래의 흐름을 가집니다.

image.png

정리

  • 코루틴의 부모-자식 관계에서 예외는 기본적으로 부모 코루틴으로 전파된다.
  • launch는 예외 발생 시 즉시 출력하고 종료되지만, async는 await()을 호출해야 예외가 발생한다.
  • SupervisorJob을 사용하면 자식 코루틴의 예외가 부모에게 전파되지 않는다.
  • CoroutineExceptionHandler는 launch에서만 적용 가능하며, 부모 코루틴이 존재하면 동작하지 않는다.
  • 코루틴은 예외 발생 시 취소된 상태로 간주하며, CancellationException은 부모에게 전파되지 않고 단순 취소로 처리된다.

[출처]

  • https://kotlinlang.org/docs/coroutines-overview.html
This post is licensed under CC BY 4.0 by the author.