[Kotlin] Coroutine의 Exception Handling

조갱·2023년 6월 6일
2

Coroutine

목록 보기
9/9

Coroutine은 예외를 전파할 때, 자식 코루틴에서 예외가 발생하면 부모에게 전파하고, 부모는 그 에러를 다시 자식한테 전파한다.
이 개념을, 그림으로 먼저 알아보자.

에러 전파 방식

CoroutineBuilder가 자식이 아닌 Root Coroutine으로 생성되어 예외가 발생한다면 아래와 같은 차이를 가진다.

전파 (Propagate)

전파 방식은, 코루틴 빌더가 생성되면서 내부 로직에서 Exception이 발생하면, 즉시 상위로 에러를 전파한다.

노출 (Expose)

반면에 노출 방식은, await() 메소드가 실행되면 그 때 에러가 전파된다.

fun main() {
    val deferred = GlobalScope.async {
        throw Exception("Root - Async - Exception")
    }
    Thread.sleep(100)
    println("TASK FINISHED")
}
TASK FINISHED

Process finished with exit code 0

deferred를 await() 하기 전까지는 에러가 발생하지 않는다.

suspend fun main() {
    val deferred = GlobalScope.async {
        throw Exception("Root - Async - Exception")
    }
    Thread.sleep(100)
    deferred.await()
    println("TASK FINISHED")
}
Exception in thread "main" java.lang.Exception: Root - Async - Exception
	at ...

Process finished with exit code 1

Coroutine 에러 전파의 여러 Case

{Root} - {Child} 의 실행 방법에 따른 결과를 적어본다.

launch - launch

fun main() {
    val parentJob = GlobalScope.launch {
        val childJob = launch {
            throw Exception("launch - launch")
        }
    }
    Thread.sleep(100)
}
Exception in thread "DefaultDispatcher-worker-1" java.lang.Exception: launch - launch
	at ...

Process finished with exit code 0

launch - async

fun main() {
    val parentJob = GlobalScope.launch {
        val childJob = async {
            throw Exception("launch - async")
        }
    }
    Thread.sleep(100)
}
Exception in thread "DefaultDispatcher-worker-1" java.lang.Exception: launch - async
	at ...

Process finished with exit code 0

async - launch

fun main() {
    val parentJob = GlobalScope.async {
        val childJob = launch {
            throw Exception("async - launch")
        }
    }
    Thread.sleep(100)
}
Process finished with exit code 0

async - async

fun main() {
    val parentJob = GlobalScope.async {
        val childJob = async {
            throw Exception("async - async")
        }
    }
    Thread.sleep(100)
}
Process finished with exit code 0

정리

launch - launch : 예외가 발생함
launch - async : 예외가 발생함
async - launch : 예외 발생 X
async - async : 예외 발생 X

위에서,
CoroutineBuilder가 자식이 아닌 Root Coroutine으로 생성되어 예외가 발생한다면 아래와 같은 차이를 가진다
라고 소개를 했던것과 같이,
launch가 root coroutine일 때는 예외가 발생했고
async가 root coroutine일 때는 예외가 발생하지 않았다.

예외 처리 방법

try-catch ?

일반적으로 프로그래밍에서 사용되는 예외 처리 기법이다.

CorotuineScope 자체를 try-catch로 감싸기

우선, Exception이 발생하는 childJob의 launch 자체의 에러를 try-catch 로 감싸보자.

suspend fun main() {
    val parentJob = coroutineScope {
        try {
            val childJob = launch {
                throw Exception("Outer Try-Catch")
            }
        } catch (ex: Exception) {
            println(ex.message)
        }
    }
}
Exception in thread "main" java.lang.Exception: Outer Try-Catch
	at ...

Process finished with exit code 1

에러가 발생하는 launch 블록을 try-catch로 감쌌는데도 에러가 발생한다.

