코루틴의 예외 처리 : try-catch, async의 예외 처리, 전파되지 않는 예외

홍성덕·2024년 11월 25일
0

Coroutines

목록 보기
14/14

1. try catch문을 사용한 코루틴 예외 처리

코루틴에서 예외가 발생했을 때 코틀린에서 일반적으로 예외를 처리하는 방식과 같이 try catch문을 통해 예외를 처리할 수 있다.

fun main() = runBlocking<Unit> {
  launch(CoroutineName("Coroutine1")) {
    try {
      throw Exception("Coroutine1에 예외가 발생했습니다")
    } catch (e: Exception) {
      println(e.message)
    }
  }
  launch(CoroutineName("Coroutine2")) {
    delay(100L)
    println("Coroutine2 실행 완료")
  }
}
/*
// 결과:
Coroutine1에 예외가 발생했습니다
Coroutine2 실행 완료
*/

이 코드에서는 Coroutine1에서 예외가 발생하지만 예외가 try catch문을 통해 처리되고 있기 때문에 부모 코루틴인 runBlocking 코루틴으로 예외가 전파되지 않는다.


try catch문 사용 시 많이 하는 실수는 try catch문을 코루틴 빌더 함수에 사용하는 것이다. 코루틴 빌더 함수에 try catch문을 사용하면 코루틴에서 발생한 예외가 잡히지 않는다.

fun main() = runBlocking<Unit> {
    val coroutineScope = CoroutineScope(Dispatchers.Default)
    try {
        coroutineScope.launch(CoroutineName("Coroutine1")) {
            throw Exception("Coroutine1에 예외가 발생했습니다")
        }
    } catch (e: Exception) {
        println(e.message)
    }
    coroutineScope.launch(CoroutineName("Coroutine2")) {
        delay(100L)
        println("Coroutine2 실행 완료")
    }
    delay(1000L)
}
/* 결과 :
Exception in thread "DefaultDispatcher-worker-1 @Coroutine1#2" java.lang.Exception: Coroutine1에 예외가 발생했습니다
	at Test3Kt$main$1$1.invokeSuspend(Test3.kt:7)
	...
*/

이 코드에서는 Coroutine1을 생성하는 launch 코루틴 빌더 함수를 try catch문으로 감싸지만 이 try catch문은 Coroutine1에서 발생하는 예외를 잡지 못한다.
launch는 코루틴을 생성하는 데 사용되는 함수일 뿐이고 람다식의 실행은 생성된 코루틴이 CoroutineDispatcher에 의해 스레드로 분배되는 시점에 일어나기 때문이다. 즉, 이 try catch문은 launch 코루틴 빌더 함수 자체의 실행만 체크하며 람다식은 예외 처리 대상이 아니다.


2. async의 예외 처리

async 코루틴 빌더는 다른 코루틴 빌더 함수와 달리 결과값을 Deferred 객체로 감싸고 await 호출 시점에 결과값을 노출한다. 이런 특성 때문에 코루틴 실행 도중 예외가 발생해 결과값이 없다면 Deferred에 대한 await 호출 시 예외가 노출된다.

fun main() = runBlocking<Unit> {  
    supervisorScope {  
        val deferred: Deferred<String> = async(CoroutineName("Coroutine1")) {  
            throw Exception("Coroutine1에 예외가 발생했습니다")  
        }  
        try {  
            deferred.await()  
        } catch (e: Exception) {  
            println("[노출된 예외] ${e.message}")  
        }  
    }  
}  
/*  
// 결과:  
[노출된 예외] Coroutine1에 예외가 발생했습니다  
*/

그래서 async 코루틴 빌더를 호출해 만들어진 코루틴에서 예외가 발생할 경우에는 await 호출부에서 예외 처리가 될 수 있도록 해야 한다.

만약 await()를 호출하지 않으면 어떻게 될까?

fun main() = runBlocking<Unit> {  
    supervisorScope {  
        val deferred: Deferred<String> = async(CoroutineName("Coroutine1")) {  
            throw Exception("Coroutine1에 예외가 발생했습니다")  
        }  
//        try {  
//            deferred.await()  
//        } catch (e: Exception) {  
//            println("[노출된 예외] ${e.message}")  
//        }  
    }  
}
/* 결과 :
(아무것도 출력되지 않고 그대로 프로세스 종료)
Process finished with exit code 0
*/

