Skip to content

코루틴 예외 처리

이 섹션에서는 예외 처리 및 예외 발생 시 취소에 대해 다룹니다. 취소된 코루틴은 일시 중단 지점에서 CancellationException을 발생시키며, 코루틴 메커니즘에 의해 무시된다는 것을 이미 알고 있습니다. 여기서는 취소 중에 예외가 발생하거나, 동일한 코루틴의 여러 자식 코루틴이 예외를 발생시키는 경우에 어떤 일이 일어나는지 살펴봅니다.

예외 전파

코루틴 빌더에는 두 가지 유형이 있습니다: 예외를 자동으로 전파하는 유형(launch)과 사용자에게 노출하는 유형(async, produce)입니다. 이러한 빌더가 다른 코루틴의 _자식_이 아닌 루트 코루틴을 생성하는 데 사용될 때, 전자의 빌더는 Java의 Thread.uncaughtExceptionHandler와 유사하게 예외를 포착되지 않은 예외로 처리하는 반면, 후자는 예를 들어 await 또는 receive를 통해 사용자가 최종 예외를 소비하도록 합니다 (producereceive채널 섹션에서 다룹니다).

이는 GlobalScope를 사용하여 루트 코루틴을 생성하는 간단한 예제로 시연될 수 있습니다:

GlobalScope는 예상치 못한 방식으로 문제가 발생할 수 있는 섬세한 API입니다. 애플리케이션 전체를 위한 루트 코루틴을 생성하는 것은 GlobalScope의 몇 안 되는 정당한 사용 사례 중 하나이므로, @OptIn(DelicateCoroutinesApi::class)를 사용하여 GlobalScope 사용을 명시적으로 선택해야 합니다.

kotlin
import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val job = GlobalScope.launch { // root coroutine with launch
        println("Throwing exception from launch")
        throw IndexOutOfBoundsException() // Will be printed to the console by Thread.defaultUncaughtExceptionHandler
    }
    job.join()
    println("Joined failed job")
    val deferred = GlobalScope.async { // root coroutine with async
        println("Throwing exception from async")
        throw ArithmeticException() // Nothing is printed, relying on user to call await
    }
    try {
        deferred.await()
        println("Unreached")
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
}

전체 코드는 여기에서 확인할 수 있습니다.

이 코드의 출력은 (디버그 모드에서) 다음과 같습니다:

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

CoroutineExceptionHandler

콘솔에 포착되지 않은 예외를 출력하는 기본 동작을 사용자 정의할 수 있습니다. 루트 코루틴의 CoroutineExceptionHandler 컨텍스트 요소는 해당 루트 코루틴과 모든 자식 코루틴에 대한 일반적인 catch 블록으로 사용될 수 있으며, 여기서 사용자 정의 예외 처리가 이루어질 수 있습니다. 이는 Thread.uncaughtExceptionHandler와 유사합니다. CoroutineExceptionHandler에서 예외로부터 복구할 수 없습니다. 핸들러가 호출될 때 코루틴은 이미 해당 예외와 함께 완료된 상태입니다. 일반적으로 핸들러는 예외를 로깅하고, 오류 메시지를 표시하며, 애플리케이션을 종료하거나 다시 시작하는 데 사용됩니다.

CoroutineExceptionHandler포착되지 않은 예외, 즉 다른 어떤 방식으로도 처리되지 않은 예외에 대해서만 호출됩니다. 특히, 모든 자식 코루틴(다른 Job의 컨텍스트에서 생성된 코루틴)은 예외 처리를 부모 코루틴에게 위임하며, 부모 코루틴도 다시 부모에게 위임하는 식으로 루트 코루틴까지 이어집니다. 따라서 자식 코루틴의 컨텍스트에 설치된 CoroutineExceptionHandler는 결코 사용되지 않습니다. 게다가 async 빌더는 항상 모든 예외를 포착하고 그 결과를 Deferred 객체로 표현하므로, async 빌더의 CoroutineExceptionHandler 또한 아무런 효과가 없습니다.

감독 범위(supervision scope)에서 실행되는 코루틴은 예외를 부모에게 전파하지 않으며 이 규칙에서 제외됩니다. 이 문서의 감독 섹션에서 더 자세한 내용을 제공합니다.