그 이유는 위의 에러 전파 과정 gif에서도 볼 수 있다.
childJob 에서 에러가 발생하면, 그 에러는 부모(parentJob => coroutineScope)에 전파된다. 그래서 childJob을 try-catch 로 묶더라도 그 에러는 부모에서 발생하게 된다.
실제로 디버깅을 해보면, Outer Try-Catch Exception은 13번 째 줄에서 발생한다.

13번 째 줄의 parentJob 도 try-catch 로 묶어주면, 전체 예외 처리가 가능해진다.

suspend fun main() {
    val parentJob = try {
        coroutineScope {
            try {
                val childJob = launch {
                    throw Exception("Outer Try-Catch")
                }
            } catch (ex: Exception) {
                println(ex.message)
            }
        }
    } catch (ex: Exception) {
        println("2nd catch: ${ex.message}")
    }
}
Connected to the target VM, address: '127.0.0.1:60701', transport: 'socket'
2nd catch: Outer Try-Catch
Disconnected from the target VM, address: '127.0.0.1:60701', transport: 'socket'

Process finished with exit code 0

CoroutineScope 내부에서 일부만 try-catch로 감싸기

이번에는 childJob 내부에 Exception이 발생하는 부분만 try-catch를 적용해보자.

suspend fun main() {
    val parentJob = coroutineScope {
        val childJob = launch {
            try {
                throw Exception("Inner Try-Catch")
            } catch (ex: Exception) {
                println(ex.message)
            }
        }
    }
}
Inner Try-Catch

Process finished with exit code 0

launch 내부에서 예외가 처리 되었기 때문에 childJob에 에러가 발생하지 않고, (당연하게도) parentJob에 전파될 에러도 없다.

runCatching ?

runCatching은 try-catch를 조금 더 깔끔하고 효율적으로 사용할 수 있도록 kotlin에서 지원하는 기능이다.

위 예시에서 봤 듯, Coroutine의 예외를 try-catch로 처리하기에는 문제가 있지만, 그래도 직접 눈으로 확인해보자.

suspend fun main() {
    val parentJob = coroutineScope {
        runCatching {
            val childJob = launch { throw Exception("runCatching") }
        }.onFailure { e -> println("CATCHING: ${e.message}") }
         .onSuccess { println("SUCCESS") }
    }
}

runCatching에 둘러쌓인 launch { throw Exception("runCatching") } 블록을 에 대해
예외가 발생하면 -> println("CATCHING: ${e.message}")
예외가 발생하지 않으면 -> println("SUCCESS")
을 출력하는 동작을 .onFailure, .onSuccess 확장함수로 관리한다.

위 코드를 실행하기 이전에, runCatching의 동작 과정을 살펴보자.

runCatching 블록

