Photo by Mark Rohan on Unsplash

Coroutine 에서의 Error handling

Zimin Byun

--

코루틴에서의 예외처리를 한번 살펴봅니다.

최근에 회사를 옮기면서 코루틴을 다시 살펴볼 기회가 생겼는데, 에러 처리 부분을 한번 정리해봤습니다.

코루틴은 기본적으로 CoroutineScope 에서 사용한는 launch, async등의 코루틴 빌더 안에서 실행되므로, 일반적인 코드를 진행하는 스레드에서의 예외를 처리하는 방식과 다르게 구현해야 하는 경우가 있습니다.

launch 안의 코드에서 에러를 처리하는 경우

다만 코루틴이라고 특별한 예외 처리 문법를 사용하는 것은 아닙니다. launch 를 사용하고 안쪽에 있는 코드에서 에러를 처리하고 싶은 경우 단순하게 try-catch 를 사용하면 됩니다.

launch {
try {
// 예외가 발생한 경우
error("cancels coroutine")
} catch (e: Exception) {
// 코드가 여기로 진행된다.
print("launch failed")
}
}

error() 는 전달한 메시지로 IllegalStateException 을 발생시키는 함수입니다.(stdlib)

코루틴 자체에서 예외가 발생할 때

그런데 만약, 위와 같이 코드 로직 안쪽에서 예외를 처리하는것이 아니라, 코루틴 자체를 예외처리 할 수는 없을까요? 만약 아래처럼 launch 를 예외처리 블록으로 감싼다면 어떨까요?

// 코루틴 종료까지 프로세스를 유지해야 해서 runblocking을 사용합니다. 
fun main(args: Array<String>): Unit = runBlocking {
try { // launch를 try-catch로 감싼다.
launch {
error("cancels coroutine")
}
} catch (e: Exception) {
println("catched $e")
}
print("main done")
}

예외를 잡을거 같지만, 그렇지 않습니다. 위 프로그램은 “main done” 을 출력하지 못하고 비정상 종료됩니다. 간단하게 이야기하면 코루틴은 기본적으로 예외가 나면 그 코루틴을 실행시킨 CoroutineScope에도 예외를 전파하기 때문입니다. 위 코드에서는 error() 을 실행하는 안쪽 launch 에서 예외가 발생하였고 이 예외가 runBlocking 으로 전달되어 프로세스 자체가 종료됩니다.

위 코드와 같은 예외를 받아서 처리하기 위해서는 다음과 같은 방법들을 사용 가능합니다.

  • 새로운 CoroutineScope 를 만들어서 거기서 코루틴 로직을 실행
  • 에러를 한 방향으로 전파하는 SupervisorScope 사용하기
  • CoroutineScope 자체에 처리되지 않은 예외를 받는 기능을 추가 (CoroutineExceptionHandler)

새로운 CoroutineScope를 실행하기

간단합니다. 위 코드에서 안쪽에서 실행하는 launch 는 사실 runBlockingthis 를 사용하는 this.launch 와 같습니다.

 fun main(args: Array<String>): Unit = runBlocking {
try {
this.launch { // 해당 함수 블록의 this(coroutineScope 에서 호출)
error("cancels coroutine")
}
} catch (e: Exception) {
println("catched $e")
}
print("main done")
}

앞서 말했듯이 코루틴의 기본 동작은 해당 코루틴을 실행한 coroutineScope 로도 예외를 전달하기 때문에, this.launch 를 사용하지 않고 새로운 ScopeBuilder를 사용하면 됩니다. builer로는 coroutineScope, GlobalScope 등이 있습니다.

fun main(args: Array<String>): Unit = runBlocking {
try {
coroutineScope { // 여기부터는 다른 scope
launch {
error("cancels coroutine")
}
}
} catch (e: Exception) {
println("catched $e")
}
delay(1000)
print("main done")
}

SupervisorScope 사용하기

