코루틴 예외 처리

코루틴 예외 처리 (Coroutine exceptions handling)

이 섹션은 예외 처리와 예외로 인한 취소를 다뤄요. 우리는 이미 취소된 코루틴이 중단 지점에서 CancellationException을 던지고, 그 예외는 코루틴 메커니즘이 무시한다는 걸 알고 있어요. 여기서는 취소 중에 예외가 던져지거나, 같은 코루틴의 여러 자식이 예외를 던질 때 어떤 일이 벌어지는지 살펴볼게요.

출처: Kotlin 공식 문서

본문

예외 전파 (Exception propagation)

코루틴 빌더는 두 종류로 갈라져요. 예외를 자동으로 전파하는 것(launch)과 예외를 사용자에게 노출하는 것(asyncproduce)이에요. 이 빌더들로 루트 코루틴(다른 코루틴의 자식이 아닌 코루틴)을 만들면, 전자(launch 계열)는 예외를 잡히지 않은 예외(uncaught exception)로 취급해요. 자바의 Thread.uncaughtExceptionHandler와 비슷하게요. 반면 후자(async 계열)는 최종 예외를 사용자가 소비하도록 맡겨요. 예를 들어 awaitreceive로 소비하는 식이죠(producereceiveChannels 섹션에서 다룹니다).

이를 GlobalScope를 사용해 루트 코루틴을 만드는 간단한 예제로 확인할 수 있어요.

GlobalScope는 사소하지 않은 방식으로 문제를 일으킬 수 있는 민감한 API예요. 애플리케이션 전체에 대한 루트 코루틴 하나를 만드는 것은 GlobalScope의 드문 정당한 사용 사례 중 하나라서, @OptIn(DelicateCoroutinesApi::class)GlobalScope 사용을 명시적으로 옵트인해야 해요.

import kotlinx.coroutines.*

//sampleStart
@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")
    }
}
//sampleEnd

전체 코드는 여기에서 받을 수 있어요.

이 코드의 출력은(디버그 모드에서) 이래요.

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)에서 실행되는 코루틴은 예외를 부모에게 전파하지 않아서 이 규칙에서 제외돼요. 자세한 내용은 이 문서의 Supervision 섹션에서 다룰게요.

import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
//sampleStart
    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)
//sampleEnd    
}

전체 코드는 여기에서 받을 수 있어요.

이 코드의 출력은 이래요.

CoroutineExceptionHandler got java.lang.AssertionError

취소와 예외 (Cancellation and exceptions)

취소는 예외와 밀접하게 연관돼 있어요. 코루틴은 내부적으로 취소에 CancellationException을 사용하고, 이 예외는 모든 핸들러가 무시해요. 그래서 CancellationExceptioncatch 블록에서 얻을 수 있는 추가 디버그 정보의 소스로만 사용해야 해요. 코루틴이 Job.cancel로 취소되면 그 코루틴은 종료되지만, 부모는 취소되지 않아요.

import kotlinx.coroutines.*

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

전체 코드는 여기에서 받을 수 있어요.

이 코드의 출력은 이래요.

Cancelling child
Child is cancelled
Parent is not cancelled

코루틴이 CancellationException이 아닌 다른 예외를 만나면, 그 예외로 부모를 취소해요. 이 동작은 오버라이드할 수 없고, 구조적 동시성(structured concurrency)을 위한 안정적인 코루틴 계층을 제공하는 데 사용돼요. CoroutineExceptionHandler 구현은 자식 코루틴에는 사용되지 않아요.

이 예제들에서 CoroutineExceptionHandler는 항상 GlobalScope에서 만들어진 코루틴에 설치돼요. 메인 runBlocking의 스코프에서 시작된 코루틴에 예외 핸들러를 설치하는 건 의미가 없어요. 자식이 예외로 완료되면 메인 코루틴은 설치된 핸들러가 있든 없든 항상 취소될 테니까요.

원래 예외는 부모가 처리하는데, 처리는 모든 자식이 종료된 뒤에야 이뤄져요. 다음 예제가 이를 보여줘요.

import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
//sampleStart
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    val job = GlobalScope.launch(handler) {
        launch { // 첫 번째 자식
            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 { // 두 번째 자식
            delay(10)
            println("Second child throws an exception")
            throw ArithmeticException()
        }
    }
    job.join()
//sampleEnd 
}