@InlineOnly
@SinceKotlin("1.3")
public inline fun <T, R> T.runCatching(block: T.() -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

onSuccess 확장 함수

@InlineOnly
@SinceKotlin("1.3")
public inline fun <T> Result<T>.onSuccess(action: (value: T) -> Unit): Result<T> {
    contract {
        callsInPlace(action, InvocationKind.AT_MOST_ONCE)
    }
    if (isSuccess) action(value as T)
    return this
}

onFailure 확장 함수

@InlineOnly
@SinceKotlin("1.3")
public inline fun <T> Result<T>.onFailure(action: (exception: Throwable) -> Unit): Result<T> {
    contract {
        callsInPlace(action, InvocationKind.AT_MOST_ONCE)
    }
    exceptionOrNull()?.let { action(it) }
    return this
}

위 코드를 통해 알 수 있듯, runCatching 은 lambda 로 작성된 코드를 단순히 try-catch 를 수행한 다음, Result 객체에 success, failure 상태를 담아 반환한다.
이후에 onSuccess, onFailure 확장함수가 Result 객체의 상태를 보고 상태에 맞는 동작을 수행한다.

SUCCESS
Exception in thread "main" java.lang.Exception: Inner Try-Catch
	at ...

Process finished with exit code 1

결과를 살펴보니 .onSuccess 확장함수에서 SUCCESS 가 출력되고, Exception이 발생했다.
예상하겠지만, 4번째 줄에 childJob 자체는 정상적으로 수행되었고
여기에서 발생한 Exception은 2번째 줄의 parentJob에 전파되어 그곳에서 예외가 발생했다.

따라서, 최종적으로 Exception이 발생하는 부모 coroutineScope 까지 runCatching으로 감싸준다면, 예외 없이 정상 동작한다.

suspend fun main() {
    val parentJob = runCatching {
        coroutineScope {
            val childJob = launch { throw Exception("runCatching") }
        }
    }.onFailure { e -> println("CATCHING: ${e.message}") }
     .onSuccess { println("SUCCESS") }
}
CATCHING: runCatching

Process finished with exit code 0

CoroutineExcpetionHandler

Coroutine에서 Exception이 발생할 경우 수행할 작업을 정의하여, 예외를 공통(일관)되게 처리할 수 있다.

우선, ExceptionHandler가 없는 CoroutineScope를 확인하자.

suspend fun main() {
    CoroutineScope(Dispatchers.Default).launch {
        launch {
            delay(300)
            throw Exception("first coroutine Failed")
        }
        launch {
            delay(400)
            println("second coroutine succeed")
        }
    }.join()
}
Exception in thread "DefaultDispatcher-worker-2" java.lang.Exception: first coroutine Failed
	at ...

Process finished with exit code 0

당연하게도 예외가 발생한다.

이제, ExceptionHandler를 적용해보자.

val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
    println("Caught: ${throwable.message}")
}

suspend fun main() {
    CoroutineScope(Dispatchers.Default + coroutineExceptionHandler).launch {
        launch {
            delay(300)
            throw Exception("first coroutine Failed")
        }
        launch {
            delay(400)
            println("second coroutine succeed")
        }
    }.join()
}

참고로, CoroutineContext는 다른 CoroutineContext에 대해 + 연산이 오버라이드 되어있어 여러개의 CoroutineContext를 합쳐서 사용할 수 있다.

public operator fun plus(context: CoroutineContext) { ... }

위 예제 코드의 실행 결과는 아래와 같다.

Caught: first coroutine Failed

Process finished with exit code 0

예외가 발생하지는 않았지만, 기대와는 다르게 second coroutine succeed는 출력되지 않는다.

왜냐하면, CoroutineScope를 사용했기 때문인데
CoroutineScope는 자식 코루틴이 실패하면, 예외처리를 했든 안했든 상관 없이 모든 자식 코루틴을 취소시킨다.

즉, 자식 중 하나의 코루틴이라도 실패하게 된다면 전체 코루틴을 취소시키기 때문에 second coroutine succeed 는 실행되지 않는다.

하나의 자식 코루틴의 실패가, 다른 코루틴에게도 전파되지 않게 하려면 SupervisorJob을 사용하면 된다.

SupervisorJob

위 예시들은 다른 코루틴 자식들에게도 취소가 전파된다.
다른 코루틴에 취소를 전파하지 않고 Exception이 발생한 Coroutine만 취소하고, 나머지 Coroutine들은 정상적으로 동작시키기 위해서는 SupervisorJob, SupervisorScope 를 사용할 수 있다.

그 중에, SupervisorJob은 에러 전파 방향을 위쪽으로 전파하는 것을 막는다.
Child Job #2에 SupervisorJob을 적용하고, Child Job #5에서 Exception이 발생한다면, 동작하는 이미지는 아래와 같다.

@Suppress("FunctionName")
public fun SupervisorJob(parent: Job? = null) : CompletableJob = SupervisorJobImpl(parent)
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
    override fun childCancelled(cause: Throwable): Boolean = false
}
private fun cancelParent(cause: Throwable): Boolean {
    ...

    // Notify parent but don't forget to check cancellation
    return parent.childCancelled(cause) || isCancellation
}