await() 호출 시 예외가 노출되는 것이기 때문에, await()를 호출하지 않으면 예외가 노출되지 않고 그대로 종료된다.


async 코루틴 빌더 함수도 예외가 발생하면 부모 코루틴으로 예외를 전파하기 때문에 이를 적절히 처리해야 한다. await 함수 호출부에서 예외가 노출되는 것일 뿐, 예외 전파는 이루어진다.

fun main() = runBlocking<Unit> {
  async(CoroutineName("Coroutine1")) {
    throw Exception("Coroutine1에 예외가 발생했습니다")
  }
  launch(CoroutineName("Coroutine2")) {
    delay(100L)
    println("[${Thread.currentThread().name}] 코루틴 실행")
  }
}
/*
// 결과:
Exception in thread "main" java.lang.Exception: Coroutine1에 예외가 발생했습니다
	at chapter8.code17.Main8_17Kt$main$1$1.invokeSuspend(Main8-17.kt:7)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	...

Process finished with exit code 1
*/

그래서 아래 코드와 같이 supervisorScope 함수를 이용해서 발생한 예외를 부모 코루틴으로 전파하지 않도록 만들 수 있다.

fun main() = runBlocking<Unit> {
    supervisorScope {
        async(CoroutineName("Coroutine1")) {
            throw Exception("Coroutine1에 예외가 발생했습니다")
        }
        launch(CoroutineName("Coroutine2")) {
            delay(100L)
            println("[${Thread.currentThread().name}] 코루틴 실행")
        }
    }
}
/*  
// 결과 :
[main @Coroutine2#3] 코루틴 실행
*/

이처럼 async 코루틴 빌더를 사용할 때는 await 호출 시 노출되는 예외와 전파되는 예외를 모두 처리해줘야 함을 기억하자.


3. 전파되지 않는 예외

코루틴에서 CancellationException은 다른 예외와 다르게 예외가 발생해도 부모 코루틴으로 예외가 전파되지 않는다.

fun main() = runBlocking<Unit>(CoroutineName("runBlocking 코루틴")) {
  launch(CoroutineName("Coroutine1")) {
    launch(CoroutineName("Coroutine2")) {
      throw CancellationException()
    }
    delay(100L)
    println("[${Thread.currentThread().name}] 코루틴 실행")
  }
  delay(100L)
  println("[${Thread.currentThread().name}] 코루틴 실행")
}
/*
// 결과:
[main @runBlocking 코루틴#1] 코루틴 실행
[main @Coroutine1#2] 코루틴 실행
*/

이 코드에서 CancellationException이 다른 Exception처럼 예외를 전파했다면, 부모 코루틴으로 예외가 전파되어 "코루틴 실행"이 출력되지 않았을 것이다. 하지만 CancellationException이기 때문에 정상적으로 출력된다.

