코루틴에서 예외가 발생하면, 부모 코루틴으로 예외가 전파되고, 부모와 형제 코루틴이 모두 취소된다.

fun main(): Unit = runBlocking {
    // A
    launch {
        // A-A
        launch {
            throw Error("A-A의 예외")
        }

        // A-B
        launch {
            println("A-B")
        }

        // A-C
        launch {
            println("A-C")
        }
    }

    // B
    launch {
        println("B")
    }
}

B
Exception in thread "main" java.lang.Error: A-A 예외

A-A에서 발생한 예외가 전파되어, 모든 코루틴[A-B, A-C, B]이 취소된다. 다음 그림은 위 코드내 코루틴의 계층도를 나타낸다.

코루틴 예외

작업하나가 실패하더라도 다른 작업에 영향을 주지 않아야 하는 경우, 이를 처리하는 방법을 알아보자.

코루틴에서 발생한 오류를 전파시키지 않도록 하는 방법

1. SupervisorJob

SupervisorJob를 사용하는 코루틴은 다른 코루틴에게 영향을 주지 않는다.

  • SupervisorJob : 자식 코루틴들은 서로 독립적으로 동작하여, 해당 코루틴에서 발생한 예외가 부모와 형제 코루틴에게 영향을 주지 않는다.
  • Job : 부모와 자식 코루틴은 서로 연결되어 있어, 예외가 전파된다.
suspend fun main(): Unit = coroutineScope {
    // A
    launch {
        println("A")
    }

    // B
    launch {
        println("B")
    }

    // C
    val scope = CoroutineScope(SupervisorJob())
    scope.launch {
        try {
            println("C")
            throw Exception("C의 예외")
        } catch (e: Exception) {
            println(e)
        }
    }
}

A
B
C
java.lang.Exception: C 예외

C에서 예외가 발생하였으나, 다른 코루틴에게 영향을 주지 않았다.

코루틴 예외-supervisorjob

사용 방법

// 1. Scope를 만들 때 SupervisorJob을 컨텍스트로 포함시키기
val scope = CoroutineScope(SupervisorJob())
scope.launch {
    // ...
}

// 2. 기존 Scope에 SupervisorJob을 추가하기
launch(SupervisorJob()) {
    // ...
}

부적절한 사용 : withContext(SupervisorJob())

withContext(SupervisorJob())으로 사용하면, 의도와 달리 예외가 전파된다. 왜냐하면 withContext은 새로운 코루틴을 만드는 것이 아니라 기존 코루틴의 context를 임시적으로 변경하는 것이기 때문이다. 다시 말해, SupervisorJob으로 지정하여도 코루틴의 동작 방식을 바꿀 수 없기 떄문에 SupervisorJob의 특성이 적용되지 않는다.

suspend fun analyzeData(dataList: List<Data>) = 
    withContext(SupervisorJob()) {
        dataList.forEach { data ->
            launch {
                val result = analyze(data)
                notifyResult(result)
            }
        }
    }

launch내에서 예외가 발생하면 다른 코루틴이 모두 취소된다.

2. supervisorScope

supervisorScope내의 코루틴은 서로 독립적으로 동작하여, 예외가 발생해도 다른 코루틴에게 영향을 주지 않는다.

Scope내에서 생성되는 코루틴은 SupervisorJob으로 동작한다.

suspend fun main(): Unit = coroutineScope {
    // A
    launch {
        println("A")
    }

    // B
    launch {
        println("B")
    }

    // C
    supervisorScope {
        launch {
            try {
                println("C")
                throw Exception("C의 예외")
            } catch (e: Exception) {
                println(e)
            }
        }
    }
}

A
B
C
java.lang.Exception: C 예외

3. Exception클래스가 CancellationException를 상속받도록 하기

예시 : 값이 홀수일 때 예외를 발생시키는 코드

예외가 발생하면 부모, 자식 코루틴이 종료된다. 짝수일 때는 정상적으로 동작해야 하는데 취소되기 때문에 개선이 필요하다.

data class OddException(val value: Int) : Exception()

suspend fun main(): Unit = coroutineScope {
    repeat(100) {
        launch {
            if (it % 2 == 1) {
                throw OddException(it)
            }
            println(it)
        }
    }
}

0
2
4
Exception in thread "main" OddException(value=1)

OddExceptionCancellationException를 상속받도록 하면, 예외는 부모로 전파되지 않고, 예외가 발생한 코루틴만 취소된다.

data class OddException(val value: Int) : CancellationException()

0
2
4
6
8
10
12
14
...
98

코루틴에서 발생한 예외를 감지하는 방법

1. try-catch, 일반적인 방법

가장 기초적인 방법으로 try-catch를 사용하여 예외를 감지할 수 있다.

