본문 바로가기
Kotlin

[Kotlin] Kotlin 공식 문서 번역 - 코루틴 예외 핸들링 (Coroutine exceptions handling)

by 노력남자 2023. 10. 2.
반응형

코루틴 예외 핸들링 (Coroutine exceptions handling)

 

이 섹션에서는 예외 처리와 예외가 발생할 때의 취소에 대해 다룹니다. 이미 취소된 코루틴이 중단 지점에서 CancellationException을 throw하고 코루틴 머신에서 무시된다는 것을 알고 있습니다. 여기에서는 취소 중에 예외가 발생하거나 동일한 코루틴의 여러 하위 코루틴이 예외를 throw하는 경우에 대해 살펴봅니다.


예외 전파


코루틴 빌더에는 예외를 자동으로 전파하는 방식(launch 및 actor)과 사용자에게 노출시키는 방식(async 및 produce) 두 가지 종류가 있습니다. 이러한 빌더를 사용하여 다른 코루틴의 하위가 아닌 루트 코루틴을 생성하는 경우, 전자의 빌더는 예외를 잡히지 않은 예외로 처리하며(자바의 Thread.uncaughtExceptionHandler와 유사), 후자는 사용자가 최종 예외를 소비하도록 의존합니다. 예를 들어 await나 receive를 통해 예외를 처리합니다(produce와 receive는 Channels 섹션에서 다룹니다).

전역 범위(GlobalScope)를 사용하여 루트 코루틴을 만드는 간단한 예제로 이를 보여줄 수 있습니다.

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val job = GlobalScope.launch { // launch로 루트 코루틴 생성
        println("Throwing exception from launch")
        throw IndexOutOfBoundsException() // Thread.defaultUncaughtExceptionHandler에 의해 콘솔에 출력됩니다.
    }
    job.join()
    println("Joined failed job")
    val deferred = GlobalScope.async { // async로 루트 코루틴 생성
        println("Throwing exception from async")
        throw ArithmeticException() // 아무것도 출력되지 않고, 사용자가 await를 호출하기를 기대합니다.
    }
    try {
        deferred.await()
        println("Unreached")
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
}


이 코드의 출력은 다음과 같습니다 (디버그 포함):

Throwing exception from launch
Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IndexOutOfBoundsException
Joined failed job
Throwing exception from async
Caught ArithmeticException


CoroutineExceptionHandler


콘솔에 출력되는 예외 메시지를 기본 동작을 사용자 정의할 수 있습니다. 루트 코루틴의 CoroutineExceptionHandler 컨텍스트 요소는 이 루트 코루틴 및 그 모든 하위 코루틴에 대한 일반적인 예외 처리 블록으로 사용될 수 있습니다. 이것은 Thread.uncaughtExceptionHandler와 유사합니다. CoroutineExceptionHandler에서 예외를 복구할 수는 없습니다. 핸들러가 호출될 때 이미 해당 예외와 함께 해당 코루틴이 완료되었습니다. 일반적으로 핸들러는 예외를 기록하거나 오류 메시지를 표시하거나 종료하고/또는 응용 프로그램을 다시 시작하는 데 사용됩니다.

CoroutineExceptionHandler는 잡히지 않은 예외에만 대해 호출됩니다. 특히 모든 하위 코루틴(다른 Job 컨텍스트에서 생성된 코루틴)은 그들의 부모 코루틴에게 예외 처리를 위임하므로 그들의 컨텍스트에 설치된 CoroutineExceptionHandler는 사용되지 않습니다. 또한 async 빌더는 항상 모든 예외를 catch하고 해당 예외를 결과 Deferred 객체에 표시하므로 해당 CoroutineExceptionHandler는 아무런 효과가 없습니다.

감독 범위에서 실행되는 코루틴은 예외를 부모에게 전파하지 않으며 이 규칙에서 제외됩니다. 이 문서의 후속 "감독" 섹션에서 자세한 내용을 찾을 수 있습니다.

val handler = CoroutineExceptionHandler { _, exception -> 
    println("CoroutineExceptionHandler got $exception") 
}
val job = GlobalScope.launch(handler) { // GlobalScope에서 실행되는 루트 코루틴
    throw AssertionError()
}
val deferred = GlobalScope.async(handler) { // 루트이지만 launch 대신 async
    throw ArithmeticException() // 아무것도 출력되지 않고, 사용자가 deferred.await()를 호출하기를 기대합니다.
}
joinAll(job, deferred)


이 코드의 출력은 다음과 같습니다:

CoroutineExceptionHandler got java.lang.AssertionError

 

취소와 예외


