코루틴의 예외 전파

홍성덕·2024년 10월 18일
1

Coroutines

목록 보기
12/14

예외 전파

코루틴 실행 도중 예외가 발생하면 해당 코루틴은 취소되고 부모 코루틴으로 예외가 전파된다. 만약 부모 코루틴에서 예외 전파가 제한되지 않으면 계속 상위 코루틴으로 전파되는데 이것이 반복되면 루트 코루틴까지 예외가 전파될 수 있다. 이처럼 코루틴의 예외는 부모 코루틴 방향으로만 전파된다.

그리고 코루틴이 예외를 전파받아 취소되면 코루틴의 특성에 따라 해당 코루틴의 하위에 있는 모든 코루틴에게 취소가 전파된다. 루트 코루틴까지 예외가 전파되고 루트 코루틴이 취소되면 하위의 모든 코루틴에 취소가 전파된다.

fun main() = runBlocking<Unit> {
    launch(CoroutineName("Coroutine1")) {
        launch(CoroutineName("Coroutine3")) {
            throw Exception("예외 발생")
        }
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴 실행")
    }
    launch(CoroutineName("Coroutine2")) {
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴 실행")
    }
}

/*
// 실행결과
Exception in thread "main" java.lang.Exception: 예외 발생
	at com.hongstudio.wtdcomposehandson.TestKt$main$1$1$1.invokeSuspend(Test.kt:17)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    
Process finished with exit code 1
/*

코드를 실행해보면 예외가 발생했다는 로그만 출력될 뿐 코루틴이 실행되었다는 로그가 출력되지 않았다.

코드를 계층 구조 이미지로 나타낸 것이다. Coroutine3에서 예외가 발생했고 부모 코루틴인 Coroutine1으로 예외가 전파되었으며, Coroutine1에서 예외 전파 제한이 이루어진 것이 아니기 때문에 루트 코루틴인 runBlocking 코루틴까지 예외가 전파되었다. 그리고 부모 코루틴이 취소되면 자식 코루틴에게 취소가 전파되기 때문에 Coroutine2도 취소된다.


예외 전파 제한

1. 구조화를 깨서 예외 전파 제한

코루틴의 예외 전파를 제한하기 위한 첫 번째 방법은 코루틴의 구조화를 깨는 것이다. 아예 독립적인 코루틴을 만들어서 예외가 전파되지 않도록 하는 방법이다.

fun main() = runBlocking<Unit> {
  launch(CoroutineName("Parent Coroutine")) {
    launch(CoroutineName("Coroutine1") + Job()) { // 새로운 Job 객체를 만들어 Coroutine1에 연결
      launch(CoroutineName("Coroutine3")) {
        throw Exception("예외 발생")
      }
      delay(100L)
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
    launch(CoroutineName("Coroutine2")) {
      delay(100L)
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
  }
  delay(1000L)
}

/*
// 실행결과
Exception in thread "main @Coroutine1#3" java.lang.Exception: 예외 발생
	at com.hongstudio.wtdcomposehandson.TestKt$main$1$1$1$1.invokeSuspend(Test.kt:18)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	...
[main @Coroutine2#4] 코루틴 실행

Process finished with exit code 0
*/

위 코드에서는 Job() 객체를 또다른 루트 Job으로 설정하여 독립적인 코루틴 계층을 이루었다. 그래서 Coroutine3에서 예외가 발생하여도 Parent Coroutine으로 예외가 전파되지 않아서 Coroutine2는 정상적으로 실행된다. Coroutine1과 루트 Job 객체에는 예외가 전파된다.

하지만 이렇게 구조화를 깨는 방법은 독립적인 코루틴 계층을 구성하기 때문에, 취소가 의도한 대로 전파되지 않는다. 예를 들어 Parent Coroutine을 취소하면 Coroutine2에만 취소가 전파될 뿐, 다른 코루틴에는 취소가 전파되지 않는다.