// JobSupport.kt
private fun cancelParent(cause: Throwable): Boolean {
    // Is scoped coroutine -- don't propagate, will be rethrown
    if (isScopedCoroutine) return true

    /* CancellationException은 "정상적인" 것으로 간주되며, 자식이 이를 생성해도 부모는 보통 취소되지 않습니다.
     * 이는 부모가 자신은 취소되지 않고도 자식들을 (정상적으로) 취소할 수 있게 합니다.
     * 단, 자식이 충돌하여 완료 중에 다른 예외를 발생시키는 경우는 제외합니다.
     */
    val isCancellation = cause is CancellationException
    val parent = parentHandle
    // No parent -- ignore CE, report other exceptions.
    if (parent === null || parent === NonDisposableHandle) {
        return isCancellation
    }

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

CancellationException이 예외를 전파하지 않는다는 내용은 JobSupport.kt 파일의 cancelParent() 함수의 주석에도 포함되어 있다. CancellationException은 정상적인 것으로 간주되며 자식이 이를 생성해도 부모는 취소되지 않는다는 내용이다.

참고로 cancelParent() 메서드는 작업이 취소될 때 호출되며, 취소를 부모에게 전파할지 여부를 결정하는 함수이다. 만약 부모가 예외를 처리할 책임이 있다면 true를 반환하고, 그렇지 않으면 false를 반환한다.


코루틴은 왜 CancellationException을 부모 코루틴으로 전파하지 않고 특별 취급할까? 바로 CancellationException은 코루틴 취소에 사용되는 특별한 예외이기 때문이다.

fun main() = runBlocking<Unit> {
  val job = launch {
    delay(1000L) // 1초간 지속
  }
  job.invokeOnCompletion { exception ->
    println(exception) // 발생한 예외 출력
  }
  job.cancel() // job 취소
}
/*
// 결과:
kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelled}@7494e528
*/

이 코드에서는 invokeOnCompletion 함수를 통해 job에 발생한 예외를 출력하는 콜백을 등록한다. 코드의 실행 결과를 보면 JobCancellationException이 발생해 코루틴이 취소되는 것을 확인할 수 있다. JobCancellationException은 CancellationException의 서브 클래스이다.


CancellationException의 서브 클래스 중 하나로 TimeoutCancellationException도 존재한다. withTimeOut 함수는 작업이 주어진 시간 내에 완료되지 않으면 TimeoutCancellationException을 발생시킨다.

fun main() = runBlocking<Unit>(CoroutineName("Parent Coroutine")) {
  launch(CoroutineName("Child Coroutine")) {
    withTimeout(1000L) { // 실행 시간을 1초로 제한
      delay(2000L) // 2초의 시간이 걸리는 작업
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
  }
  delay(2000L)
  println("[${Thread.currentThread().name}] 코루틴 실행")
}
/*
// 결과:
[main @Parent Coroutine#1] 코루틴 실행
*/

이 코드에서는 Child Coroutine의 실행 시간을 1초로 제한시키고 내부에서 2초의 시간이 걸리는 작업을 실행한다. 작업의 실행 시간이 1초로 제한됐음에도 작업에 2초의 시간이 걸리므로 withTimeOut은 TimeoutCancellationException을 발생시켜 Child Coroutine은 취소시키지만 예외는 Parent Coroutine으로 전파되지는 않는다. 그래서 [main @Parent Coroutine#1] 코루틴 실행은 정상적으로 출력된다.

withTimeOut 함수는 실행 시간이 제한되어야 할 필요가 있는 다양한 작업에 사용될 수 있다. (대표적으로 네트워크 호출의 실행 시간 제한)

// Code8-22.kt
fun main() = runBlocking<Unit>(CoroutineName("Parent Coroutine")) {
  try {
    withTimeout(1000L) { // 실행 시간을 1초로 제한
      delay(2000L) // 2초의 시간이 걸리는 작업
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
  } catch (e: Exception) {
    println(e)
  }
}
/*
// 결과:
kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1000 ms
*/

withTimeOut함수에서 발생한 TimeoutCancellationException은 try catch문을 통해 처리하는 것도 가능하다.


fun main() = runBlocking<Unit>(CoroutineName("Parent Coroutine")) {  
    launch(CoroutineName("Child Coroutine")) {  
        val result = withTimeoutOrNull(1000L) { // 실행 시간을 1초로 제한  
            delay(2000L) // 2초의 시간이 걸리는 작업  
            return@withTimeoutOrNull "결과"  
        } ?: "Default value"  
        println(result)  
    }  
}  
/*  
// 결과:  
Default value  
*/

실행 시간을 초과하더라도 코루틴이 취소되지 않고 결과가 반환되어야 하는 경우에는 withTimeOutOrNull 함수를 사용할 수 있다.
제한 시간이 초과됐을 때 TimeoutCancellationException을 외부로 전파하는 대신 내부적으로 해당 예외를 처리하고 null을 반환한다. 그래서 null을 반환할 때의 Default value를 설정하는 것도 가능하다.

// Timeout.kt
public suspend fun <T> withTimeoutOrNull(timeMillis: Long, block: suspend CoroutineScope.() -> T): T? {  
    if (timeMillis <= 0L) return null  
  
    var coroutine: TimeoutCoroutine<T?, T?>? = null  
    try {  
        return suspendCoroutineUninterceptedOrReturn { uCont ->  
            val timeoutCoroutine = TimeoutCoroutine(timeMillis, uCont)  
            coroutine = timeoutCoroutine  
            setupTimeout<T?, T?>(timeoutCoroutine, block)  
        }  
    } catch (e: TimeoutCancellationException) {  
        // Return null if it's our exception, otherwise propagate it upstream (e.g. in case of nested withTimeouts)  
        if (e.coroutine === coroutine) {  
            return null  
        }  
        throw e  
    }  
}

withTimeOutOrNull() 함수를 보면 내부적으로 try catch문을 사용하여 null을 반환하는 것을 확인할 수 있다.


참고자료

profile
안드로이드 주니어 개발자

0개의 댓글

관련 채용 정보