사실 SupervisorJob의 구조는 굉장히 단순한데, 위의 정의와 같이 childCancelled 를 false 로 override 함으로써
1. 나에게 Exception이 발생할 때
2. 부모에게 취소를 전파하지 않게 하여
3. 부모/형제 코루틴이 취소되지 않게 한다.

위 동작 과정을 예시로 들면
1. Child Job #5 에서 발생한 예외는 SupervisorJob이 설정되어있지 않아서
2. Parent인 Child Job #2에 예외가 전파되었고
3. #5의 형제, #2의 자식인 Child Job #6까지는 예외가 전파되지만
4. Child Job #2에 SupervisorJob 이 설정되어있기 때문에
5. #2의 부모인 Parent Job #0 까지 예외가 전파되지 않으며
6. #2의 형제, #0의 자식인 Child Job #1에는 예외가 전파되지 않는다.

실제 코드와 동작과정으로 살펴보면,

suspend fun main() {
    // Child Job #2
    CoroutineScope(Dispatchers.Default + coroutineExceptionHandler + SupervisorJob()).launch {
        // Child Job #5
        launch {
            delay(300)
            throw Exception("first coroutine Failed")
        }.join()
        // Child Job #6
        launch {
            delay(400)
            println("second coroutine succeed")
        }.join()
    }.join()
}
Caught: first coroutine Failed

Process finished with exit code 0

#5에서 발생한 예외는 #2까지 전파됐고, 그 자식인 #6까지도 전파되지만
#2보다 상위로는 전파되지 않는다.

suspend fun main() {
    // Child Job #2
    CoroutineScope(Dispatchers.Default + coroutineExceptionHandler).launch {
        // Child Job #5
        launch(SupervisorJob()) {
            delay(300)
            throw Exception("first coroutine Failed")
        }.join()
        // Child Job #6
        launch(SupervisorJob()) {
            delay(400)
            println("second coroutine succeed")
        }.join()
    }.join()
}
Caught: first coroutine Failed
second coroutine succeed

Process finished with exit code 0

#5 에서 발생한 예외는 그보다 상위인 #2까지 전파되지 않고,
#6도 정상적으로 실행된다.

SupervisorScope

SupervisorJob을 사용하면 각 코루틴에 적용할 수는 있지만,
매번 적용하지 않고 한번에 블록으로 처리하기 위해서는 SupervisorScope를 사용할 수 있다.

public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = SupervisorCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}
private class SupervisorCoroutine<in T>(
    context: CoroutineContext,
    uCont: Continuation<T>
) : ScopeCoroutine<T>(context, uCont) {
    override fun childCancelled(cause: Throwable): Boolean = false
}

supervisorScope에 둘러쌓인 각각의 coroutineScope은
각각에 SupervisorJob을 적용한 것과 같다.

실제 코드로 확인해보면,

suspend fun main() {
    supervisorScope {
        // Child Job #2
        CoroutineScope(Dispatchers.Default + coroutineExceptionHandler).launch {
            // Child Job #5
            launch {
                delay(300)
                throw Exception("first coroutine Failed")
            }.join()
            // Child Job #6
            launch {
                delay(400)
                println("second coroutine succeed")
            }.join()
        }.join()
    }
}
Caught: first coroutine Failed

Process finished with exit code 0

위 예제는 #2에 SupervisorJob 이 적용된 것과 같다.

suspend fun main() {
    // Child Job #2
    CoroutineScope(Dispatchers.Default + coroutineExceptionHandler).launch {
        supervisorScope {
            // Child Job #5
            launch {
                delay(300)
                throw Exception("first coroutine Failed")
            }.join()
            // Child Job #6
            launch {
                delay(400)
                println("second coroutine succeed")
            }.join()
        }
    }.join()
}
Caught: first coroutine Failed
second coroutine succeed

Process finished with exit code 0

위 예제는 #5, #6 에 SupervisorJob이 적용된 것과 같다.

Reference
https://www.youtube.com/watch?v=VWlwkqmTLHc
https://kotlinlang.org/docs/exception-handling.html

profile
A fast learner.

0개의 댓글