구조화를 깨는 방법에 대한 더 자세한 내용은 이 글을 참고하면 된다. 취소 전파가 되지 않는 현상에 대한 내용도 더 자세히 나와 있다.

2. SupervisorJob 객체를 사용한 예외 전파 제한

SupervisorJob 객체는 자식 코루틴으로부터 예외를 전파받지 않는 특수한 Job 객체이다. 예외를 전파받지 않기 때문에 하나의 자식 코루틴에서 예외가 발생해도 다른 자식 코루틴에게 영향을 미치지 않는다.

public fun SupervisorJob(parent: Job? = null) : CompletableJob = SupervisorJobImpl(parent)

Job() 함수를 통해 Job 객체를 생성하듯이, 이와 비슷하게 SupervisorJob() 함수를 통해 SupervisorJob 객체를 생성할 수 있다. Job() 함수가 JobImpl 객체를 리턴하는 것처럼 SupervisorJob() 함수도 SupervisorJobImpl 객체를 리턴한다. 그리고 parent 인자를 전달하여 부모 Job을 명시적으로 지정 가능한 것도 똑같다.

private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
    override fun childCancelled(cause: Throwable): Boolean = false
}

SupervisorJobImpl 클래스 정의에서 왜 자식 코루틴이 취소되지 않는지에 대한 힌트를 얻을 수 있다. childCancelled() 함수가 false를 리턴하기 때문이다.


이제 SupervisorJob 객체를 사용하면 정말로 예외가 전파되지 않는지 코드를 통해 알아보자.

CoroutineScope 함수 + SupervisorJob 사용하여 예외 전파 제한

fun main() = runBlocking<Unit> {
    val coroutineScope = CoroutineScope(SupervisorJob())
    coroutineScope.launch(CoroutineName("Coroutine1")) {
        launch(CoroutineName("Coroutine3")) {
            throw Exception("예외 발생")
        }
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴1 실행")
    }
    coroutineScope.launch(CoroutineName("Coroutine2")) {
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴2 실행")
    }
    delay(1000L)
}

/*
// 결과:
Exception in thread "DefaultDispatcher-worker-1" java.lang.Exception: 예외 발생
	...
[DefaultDispatcher-worker-1 @Coroutine2#3] 코루틴2 실행

Process finished with exit code 0
*/

CoroutineScope 함수와 SupervisorJob을 함께 사용한 예시이다.

위와 같이 구조화가 이루어지는데, Coroutine3에서 예외가 발생하여도 Coroutine1에만 예외가 전파되어서, Coroutine2는 취소되지 않는다. 그래서 실행결과를 보면 Corotuine2는 정상적으로 실행되는 것을 확인할 수 있다.


SupervisorJob 객체만 사용하여 예외 전파 제한

fun main() = runBlocking<Unit> {
    val supervisorJob = SupervisorJob()
    launch(CoroutineName("Coroutine1") + supervisorJob) {
        launch(CoroutineName("Coroutine3")) {
            throw Exception("예외 발생")
        }
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴1 실행")
    }
    launch(CoroutineName("Coroutine2") + supervisorJob) {
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴2 실행")
    }
    delay(1000L)
}

/*
// 결과:
Exception in thread "main" java.lang.Exception: 예외 발생
	...
[main @Coroutine2#3] 코루틴2 실행

Process finished with exit code 0
*/

위 코드는 SupervisorJob 객체를 직접 생성하였다. Coroutine1과 Coroutine2는 supervisiorJob을 부모 Job으로 가진다. 구조화된 모습은 CoroutineScope(SupervisorJob())를 통해 작성한 코드의 구조화된 모습과 동일하다. 그래서 코루틴2가 정상적으로 실행되는 것을 확인할 수 있다.

CoroutineScope(SupervisorJob())를 통해 작성한 코드와 다른 점은 이 코드는 runBlocking의 CorotuineScope를 통해 launch를 하였기 때문에 runBlocking의 CorotuineContext를 상속받는다는 점이다. 그래서 Coroutine1과 Coroutine2의 CoroutineDispatcher, CoroutineExceptionHandler는 runBlocking 코루틴의 CoroutineDispatcher, CoroutineExceptionHandler와 같다.