전체 코드는 여기에서 받을 수 있어요.

이 코드의 출력은 이래요.

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

예외 집계 (Exceptions aggregation)

코루틴의 여러 자식이 예외로 실패할 때의 일반적인 규칙은 " 첫 번째 예외가 승리한다"예요. 그래서 첫 번째 예외가 처리돼요. 첫 번째 이후에 발생하는 추가 예외들은 모두 숨김(suppressed) 예외로 첫 번째 예외에 붙어요.

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의 제약은 일시적이며 나중에 해제될 예정이에요.

취소 예외는 투명하고 기본적으로 래핑이 풀려요(unwrapped):

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

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
//sampleStart
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val job = GlobalScope.launch(handler) {
        val innerJob = launch { // 이 코루틴 스택 전체가 취소될 거예요
            launch {
                launch {
                    throw IOException() // 원래 예외
                }
            }
        }
        try {
            innerJob.join()
        } catch (e: CancellationException) {
            println("Rethrowing CancellationException with original cause")
            throw e // 취소 예외는 다시 던지지만, 원래 IOException은 핸들러로 전달돼요  
        }
    }
    job.join()
//sampleEnd    
}

전체 코드는 여기에서 받을 수 있어요.

이 코드의 출력은 이래요.

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

슈퍼비전 (Supervision)

앞에서 공부했듯이 코루틴의 실패는 코루틴 계층 전체를 관통하는 양방향 관계예요. 이제 단방향 실패 전파가 필요할 때를 살펴볼게요.

이런 요구의 좋은 예는 스코프에 정의된 Job을 가진 UI 컴포넌트예요. UI의 자식 작업 중 하나가 실패했다고 해서 항상 UI 컴포넌트 전체를 취소(사실상 종료)할 필요는 없어요. 하지만 UI 컴포넌트가 파괴되면(그리고 그 Job이 취소되면) 자식 작업들의 결과가 더 이상 필요 없으므로 모든 자식 Job을 취소해야 해요.

또 다른 예는 여러 자식 Job을 생성하고 그 실행을 감독(supervise) 해야 하는 서버 프로세스예요. 실패를 추적하고 실패한 것만 재시작하는 식이죠.

슈퍼비전 Job (Supervision job)

SupervisorJob을 이런 목적으로 쓸 수 있어요. 일반 Job과 비슷하지만, 자식의 실패나 취소가 슈퍼바이저 Job이나 그 다른 자식에게 전파되지 않는다는 점만 달라요. 다음 예제로 쉽게 확인할 수 있어요.

import kotlinx.coroutines.*

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

전체 코드는 여기에서 받을 수 있어요.

이 코드의 출력은 이래요.

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처럼 완료 전에 모든 자식을 기다려요.

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

fun main() = runBlocking {
//sampleStart
    try {
        supervisorScope {
            val child = launch {
                try {
                    println("The child is sleeping")
                    delay(Long.MAX_VALUE)
                } finally {
                    println("The child is cancelled")
                }
            }
            // yield를 써서 자식에게 실행·출력할 기회를 줘요 
            yield()
            println("Throwing an exception from the scope")
            throw AssertionError()
        }
    } catch(e: AssertionError) {
        println("Caught an assertion error")
    }
//sampleEnd
}

전체 코드는 여기에서 받을 수 있어요.

이 코드의 출력은 이래요.

The child is sleeping
Throwing an exception from the scope
The child is cancelled
Caught an assertion error
슈퍼비전 코루틴에서의 예외

일반 Job과 슈퍼바이저 Job 사이의 또 다른 핵심 차이는 예외 처리예요. 슈퍼비전에서는 각 자식이 예외 처리 메커니즘으로 스스로 예외를 처리해야 해요. 이 차이는 자식의 실패가 부모에게 전파되지 않는다는 사실에서 비롯돼요. 즉 supervisorScope 안에서 직접 시작된 코루틴은 루트 코루틴과 같은 방식으로, 그 스코프에 설치된 CoroutineExceptionHandler사용해요(자세한 내용은 CoroutineExceptionHandler 섹션 참고).

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

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

전체 코드는 여기에서 받을 수 있어요.

이 코드의 출력은 이래요.

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

더 알아보기 (Learn more)