이야기 했듯이 코루틴에서의 예외는 해당 코루틴을 실행한 코루틴, 해당 코루틴이 실행하는 코루틴 모두로 퍼집니다. 아마 기본 동작이 이렇게 된것은 코루틴에서 코루틴을 시행하는 경우 그 전체를 하나의 트랜젝션으로 보기 때문이 아닐까 싶습니다.

하지만 어떤 시나리오에서는 하위에서 진행되는 코루틴이 실패했다고 해서 그 코루틴 전체를 실패시킬 필요는 없습니다. 여러군데의 api를 호출하고 성공한 결과만 화면에 뿌려도 된다면, 해당 작업 하나만 실패처리 하고 다른 작업을 계속 실행시키는 것이 정상동작인 시나리오도 존재합니다.

이를 위해 예외를 child 로만 퍼트리는 특별한 Scope가 있는데, 그것이 바로 SupervisorScope입니다.

fun main(args: Array<String>): Unit = runBlocking {
supervisorScope {
launch {
delay(500)
println("first scope done")
}

// 예외가 발생해도, 해당 launch만 실패하게 됩니다. (예외를 위로 전달하지 않음)
launch {
error("cancels coroutine")
println("second scope done")
}
delay(1000)
println("main scope done")
}
print("main function completed")
}

위 코드에서 두번째 있는 launch에서 예외를 발생시키지만, 이를 실행한 supervisorScope로 예외가 퍼지지 않고 해당 코루틴만 실패합니다.

참고: android 의 ViewModel 에서 사용하는 viewModelScopeSuperVisorScope로 설정되어있습니다.

CoroutineExceptionHandler 사용하기

코루틴이 실행 될 때 예외 처리가 되지 않은 경우, 이를 기본으로 처리하는 ` CoroutineExceptionHandler 를 추가 할 수 있습니다. 자바에서 사용되는 Thread.UncaughtExceptionHandler 와 같다고 생각하면 됩니다. 안드로이드의 경우 ViewModel에서 사용하는 viewModelScope등에 coroutineExceptionHandler를 추가해서 사용하면 해당 Scope에서 발생하는 예외는 모두 처리 가능합니다.(물론 예외 발생 코드에서 예외처리를 해야하는 경우는 코드가 다를 수 있습니다.)

// SomeViewModel val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> 
// handle error
}

val scope = viewModelScoe + coroutineExceptionHandler
scope.launch {
// scope를 사용한 launch 에서 발생한 예외는 coroutineExceptionHandler로
}

PS. GlobalScope의 동작

이전에 적은 내용에서, CoroutineScope내에서 예외가 발생한다면 해당 Scope도 예외가 발생한다고 했습니다. 그렇다면, GlobalScope는 앱 전체에서 하나의 인스턴스를 사용하는데 만약 아래와 같이 코드를 작성한다면 secondJob이 예외를 발생시켰을 때 firstJob도 실패할까요?

fun main(args: Array<String>) = runBlocking {
val firstJob = GlobalScope.launch {
delay(3000)
println("first job done.")
}

val secondJob = GlobalScope.launch {
delay(1000)
println("second job done")
error("throws error")
}

secondJob.join()
firstJob.join()
println("ended")
}

firstJob은 실패하지 않습니다. GlobalScope라는 object 를 공유하는 것은 맞으나 launch 메소드는 새로운 context를 만들어서 실행하기때문에 GlobalScope 자체에 영향을 주지는 않습니다. 게다가 새로운 runblocking과 다른 Scope를 사용하므로 코루틴의 에러를 위쪽으로 전파시키지 않고, 따라서 firstJob도 종료되지 않습니다.

위 내용 외에도 여러개의 예외가 발생시, 임의로 cancel()사용시 어떻게 진행되는지 살펴보면 재미있습니다.

더 자세하게 알고싶다면 코루틴 공식 문서에서 예외처리 부분을 참고 바랍니다.

--

--