코루틴 구조화를 깨지 않고 SupervisorJob 사용하여 예외 전파 제한

지금까지 SupervisorJob 객체를 사용한 두 가지 코드블록을 보았는데, 사실 구조화 그림에서도 봤듯이 runBlocking 코루틴과의 구조화가 깨져있는 코드이다. 구조화를 깨지 않고 SupervisorJob을 사용하는 방법을 알아보자.

fun main() = runBlocking<Unit> {
    // supervisorJob의 parent로 runBlocking으로 생성된 Job 객체 설정
    val supervisorJob = SupervisorJob(parent = this.coroutineContext[Job])
    launch(CoroutineName("Coroutine1") + supervisorJob) {
        launch(CoroutineName("Coroutine3")) {
            throw Exception("예외 발생")
        }
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴1 실행")
    }
    launch(CoroutineName("Coroutine2") + supervisorJob) {
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴2 실행")
    }
    supervisorJob.complete() // supervisorJob 완료 처리
}

/*
// 결과:
Exception in thread "main" java.lang.Exception: 예외 발생
	...
[main @Coroutine2#3] 코루틴 실행

Process finished with exit code 0
*/

구조화를 깨지 않으려면 SupervisorJob 함수의 parent 인자로 부모 Job 객체를 전달하면 된다. Job 함수를 통해 부모 Job 객체를 전달하는 것과 방법이 동일하다. 그리고 Job 함수를 통해 생성된 Job 객체와 같이 SupervisorJob 함수를 통해 생성된 Job 객체도 자동으로 완료 처리되지 않는다. 그래서 complete() 함수를 통해 명시적으로 완료 처리를 해야 한다.


SupervisorJob을 사용할 때 주의할 점

