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() 하기 전까지는 에러가 발생하지 않는다.
따라서, async는 아래와 같이 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
await호출을 아래와 같이 try/catch로 감싸면 예외가 발생하지 않고 정상적으로 동작한다.
suspend fun main() {
val deferred = GlobalScope.async {
throw Exception("Root - Async - Exception")
}
Thread.sleep(100)
try{
deferred.await()
}catch(e: Exception){
//Handle exception thrown in async
}
println("TASK FINISHED")
}
TASK FINISHED
Process finished with exit code 1
하지만, 다른 코루틴에서 생성된 예외는 빌더에 관계 없이 항상 전파됩니다. (부모-자식 관계)
예를들어
val scope = CoroutineScope(Job())
scope.launch {
async {
// If async throws, launch throws without calling .await()
}
}
이 경우 async에서 예외가 발생하면, 직접적인 부모 코루틴이 launch인 경우 즉시 throw됩니다.
이유는 async가 포함된 CoroutineContext에서 Job(Deferred)이 예외를 자동으로 부모 코루틴(launch)에게 전파하기 때문이다.
따라서 launch에서 예외가 throw되게 된다.
예외 처리 방법
코루틴에서 예외 처리 방법은 다양하다. 하나 하나 살펴보도록 하자.
1. 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 로 묶더라도 그 에러는 부모에서 발생하게 된다.
📌 정확히는 Exception 자체가 전파되는 것이 아니라,
Result 객체에 Exception 정보를 담아 부모 코루틴에 취소를 전파한다.
이후, 부모 코루틴은 자식에게 받은 Result가 success 인지, failure 인지 확인하여
failure 이면, 내부에 있는 Exception을 꺼내서 발생시킨다.
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에 전파될 에러도 없다.
⚠️ 주의점
하지만, CoroutineScope 빌더나 다른 코루틴에서 생성된 코루틴에서 발생하는 예외는 try/catch에 잡히지 않는다.
이게 무슨말일까?
예시 코드를 통해 알아보도록하자.
suspend fun main() {
try {
CoroutineScope(Dispatchers.Default).launch {
launch {
delay(1000L)
throw Exception("Launch 내부에서 예외 발생")
}
}.join()
} catch (e: Exception) {
println("예외를 잡았습니다: ${e.message}")
}
}
Exception in thread "DefaultDispatcher-worker-2" java.lang.Exception: Launch 내부에서 예외 발생
at coroutine.Example38Kt$main$2$1.invokeSuspend(example38.kt:10)
..
위 코드를 실행하면 try-catch를 했음에도 불구하고 예외처리가 되지 않고, 예외를 throw하게 된다.
결과적으로 말하면, 예외가 잡히지 않는 이유는 CoroutineScope.launch와 try/catch의 실행순서 때문이다.
CoroutineScope.launch는 비동기적 실행이므로, 내부의 launch에서 예외가 발생할 때, 이미 외부의 try/catch가 블록을 실행을 종료한 상태이다.
그래서 try/catch는자식 코루틴에서 발생한 예외를 감지할 수 없고, 예외가 그대로 발생한다.
이를 해결하기 위해서는 자식 코루틴 launch 내부에 try/catch를 선언해서 자식 코루틴의 예외를 처리하거나, CoroutineExceptionHandler를 CoroutineScope를 생성할 때 Context에 함께 정의해줘야한다.
2. 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 확장함수로 관리한다.
SUCCESS
Exception in thread "main" java.lang.Exception: Inner Try-Catch
at ...
Process finished with exit code 1
결과를 살펴보니 .onSuccess 확장함수에서 SUCCESS 가 출력되고, Exception이 발생했다.
예상하겠지만,childJob 자체는 정상적으로 수행되었고
여기에서 발생한 Exception은 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
3. 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는 출력되지 않는다.
왜냐하면, CoroutineExceptionHandler는 예외 전파를 제한하지 않기 때문이다.
CoroutineExceptionHandler는 try-catch문처럼 동작하지 않는다. 예외가 마지막으로 처리되는 위치에서 예외를 처리할 뿐, 예외 전파를 제한하지 않는다.
즉, 자식 코루틴이 실패하면, CoroutineExceptionHandler를 통해 예외처리를 했든 안했든 상관 없이 모든 자식 코루틴을 취소시킨다. ( CancellationException 예외)
그렇기 때문에, 자식 중 하나의 코루틴이라도 실패하게 된다면 전체 코루틴을 취소시키기 때문에 위에 코드에서는 second coroutine succeed 는 실행되지 않는 것이다.
하나의 자식 코루틴의 실패가, 다른 코루틴에게도 전파되지 않게 하려면 SupervisorJob을 사용하면 된다.
⚠️ 주의점
아래와 같이 핸들러가 내부 코루틴에 설치된 경우에는 catch되지 않는다.
fun main() = runBlocking<Unit> {
val handler = CoroutineExceptionHandler { context, exception ->
println("Caught $exception")
}
CoroutineScope(Dispatchers.Default).launch {
launch(CoroutineName("Parent Coroutine") + handler) {
launch {
throw Exception("Failed coroutine")
}
}
}.join()
delay(1000L)
}
Exception in thread "DefaultDispatcher-worker-3" java.lang.Exception: Failed coroutine
..
Process finished with exit code 0
내부 launch는 예외가 발생하자마자 부모에게 즉시 예외를 전파한다.
이로 인해,, 내부 코루틴에 전달된 핸들러는 무시된다.
또한, 루트 코루틴에 예외 처리용 핸들러를 전달하지 않았기 때문에, 결과적으로 예외가 처리되지 않고 Exception이 throw된다.
특정 코루틴만 예외처리를 하고 싶다면 어떻게 하면될까?
아래와 같이 구조적 동시성을 깬다면, 예외 throw없이 정상적으로 예외처리가 된다.
fun main() = runBlocking<Unit> {
val handler = CoroutineExceptionHandler { context, exception ->
println("Caught $exception")
}
CoroutineScope(Dispatchers.Default).launch {
launch(Job() + CoroutineName("Parent Coroutine") + handler) {
launch {
throw Exception("Failed coroutine")
}
}
}.join()
delay(1000L)
}
Caught java.lang.Exception: Failed coroutine
Process finished with exit code 0
Job()을 호출하여 새로운 루트 Job을 생성하면, Job이 상위 컨텍스트와 독립적으로 동작하게 된다.
따라서, CoroutineExceptionHandler 객체를 해당 코루틴의 예외를 처리하는 위치로 설정할 수 있다.
4. SupervisorJob
위 예시들은 다른 코루틴 자식들에게도 취소가 전파된다.
다른 코루틴에 취소를 전파하지 않고 Exception이 발생한 Coroutine만 취소하고, 나머지 Coroutine들은 정상적으로 동작시키기 위해서는 SupervisorJob, SupervisorScope 를 사용할 수 있다.
SupervisorJob, SupervisorScope는 에러 전파 방향을 위쪽으로 전파하는 것을 막는다.
Child Job #2에 SupervisorJob을 적용하고, Child Job #5에서 Exception이 발생한다면, 동작하는 이미지는 아래와 같다.
사실 SupervisorJob의 구조는 굉장히 단순한데, 나에게 Exception이 발생할 때부모에게 취소를 전파하지 않게 하여 부모/형제 코루틴이 취소되지 않게 한다.
위 동작 과정을 예시로 들면
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 {
val supervisorJob = SupervisorJob(parent = this.coroutineContext[Job])
// 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()
supervisorJob.complete() // supervisorJob 완료 처리
}.join()
}
Caught: first coroutine Failed
second coroutine succeed
Process finished with exit code 0
#5 에서 발생한 예외는 그보다 상위인 #2까지 전파되지 않고,
#6도 정상적으로 실행된다.
5. SupervisorScope
SupervisorJob을 사용하면 각 코루틴에 독립적으로 적용할 수는 있지만,
매번 설정하는 번거로움을 줄이기 위해 supervisorScope를 사용할 수 있다.
supervisorScope는 내부적으로 SupervisorJob을 생성하고, 이를 사용해 해당 범위 내 모든 자식 코루틴에 적용한다.
그래서, SuperVisorScope 범위 내에서 자식 코루틴들이 실패해도 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을 적용한 것과 같다.
이는 각 Coroutine가 동일한 부모 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이 적용된 것과 같다.
참고자료
https://velog.io/@jkh9615/Kotlin-Coroutine%EC%9D%98-Exception-Handling
[Kotlin] Coroutine의 Exception Handling
Coroutine의 Exception Handling 방법은 일반적인 예외 처리 메커니즘과는 조금 다릅니다. 함께 확인해봐요!
velog.io
https://co-zi.medium.com/coroutine-%EC%97%90%EC%84%9C%EC%9D%98-error-handling-fb3a88dcd358
Coroutine 에서의 Error handling
코루틴에서의 예외처리를 한번 살펴봅니다.
co-zi.medium.com
https://ducorner.tistory.com/6
[KotlinConf 2019 시리즈] Part 3 : Exceptions in coroutines
이 글은 아래의 문서를 바탕으로 정리된 글임을 알려드립니다. 원문 : https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c Exceptions in coroutines Cancellation and Exceptions in Coroutines (Part 3) — Gotta ca
ducorner.tistory.com
'Android > Coroutine' 카테고리의 다른 글
[Android] 콜백을 코루틴(Coroutine)으로 바꿔보자(SuspendCoroutine/SuspendCancellableCoroutine) (2) | 2024.11.16 |
---|---|
[Android] 코루틴(Coroutine)에서의 작업 취소 (1) | 2024.11.15 |
[Android] 코루틴(Coroutine)의 구조적 동시성(Structured Concurrency) (0) | 2024.11.09 |
[Android] 코루틴(Coroutine) Builder에 대한 고찰 - Deep Dive (0) | 2024.11.09 |
[Android] 코루틴(Coroutine) Scope에 대한 고찰 - Deep Dive (0) | 2024.11.09 |