kotlin
import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    val job = GlobalScope.launch(handler) { // root coroutine, running in GlobalScope
        throw AssertionError()
    }
    val deferred = GlobalScope.async(handler) { // also root, but async instead of launch
        throw ArithmeticException() // Nothing will be printed, relying on user to call deferred.await()
    }
    joinAll(job, deferred)
}

전체 코드는 여기에서 확인할 수 있습니다.

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

text
CoroutineExceptionHandler got java.lang.AssertionError

취소 및 예외

취소는 예외와 밀접하게 관련되어 있습니다. 코루틴은 내부적으로 CancellationException을 사용하여 취소를 처리하며, 이 예외는 모든 핸들러에 의해 무시됩니다. 따라서 CancellationException은 추가 디버그 정보의 소스로만 사용되어야 하며, 이 정보는 catch 블록을 통해 얻을 수 있습니다. 코루틴이 Job.cancel을 사용하여 취소되면 해당 코루틴은 종료되지만, 부모 코루틴은 취소되지 않습니다.

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
    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()
}

전체 코드는 여기에서 확인할 수 있습니다.

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

text
Cancelling child
Child is cancelled
Parent is not cancelled

코루틴이 CancellationException이 아닌 다른 예외를 만나면, 해당 예외와 함께 부모 코루틴을 취소합니다. 이 동작은 재정의할 수 없으며, 구조화된 동시성을 위한 안정적인 코루틴 계층을 제공하는 데 사용됩니다. 자식 코루틴에는 CoroutineExceptionHandler 구현이 사용되지 않습니다.

이 예제들에서 CoroutineExceptionHandler는 항상 GlobalScope에서 생성된 코루틴에 설치됩니다. 주요 runBlocking 범위에서 실행되는 코루틴에 예외 핸들러를 설치하는 것은 의미가 없습니다. 설치된 핸들러에도 불구하고 자식 코루틴이 예외와 함께 완료되면 주요 코루틴은 항상 취소되기 때문입니다.

원래 예외는 모든 자식 코루틴이 종료될 때만 부모에 의해 처리되며, 이는 다음 예제에서 시연됩니다.

kotlin
import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    val job = GlobalScope.launch(handler) {
        launch { // the first child
            try {
                delay(Long.MAX_VALUE)
            } finally {
                withContext(NonCancellable) {
                    println("Children are cancelled, but exception is not handled until all children terminate")
                    delay(100)
                    println("The first child finished its non cancellable block")
                }
            }
        }
        launch { // the second child
            delay(10)
            println("Second child throws an exception")
            throw ArithmeticException()
        }
    }
    job.join()
}

전체 코드는 여기에서 확인할 수 있습니다.

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

text
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
CoroutineExceptionHandler got java.lang.ArithmeticException

예외 집계

코루틴의 여러 자식 코루틴이 예외와 함께 실패할 경우, 일반적인 규칙은 "첫 번째 예외가 우선"이며, 따라서 첫 번째 예외가 처리됩니다. 첫 번째 예외 이후에 발생하는 모든 추가 예외는 첫 번째 예외에 억제된(suppressed) 예외로 첨부됩니다.

kotlin
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) // it gets cancelled when another sibling fails with IOException
            } finally {
                throw ArithmeticException() // the second exception
            }
        }
        launch {
            delay(100)
            throw IOException() // the first exception
        }
        delay(Long.MAX_VALUE)
    }
    job.join()  
}

전체 코드는 여기에서 확인할 수 있습니다.

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

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

이 메커니즘은 현재 Java 버전 1.7 이상에서만 작동합니다. JS 및 Native의 제약 사항은 일시적이며 향후 해제될 예정입니다.

취소 예외는 투명하며 기본적으로 래핑이 해제됩니다:

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

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val job = GlobalScope.launch(handler) {
        val innerJob = launch { // all this stack of coroutines will get cancelled
            launch {
                launch {
                    throw IOException() // the original exception
                }
            }
        }
        try {
            innerJob.join()
        } catch (e: CancellationException) {
            println("Rethrowing CancellationException with original cause")
            throw e // cancellation exception is rethrown, yet the original IOException gets to the handler  
        }
    }
    job.join()
}