코루틴 빌더 내에서 try-catch를 사용하면 된다.

launch {
    try {
        throw Exception("예외 발생")
    } catch (e: Exception) {
        // try 내에서 발생한 예외에 대한 대응 로직을 작성
    }
}

그러나 try문 내에 자식 코루틴이 있는 경우, 자식 코루틴에서 발생한 예외는 try-catch로 감지할 수 없다.

try-catch로 예외를 감지할 수 없는 경우

try문 내에 자식 코루틴이 있을 때

다음 코드와 같이 내부적으로 또 다른 코루틴이 있다면 그 코루틴에서 발생하는 예외는 catch로 감지할 수 없다. 즉, 계층 구조가 있는 코루틴에서는 try-catch로 예외를 감지할 수 없다는 것이다.

try {
    launch {
        // 자식 코루틴 A
        throw Exception("코루틴 A 예외")
    }

    // 새로운 Scope에서 코루틴 B를 실행
    val scope = CoroutineScope(Dispatchers.Default)
    scope.launch {
        throw Exception("코루틴 B 예외")
    }
} catch (e: Exception) {
    // 호출되지 않음
    // 자식 코루틴의 예외를 감지할 수 없다.
}

이러한 경우에는 루트 코루틴에서 CoroutineExceptionHandler를 사용하면 자식 코루틴의 예외를 감지할 수 있다.

2. CoroutineExceptionHandler

Scope내의 코루틴에서 발생한 예외를 감지할 수 있다, 보통 launch 빌더와 함께 쓰인다

supervisorScope {
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("예외 감지: $throwable")
    }

    println("ROOT")

    launch(exceptionHandler) {
        println("job : ROOT-A")

        launch {
            println("job : ROOT-A-B")

            launch {
                println("job : ROOT-A-B-C")

                supervisorScope {
                    launch {
                        throw Exception("ROOT-A-B-C의 예외")
                    }
                }
            }
        }
    }

    launch(exceptionHandler) {
        println("job : ROOT-B")
        throw Exception("ROOT-B의 예외")
    }
}

ROOT
job : ROOT-A
job : ROOT-B
예외 감지: java.lang.Exception: ROOT-B 예외
job : ROOT-A-B
job : ROOT-A-B-C
예외 감지: java.lang.Exception: ROOT-A-B-C 예외

CoroutineExceptionHandler

주의 사항

예외에 대해 복구한 후 후속 작업을 해야한다면 부적합

예외가 CoroutineExceptionHandler에서 감지되었을 때, 해당 코루틴은 이미 실패 상태로 완료된 것이다. 따라서 CoroutineExceptionHandler에서 예외를 복구하고 다음 작업을 진행하는 것은 불가능하다.

  • try-catch를 사용하여 따로 처리
    • 예외에 대해서 대응하고 작업을 이어나가려면, 해당 코드를 try-catch 로 감싸서 예외를 직접 처리해야 한다.

async에서는 try-catch를 사용해야 한다.

다음과 같이 async에서 발생한 예외는 CoroutineExceptionHandler에서 감지되지 않는다.

suspend fun main(): Unit = supervisorScope {
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Handler 예외 감지: $throwable")
    }
    
    launch(exceptionHandler) {
        throw AssertionError()
    }

    val deferred = async(exceptionHandler) {
        throw ArithmeticException()
    }

    try {
        deferred.await()
    } catch (e: Exception) {
        println("catch 예외 감지: $e")
    }
}

Handler 예외 감지: java.lang.AssertionError
catch 예외 감지: java.lang.ArithmeticException

핸들러에서 감지되지 않는 이유는 async의 동작 방식의 특징 때문이다.

async는 결과를 Deferred 객체에 담아서 반환하는데, async에서 발생하는 예외는 이 Deferred객체에 저장되고, await() 호출 시에 예외에 대해서 처리가 가능하다. 따라서, async에서 발생한 예외는 CoroutineExceptionHandler에 도달하지 않으므로, await()를 호출하는 쪽에서 try-catch로 예외를 처리해야 한다.

delay(), yield() 등의 취소가능한 중단 함수는 취소될 때 CancellationException을 발생시킨다.

다음은 50MS 동안 대기한 후에 코루틴 B를 취소시키는 코드이다.

suspend fun main(): Unit = coroutineScope {
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("예외 감지: $throwable")
    }

    // A
    launch {
        println("A 시작, 200ms 대기")
        delay(200)
        println("200ms 대기 종료, A 완료")
    }

    // B
    val b = launch {
        try {
            delay(100)
        } catch (e: Exception) {
            println("catch에서 예외 처리: $e")
        }
        println("B 완료")
    }

    delay(50)
    b.cancel()
}

A 시작, 200ms 대기
catch에서 예외 처리: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@17036f98
B 완료
200ms 대기 종료, A 완료