취소와 예외는 밀접한 관련이 있습니다. 코루틴은 내부적으로 취소에 대해 CancellationException을 사용하며, 이러한 예외는 모든 핸들러에서 무시되므로 디버그 정보의 원본으로 사용되어야 합니다. 캐치 블록을 통해 얻을 수 있습니다. 코루틴이 Job.cancel을 사용하여 취소되면 종료되지만 부모는 취소되지 않습니다.

val job = launch {
    val child = launch {
        try {
            delay(Long.MAX_VALUE)
        } finally {
            println("Child is cancelled")
        }
    }
    yield()
    println("Cancelling child")
    child.cancel()
    child.join()
    yield()
    println("Parent is not cancelled")
}
job.join()


이 코드의 출력은 다음과 같습니다:

Cancelling child
Child is cancelled
Parent is not cancelled


코루틴이 CancellationException 이외의 예외를 만나면 해당 예외로 부모를 취소합니다. 이 동작은 구조적 동시성을 위한 안정적인 코루틴 계층 구조를 제공하기 위해 재정의할 수 없으며 하위 코루틴의 CoroutineExceptionHandler 구현에는 영향을 주지 않습니다.

이러한 예제에서 CoroutineExceptionHandler는 항상 GlobalScope에서 생성된 코루틴에 설치됩니다. 주요 runBlocking의 범위에서 시작된 코루틴에 예외 처리기를 설치하는 것은 의미가 없습니다. 왜냐하면 주요 코루틴의 하위 코루틴이 예외로 완료될 때 항상 주요 코루틴이 취소되기 때문입니다.

원래의 예외는 모든 하위 코루틴이 종료될 때만 부모에 의해 처리됩니다. 이를 보여주는 다음 예제가 있습니다.

val handler = CoroutineExceptionHandler { _, exception -> 
    println("CoroutineExceptionHandler got $exception") 
}
val job = GlobalScope.launch(handler) {
    launch { // 첫 번째 하위 코루틴
        try {
            delay(Long.MAX_VALUE)
        } finally {
            withContext(NonCancellable) {
                println("하위 코루틴이 취소되었지만 모든 하위 코루틴이 종료될 때까지 예외 처리되지 않습니다.")
                delay(100)
                println("첫 번째 하위 코루틴이 비 취소 가능 블록을 완료했습니다.")
            }
        }
    }
    launch { // 두 번째 하위 코루틴
        delay(10)
        println("두 번째 하위 코루틴이 예외를 throw합니다")
        throw ArithmeticException()
    }
}
job.join()


이 코드의 출력은 다음과 같습니다:

두 번째 하위 코루틴이 예외를 throw합니다
하위 코루틴이 취소되었지만 모든 하위 코루틴이 종료될 때까지 예외 처리되지 않습니다.
첫 번째 하위 코루틴이 비 취소 가능 블록을 완료했습니다.
CoroutineExceptionHandler got java.lang.ArithmeticException


예외 집계


코루틴의 여러 하위 코루틴이 예외로 실패하면 일반적인 규칙은 "첫 번째 예외가 승리"이므로 첫 번째 예외가 처리됩니다. 첫 번째 예외가 발생한 후에 추가 예외는 모두 첫 번째 예외의 하위 예외로 첨부됩니다.

import kotlinx.coroutines.*
import java.io.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception with suppressed ${exception.suppressed.contentToString()}")
    }
    val job = GlobalScope.launch(handler) {
        launch {
            try {
                delay(Long.MAX_VALUE) // IOException이 발생하면 취소됩니다.
            } finally {
                throw ArithmeticException() // 두 번째 예외
            }
        }
        launch {
            delay(100)
            throw IOException() // 첫 번째 예외
        }
        delay(Long.MAX_VALUE)
    }
    job.join()  
}


이 코드의 출력은 다음과 같습니다:

CoroutineExceptionHandler got java.io.IOException with suppressed [java.lang.ArithmeticException]


참고: 이러한 메커니즘은 현재 Java 버전 1.7+에서만 작동합니다. JS 및 Native 제한은 임시적이며 미래에 해제될 예정입니다.

취소 예외는 투명하며 기본적으로 언랩됩니다.

val handler = CoroutineExceptionHandler { _, exception ->
    println("CoroutineExceptionHandler got $exception")
}
val job = GlobalScope.launch(handler) {
    val inner = launch { // 이 모든 코루틴 스택은 취소됩니다.
        launch {
            launch {
                throw IOException() // 원래의 예외
            }
        }
    }
    try {
        inner.join()
    } catch (e: CancellationException) {
        println("Rethrowing CancellationException with original cause")
        throw e // 취소 예외가 다시 throw되지만 원래의 IOException은 핸들러에 도달합니다
    }
}
job.join()


이 코드의 출력은 다음과 같습니다:

Rethrowing CancellationException with original cause
CoroutineExceptionHandler got java.io.IOException

 

감독


이전에 공부한 대로 취소는 코루틴의 전체 계층 구조를 통해 양방향으로 전파되는 관계입니다. 우리는 양방향 취소가 필요한 경우를 살펴보겠습니다.

이러한 요구 사항의 좋은 예는 작업이 범위 내에서 정의된 UI 구성 요소입니다. UI의 하위 작업 중 하나라도 실패한 경우 UI 구성 요소 전체를 취소 (효과적으로 종료)할 필요는 없지만 UI 구성 요소가 파괴되면 (그리고 해당 작업이 취소되면) 모든 하위 작업을 취소해야합니다. 그들의 결과는 더 이상 필요하지 않습니다.

또 다른 예는 여러 하위 작업을 생성하고 그 실행을 지도하며 실패를 추적하고 실패한 작업만 다시 시작해야하는 서버 프로세스입니다.


감독 작업


이러한 목적으로 SupervisorJob을 사용할 수 있습니다. 이것은 일반적인 Job과 유일한 예외인 취소가 아래로만 전파되는 점을 제외하고는 일반 Job과 유사합니다. 이것은 다음 예를 사용하여 쉽게 설명할 수 있습니다.

val supervisor = SupervisorJob()
with(CoroutineScope(coroutineContext + supervisor)) {
    // 첫 번째 하위 작업 시작 -- 이 예에서는 예외를 무시합니다 (실제로는 이렇게 하지 마십시오!)
    val firstChild = launch(CoroutineExceptionHandler { _, _ ->  }) {
        println("The first child is failing")
        throw AssertionError("The first child is cancelled")
    }
    // 두 번째 하위 작업 시작
    val secondChild = launch {
        firstChild.join()
        // 첫 번째 하위 작업의 취소는 두 번째 하위 작업에 전파되지 않습니다.
        println("The first child is cancelled: ${firstChild.isCancelled}, but the second one is still active")
        try {
            delay(Long.MAX_VALUE)
        } finally {
            // 그러나 감독자의 취소는 전파됩니다.
            println("The second child is cancelled because the supervisor was cancelled")
        }
    }
    // 첫 번째 하위 작업이 실패하고 완료될 때까지 대기
    firstChild.join()
    println("Cancelling the supervisor")
    supervisor.cancel()
    secondChild.join()
}


이 코드의 출력은 다음과 같습니다:

The first child is failing
The first child is cancelled: true, but the second one is still active
Cancelling the supervisor
The second child is cancelled because the supervisor was cancelled


감독 범위


coroutineScope 대신에 범위 병렬성에 supervisorScope를 사용할 수 있습니다. 이것은 취소를 한 방향으로만 전파하고 실패한 경우에만 모든 하위 작업을 취소합니다. 또한 coroutineScope가 하는 것과 마찬가지로 모든 하위 작업이 완료될 때까지 기다립니다.

try {
    supervisorScope {
        val child = launch {
            try {
                println("The child is sleeping")
                delay(Long.MAX_VALUE)
            } finally {
                println("The child is cancelled")
            }
        }
        // 자식에게 실행 및 출력할 기회를 제공합니다.
        yield()
        println("Throwing an exception from the scope")
        throw AssertionError()
    }
} catch(e: AssertionError) {
    println("Caught an assertion error")
}


이 코드의 출력은 다음과 같습니다:

The child is sleeping
Throwing an exception from the scope
The child is cancelled
Caught an assertion error


감독되는 코루틴에서의 예외


일반 Job과 감독 Job 간의 또 다른 중요한 차이점은 예외 처리입니다. 각

 하위 작업은 예외 처리 메커니즘을 통해 예외를 자체적으로 처리해야합니다. 이 차이점은 하위 작업의 실패가 부모로 전파되지 않는다는 사실에서 나옵니다. 즉, supervisorScope에서 직접 시작된 코루틴은 루트 코루틴이 하는 것과 마찬가지로 해당 범위에 설치된 CoroutineExceptionHandler를 사용합니다 (세부 정보는 CoroutineExceptionHandler 섹션 참조). 

val handler = CoroutineExceptionHandler { _, exception -> 
    println("CoroutineExceptionHandler got $exception") 
}
supervisorScope {
    val child = launch(handler) {
        println("The child throws an exception")
        throw AssertionError()
    }
    println("The scope is completing")
}
println("The scope is completed")


이 코드의 출력은 다음과 같습니다:

The scope is completing
The child throws an exception
CoroutineExceptionHandler got java.lang.AssertionError
The scope is completed

 

원문

 

https://kotlinlang.org/docs/exception-handling.html

반응형

댓글