fun main() = runBlocking<Unit> {
  launch(CoroutineName("Parent Coroutine") + SupervisorJob()) {
    launch(CoroutineName("Coroutine1")) {
      launch(CoroutineName("Coroutine3")) {
        throw Exception("예외 발생")
      }
      delay(100L)
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
    launch(CoroutineName("Coroutine2")) {
      delay(100L)
      println("[${Thread.currentThread().name}] 코루틴 실행")
    }
  }
  delay(1000L)
}

/*
// 결과:
Exception in thread "main @Coroutine2#4" java.lang.Exception: 예외 발생
	...
    
Process finished with exit code 0
*/

흔히 하는 실수인데 예외 전파 방지를 위해 코루틴 빌더 함수의 context 인자에 SupervisorJob 객체를 전달하고, 하위에 자식 코루틴들을 생성하는 것이다. 위와 같이 코드를 작성하면 예외가 전파되어 모두 취소된다. 구조 이미지를 통해 왜 그런지 알아보자.

구조를 보면 실질적으로 Parent Coroutine의 자식으로 하위 코루틴들이 생성되어 있기 때문에 취소가 Coroutine2로 전파된다. SupervisorJob이 예외 전파 제한 역할을 하지 못하고 있는 것이다. 그래서 코드의 실행 결과로 예외 로그만 출력된다.

안드로이드에서도 흔히 위와 같은 실수를 범할 수 있다. 자주 사용하는 lifecycleScope와 viewModelScope가 내부적으로 SupervisorJob 객체를 사용하고 있기 때문이다.

class ExampleViewModel : ViewModel() {

    fun test() { 
        viewModelScope.launch {
            launch(CoroutineName("Coroutine1")) {
                launch(CoroutineName("Coroutine3")) {
                    throw Exception("예외 발생")
                }
                delay(100L)
                Timber.d("[${Thread.currentThread().name}] Coroutine1 코루틴 실행")
            }
            launch(CoroutineName("Coroutine2")) {
                delay(100L)
                Timber.d("[${Thread.currentThread().name}] Coroutine2 코루틴 실행")
            }
        }
	}

}

ViewModel 클래스에서 이렇게 작성한다면 예외가 전파되어 Coroutine2에 취소가 전파된다.

class ExampleViewModel : ViewModel() {

    fun test() { 
        viewModelScope.launch(CoroutineName("Coroutine1")) {
            launch(CoroutineName("Coroutine3")) {
                throw Exception("예외 발생")
            }
            delay(100L)
            Timber.d("[${Thread.currentThread().name}] Coroutine1 코루틴 실행")
        }
        viewModelScope.launch(CoroutineName("Coroutine2")) {
            delay(100L)
            Timber.d("[${Thread.currentThread().name}] Coroutine2 코루틴 실행")
        }
	}

}

이렇게 수정하여 예외가 발생해도 Coroutine2가 정상적으로 실행하게 만들어야 한다.


supervisorScope를 사용하여 예외 전파 제한

fun main() = runBlocking<Unit> {
    supervisorScope {
        launch(CoroutineName("Coroutine1")) {
            launch(CoroutineName("Coroutine3")) {
                throw Exception("예외 발생")
            }
            delay(100L)
            println("[${Thread.currentThread().name}] 코루틴1 실행")
        }
        launch(CoroutineName("Coroutine2")) {
            delay(100L)
            println("[${Thread.currentThread().name}] 코루틴2 실행")
        }
    }
}
/*
// 결과:
Exception in thread "main @Coroutine1#2" java.lang.Exception: 예외 발생
	...
[main @Coroutine2#3] 코루틴2 실행

Process finished with exit code 0
*/

supervisorScope를 사용하여 예외 전파를 제한하는 방법도 있다. supervisorScope를 사용하면 좋은 점은 예외 전파를 제한하면서, runBlocking 코루틴과의 구조화도 깨지 않고, 자식 코루틴들이 모두 실행 완료되면 자동으로 완료 처리된다는 점이다. 그래서 complete()를 통해 명시적으로 완료 처리를 할 필요가 없다.

/**
 * [SupervisorJob]을 가진 [CoroutineScope]를 생성하고, 이 scope에서 지정된 suspend [block]을 호출합니다.
 * 제공된 scope는 outer scope의 [coroutineContext][CoroutineScope.coroutineContext]에서 상속받으며,
 * 해당 context에서 [Job]을 사용하여 새 [SupervisorJob]의 부모로 설정합니다.
 * 이 함수는 주어진 block과 그 하위의 모든 코루틴이 완료되면 반환됩니다.
 *
 * [coroutineScope]와 달리, 하위 코루틴 중 하나의 실패가 이 scope를 실패하게 하거나
 * 다른 하위 코루틴에 영향을 미치지 않으므로, 하위 코루틴의 실패를 처리하는
 * 사용자 정의 정책을 구현할 수 있습니다. 자세한 내용은 [SupervisorJob]을 참조하십시오.
 *
 * (...생략)
 *
 */
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)
    }
}

supervisorScope 함수의 주석을 번역해보면 SupervisorJob 객체를 가진 CoroutineScope를 생성한다고 설명하고 있다. 그리고 두번째 문장의 설명을 살펴보면 외부 scope의 context의 Job이 SupervisorJob의 부모로 설정된다는 것을 확인할 수 있다.

private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
    override fun childCancelled(cause: Throwable): Boolean = false
}

private class SupervisorCoroutine<in T>(
    context: CoroutineContext,
    uCont: Continuation<T>
) : ScopeCoroutine<T>(context, uCont) {
    override fun childCancelled(cause: Throwable): Boolean = false
}

주석에서 설명하고 있는 SupervisorJob 객체는 SupervisorCoroutine 객체를 의미하는 것으로 추측된다. SupervisorJob() 함수의 리턴 객체인 SupervisorJobImpl 객체의 childCancelled()가 false를 반환하듯이, SupervisorCoroutine 객체의 childCancelled()가 false를 반환하고 있기 때문이다.


참고자료

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

0개의 댓글

관련 채용 정보