위 코드에는 다음과 같은 문제가 있다.

  1. B가 취소되었음에도 불구하고 B 완료가 출력됨
  2. SupervisorJob, supervisorScope에서 처리되지 않음에도 B의 예외가 전파되지 않고 A가 정상적으로 동작함
문제의 원인 및 해결 방법

코루틴이 취소된다면 동작중인 delayCancellationException을 발생시키는데, catch에서 이 예외를 잡아서 대응을 하였기 때문에 A 완료가 출력된 것이며, 이 예외가 catch에서 잡히면서 부모로 전파되지 않아 A가 정상적으로 동작한 것이다.

이는 다음과 같이 정확하게 잡아야하는 예외를 감지하도록 하면 해결된다.

try {
    delay(100)
} catch (e: IllegalArgumentException) {
    println("catch에서 예외 처리: $e")
}
println("B 완료")

A 시작, 200ms 대기
200ms 대기 종료, A 완료

CancellationException를 잡지 않고 정상적으로 코루틴 취소 예외가 부모 코루틴으로 전파되어 A가 취소되며 B 완료가 출력되지 않는다.

CoroutineExceptionHandler에서 CancellationException는 감지하지 않는다.

CancellationException은 코루틴이 취소 될 때 작동하는 메커니즘의 일부이므로, 오류가 아니기 때문에 굳이 이 예외를 감지하여 처리할 필요가 없다.

3. runCatching

runCatching을 사용하여 좀 더 간결하게 예외를 감지할 수 있다.

예외가 발생하면 onFailure 함수에서 처리하며, onSuccess 함수에서는 정상적인 동작을 처리한다.

suspend fun main(): Unit = supervisorScope {
    println("ROOT")

    launch {
        println("job : ROOT-A")

        launch {
            println("job : ROOT-A-B")

            launch {
                println("job : ROOT-A-B-C")

                runCatching {
                    throw Exception("ROOT-A-B-C의 예외")
                }.onFailure { throwable ->
                    println("예외 감지: $throwable")
                }
            }

            delay(100L)
            println("job : ROOT-A-B")
        }
    }

    launch {
        println("job : ROOT-B")
        runCatching {
            throw Exception("ROOT-B의 예외")
        }.onFailure { throwable ->
            println("예외 감지: $throwable")
        }
    }
}

ROOT
job : ROOT-A
job : ROOT-A-B-C
job : ROOT-B
예외 감지: java.lang.Exception: ROOT-B 예외
예외 감지: java.lang.Exception: ROOT-A-B-C 예외
job : ROOT-A-B

이 함수는 try-catch와 유사한 역할을 하기 때문에, 자식 코루틴에서 발생하는 예외에 대해서는 감지하지 못한다.

다음 코드에서는 ROOT-A-B-C에서 발생한 예외를 runCatching으로 감지할 수 없으며, 예외가 전파되어 모든 코루틴이 종료된다.(CoroutineExceptionHandler를 사용하면 된다)

runCatching {
    launch {
        throw Exception("ROOT-A-B-C의 예외")
    }
}.onFailure { throwable ->
    println("예외 감지: $throwable")
}

사용 방법

runCatching {
    throw Exception("ROOT-A-B-C의 예외")
}.onFailure { throwable ->
    println("예외 감지: $throwable")
}.onSuccess {
    println("정상 동작")
}


// 함수 형태
inline fun <T, R> T.runCatching(block: T.() -> R): Result<R>
inline fun <R> runCatching(block: () -> R): Result<R>

위와 같이 onFailureonSuccess를 사용하면 되고, 반환 타입은 Result<R>이다.

정리

기본적으로 코루틴에서 발생하는 예외는 부모 코루틴으로 전파되며, 이는 연결된 모든 자식 코루틴들에 영향을 미친다. 따라서, 각 코루틴에서 예외를 어떻게 처리할지를 명확히 결정하는 것이 중요하다. try-catch 는 특정 코루틴 내에서 발생한 예외를 직접 처리하는 가장 기본적인 방법이다. 반면 SupervisorJobsupervisorScope는 코루틴 간 예외의 독립적 처리를 가능하게 하여, 하나의 코루틴 실패가 다른 코루틴에 영향을 미치지 않도록 한다.

CoroutineExceptionHandler는 감지되지 못한 예외를 감지할 수 있는데, 주로 로깅, 에러 메시지 표시, 프로그램의 재시작 등에 사용된다. 하지만, CoroutineExceptionHandler에서 예외를 복구하거나 코루틴의 실행을 계속하는 것은 불가능하다.

코루틴의 취소 메커니즘은 CancellationException을 통해 이루어되며, 코루틴의 취소는 프로그램의 정상적인 흐름의 일부로, 별도의 처리를 요구하지 않는다.