전체 코드는 여기에서 확인할 수 있습니다.

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

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

감독 (Supervision)

이전에 학습했듯이, 취소는 코루틴 계층 전체에 전파되는 양방향 관계입니다. 단방향 취소가 필요한 경우를 살펴보겠습니다.

이러한 요구 사항의 좋은 예는 범위에 Job이 정의된 UI 컴포넌트입니다. UI의 자식 작업 중 하나라도 실패하더라도, 전체 UI 컴포넌트를 취소(실질적으로 종료)하는 것이 항상 필요한 것은 아닙니다. 그러나 UI 컴포넌트가 파괴되면(그리고 해당 Job이 취소되면), 모든 자식 Job도 결과가 더 이상 필요하지 않으므로 취소해야 합니다.

또 다른 예는 여러 자식 Job을 생성하고, 이들의 실행을 _감독_하며, 실패를 추적하고 실패한 Job만 다시 시작해야 하는 서버 프로세스입니다.

감독 Job

이러한 목적으로 SupervisorJob을 사용할 수 있습니다. 이는 취소가 아래쪽으로만 전파된다는 유일한 예외를 제외하고는 일반적인 Job과 유사합니다. 이는 다음 예제를 통해 쉽게 시연될 수 있습니다:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
    val supervisor = SupervisorJob()
    with(CoroutineScope(coroutineContext + supervisor)) {
        // launch the first child -- its exception is ignored for this example (don't do this in practice!)
        val firstChild = launch(CoroutineExceptionHandler { _, _ ->  }) {
            println("The first child is failing")
            throw AssertionError("The first child is cancelled")
        }
        // launch the second child
        val secondChild = launch {
            firstChild.join()
            // Cancellation of the first child is not propagated to the second child
            println("The first child is cancelled: ${firstChild.isCancelled}, but the second one is still active")
            try {
                delay(Long.MAX_VALUE)
            } finally {
                // But cancellation of the supervisor is propagated
                println("The second child is cancelled because the supervisor was cancelled")
            }
        }
        // wait until the first child fails & completes
        firstChild.join()
        println("Cancelling the supervisor")
        supervisor.cancel()
        secondChild.join()
    }
}

전체 코드는 여기에서 확인할 수 있습니다.

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

text
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

감독 범위 (Supervision Scope)

coroutineScope 대신 범위가 지정된 동시성을 위해 supervisorScope를 사용할 수 있습니다. 이는 취소를 한 방향으로만 전파하며, 자신에게 실패가 발생한 경우에만 모든 자식 코루틴을 취소합니다. 또한 coroutineScope와 마찬가지로 완료되기 전에 모든 자식 코루틴을 기다립니다.

kotlin
import kotlin.coroutines.*
import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        supervisorScope {
            val child = launch {
                try {
                    println("The child is sleeping")
                    delay(Long.MAX_VALUE)
                } finally {
                    println("The child is cancelled")
                }
            }
            // Give our child a chance to execute and print using yield 
            yield()
            println("Throwing an exception from the scope")
            throw AssertionError()
        }
    } catch(e: AssertionError) {
        println("Caught an assertion error")
    }
}

전체 코드는 여기에서 확인할 수 있습니다.

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

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

감독되는 코루틴의 예외

일반 Job과 감독 Job의 또 다른 중요한 차이점은 예외 처리입니다. 각 자식 코루틴은 예외 처리 메커니즘을 통해 스스로 예외를 처리해야 합니다. 이러한 차이점은 자식의 실패가 부모에게 전파되지 않는다는 사실에서 비롯됩니다. 이는 supervisorScope 내에서 직접 시작된 코루틴이 루트 코루틴과 동일한 방식으로 해당 범위에 설치된 CoroutineExceptionHandler사용한다는 것을 의미합니다 (자세한 내용은 CoroutineExceptionHandler 섹션을 참조하십시오).

kotlin
import kotlin.coroutines.*
import kotlinx.coroutines.*

fun main() = runBlocking {
    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")
}

전체 코드는 여기에서 확인할 수 있습니다.

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

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