Kotlin Coroutines Exception 영향도 알아보기



개인 광고 영역

이전 글에서 Kotlin coroutines을 제어할 수 있는 Job에 대해 알아보았다. 기본적인 Job 초기화 후 사용한데 어떤 방법이 좋을까?

먼저 이전 글에서 초기화 때 적용했던 방법을 다시 살펴보자.

val job = Job()
CoroutineScope(Dispatchers.Default + job).launch {
  // ...
}

이 코드는 CoroutineScope 초기화 시 Job을 함께 초기화하여 사용한다.(+를 이용하여 초기화한다.) 이렇게 초기화 함에 따라 CoroutineScope 사용 시 하위 child 모두 이 job의 영향을 받게 설계하였다.


Exception 발생의 경우에 대해서 살펴보자

코루틴에서도 Exception이 발생하지 않으리라는 보장은 없으며, child coroutines 영향이 부모에게도 미칠 경우에 대해서 대비해야 한다.

하나씩 살펴보면서 해결 방법을 정리해보려고 한다. 먼저 다음과 같은 조건을 임의로 만들어보았다.

  • 2 개의 child coroutines을 만든다.
  • 1 개의 child에서는 Exception을 임의로 발생시킨다.
  • 2 번째 child에서는 delay를 걸어 로그를 출력한다.

주요 코드에서는 Job을 별도로 초기화하였고, 이 Job을 이용해서 CoroutineScope을 초기화하였다.

class UICoroutineScopeTest {

    private val job = Job()

    private val coroutineScope = CoroutineScope(Dispatchers.IO + job)

    @Test
    fun testException() = runBlocking {
        with(coroutineScope) {
            println("Parent job")
            val childJob = launch {
                println("First child is failing")
                throw Exception("First child is exception")
            }
            val secondJob = launch {
                println("Second child is success")
                delay(500)
                println("Second child is delay 500ms")
            }
            childJob.join()
            secondJob.join()
        }
    }
}

참고로

runBlocking은 GlobalScope을 이용하여 초기화하는데 필요한 Context만 변경하고 초기화하도록 내부에 구현되어있다.

runBlocking 안에서 또 다른 CoroutineScope을 사용해야 하여 with으로 초기화 후 사용하였고, join으로 대기하여 내부의 CoroutineScope을 종료하고, runBlocking을 종료한다.

이 경우 coroutineContext는 runBlocking과 CoroutineScope의 coroutineContext 2 개가 존재하기에 this 스쿱을 잘 활용하여 사용해야 한다.

테스트 코드에서는 약간의 트릭이 필요한데 결국 delay 또는 join을 잘 활용해야 명확한 테스트가 가능해진다.


당연한 이야기지만 오류가 난다. 어떻게 동작할까?

아쉽게도 본 코드는 child 2 개가 모두 동작하지 않고 종료한다.

childJob은 동작 중에 exception이 발생하며, secondJob은 앞서 발생한 exception로 인해 아무것도 하지 않고 즉시 종료한다.

로그상으로 아래와 같이 확인해볼 수 있는데, childJob만이 동작함을 확인할 수 있다.

Parent job
First child is failing

java.lang.Exception: First child is exception

	at tech.thdev.coroutines.scope.UICoroutineScopeTest$testException$1$1$childJob$1.invokeSuspend(UICoroutineScopeTest.kt:22)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:32)
	at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:233)
	at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.kt:116)
	at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:76)
	at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:53)
	at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
	at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:35)
	at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
	at tech.thdev.coroutines.scope.UICoroutineScopeTest.testException(UICoroutineScopeTest.kt:17)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
	at com.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:47)
	at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:242)
	at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)


이 경우 무슨 문제가 생길까?

이 경우 개발자가 유도한 건 당연히 CoroutineScope의 childJob이 모두 동작하기를 희망한다.

예를 들어보면 3 개의 네트워크를 통해 데이터를 불러와서 합쳐야 한다.

  • 이 경우 join()을 이용해서 3 개의 데이터를 모두 대기한다.
  • 하지만 이 중 하나가 exception 발생으로 다른 데이터들도 불러오는 중에 멈추었다.

예외 처리를 해야 하는데 위 예제에서 보았듯 예외 처리도 불가능한 사항이 만들어진다. try/catch로 예외 처리를 해주지 않으면 앱은 종료될 것이다.

최소한 이를 잡아서 예외처리를 해야하는 데 매번 try/catch로 묶기엔 무리가 있어 좀 더 쉬운 방법을 찾아야 한다.


그러면 해결 방법은 뭘까?

차근차근 해결 방법을 알아보도록 하자. 언제나 그렇듯 문서가 좋은데 Job 문서를 다시 보도록 하자.

The most basic instances of Job are created with launch coroutine builder or with a Job() factory function. By default, a failure of any of the job’s children leads to an immediately failure of its parent and cancellation of the rest of its children. This behavior can be customized using SupervisorJob.

Job 문서에 나오는 문항이다. 몇 가지 키워드가 있는데 children의 종료에 따라 즉시 Parent와 다른 children들이 종료됨을 알 수 있는데, 다른 행동이 필요하면 SupervisorJob을 활용하라고 나와있다.


SupervisorJob?

SupervisorJob이 기본적으로 사용하는 Job과 어떻게 다른지 문서를 통해 확인해보자.

SupervisorJob에 작성되어 있는 첫 줄 그대로 가져왔다.

Creates a new supervisor job object in an active state. Children of a supervisor job can fail independently of each other.

Children의 Job fail 정보를 독립적으로 관리한다고 한다. 기본적인 Job은 모든 Scope을 종료하지만 SupervisorJob은 필요한 곳에서만 종료함을 알 수 있다.


왜 그럴까?

왜 그럴까를 알려면 결국 코드가 어떻게 다른지를 알면 되겠다. 그래서 좀 더 들여다보려고 한다.

먼저 모든 Job은 interface로 정의되어 있는데 아래 그림과 같은 라이프 사이클을 가지고 있음을 이전 글에서 확인하였다.

coroutine-job

실제 interface는 아래와 같이 초기화되어있다.

참고로 CoroutineScope 초기화 시에 CoroutineScope(Dispatchers.IO + job) 형태 사용이 가능한 이유는 CoroutineContext.Element 상속으로 인해 가능하다.

public interface Job : CoroutineContext.Element {
  // 생략
  public val isActive: Boolean
  public val isCompleted: Boolean
  // 생략
}

이러한 Job interface는 JobSupport라는 곳에서 상속받아 구현해두고 있다. 이 JobSupport가 Job을 편하게 사용하기 위해서 구현해둔 클래스인데 일반 개발자가 이를 확장하여 사용하지 못하도록 막아두었다.

public open class JobSupport constructor(active: Boolean) : Job, ChildJob, ParentJob, SelectClause0 {
   // 생략
}

개발자가 커스텀 해서 이를 활용할 방법은 없고, coroutines 사용 규칙에 따라서만 사용 및 접근이 가능하다.

이제 Job과 SupervisorJob 각각 초기화 코드 및 어떤 부분이 다른지 살펴보도록 하자.


Job 초기화 살펴보기

Job을 초기화할 때는 아래와 같은 function을 통해 초기화를 하고, 이를 활용할 수 있다. 필요할 경우 ParentJob을 함께 초기화하여 사용하는 것도 가능하다.

이 코드에서 확인할 수 있는 건 JobImpl()이라는것을 통해 초기화함을 알 수 있다.

@Suppress("FunctionName")
public fun Job(parent: Job? = null): Job = JobImpl(parent)

JobImpl이라는 건 JobSupport class를 상속받아 확장한 형태이다.

cancelParent를 통해 어떠한 조건에 따라 Parent를 종료하도록 유도할 수 있으며, onCancelComplete 가능하도록 하고 있다.

handlesException은 false로 처리하고 있음만 사실 알 수 있다.

이 코드는 JobSupport의 동작 중에 필요한 flag 값을 이용하여 간단하게 초기화함을 알 수 있다.

internal class JobImpl(parent: Job? = null) : JobSupport(true) {
    init { initParentJobInternal(parent) }
    override val cancelsParent: Boolean get() = true
    override val onCancelComplete get() = true
    override val handlesException: Boolean get() = false
}

이 코드만으로는 어떠한 차이가 있는지 사실 알지는 못한다. 결국 SupervisorJob 초기화 코드도 보아야 이해가 가능할 것 같다.


SupervisorJob 초기화 살펴보기

SupervisorJob 역시 Job처럼 function을 통한 초기화를 알 수 있고, 여기에서도 JobImpl과 동일한 SupervisorJobImpl이라는 걸 초기화함을 알 수 있다.

@Suppress("FunctionName")
public fun SupervisorJob(parent: Job? = null) : Job = SupervisorJobImpl(parent)

SupervisorJobImpl은 JobImpl과 동일하게 JobSupport를 상속받아 필요한 값만을 초기화함을 알 수 있는데, 딱 하나 다른 점을 발견할 수 있다.

childCancelled() = false라는 걸 확인할 수 있고, 나머지는 모두 JobImpl과 동일하다.

결국 childCancelled이라는 값이 상위 부모에게 영향을 미칠지 말지를 결정하는 중요한 요소임을 알 수 있다.

private class SupervisorJobImpl(parent: Job?) : JobSupport(true) {
    init { initParentJobInternal(parent) }
    override val cancelsParent: Boolean get() = true
    override val onCancelComplete get() = true
    override val handlesException: Boolean get() = false
    override fun childCancelled(cause: Throwable): Boolean = false
}


결국

JobSupport에서 Job 동작에 관한 모든 걸 구현해두고, 이를 상속받아 구현하는 실제 동작 Job 클래스들은 필요한 flag만을 조절하는 형태로 확장하고, 이 값들 하나하나에 따라 Job의 동작이 달라짐을 알 수 있다.


childCancelled은 어떻게 동작하고 있을까?

이 true/false의 값이 어떠한 영향을 미치는지 알면 좋을 것 같으니 좀 더 살펴보면 좋지만, 코드의 양이 많고 이해가 어려워 필요한 코드만을 분리해보았다.

필자가 알고 싶은 것은 childCancelled의 return 값이 어떠한 차이를 가지고 있는지만 알고 싶었을 뿐이기에 관련 코드를 사용하는 곳을 발견했는데, 이름만으로 유추해보았다.

cancelParent에서 이 값을 사용하고 있다. 이 cancelParent는 notifyHandlers라는 곳에서 불러주고 있었다.(필요한 코드만 가지고 왔다)

결국 어떠한 notify를 해야 하는 곳 중 parent에게 알려줘야 하는 곳이 있으며, job은 childCancelled의 값에 따라 부모에게 알려줄지 말지를 결정하게 됨을 알 수 있었다.

참고로 notifyHandlers를 부르고 있는 곳은 아래와 같이 3 곳을 확인할 수 있었다.

  • private fun makeCancelling(cause: Any?): Boolean
  • private fun tryMakeCancelling(state: Incomplete, rootCause: Throwable): Boolean
  • private fun tryMakeCompleting(state: Any?, proposedUpdate: Any?, mode: Int): Int

대략 유추하면서 찾아본 코드들이며, 해당 코드 중 발견한 시점의 코드를 아래와 같이 나열해본다.(이 글을 작성할 시점의 단순 참고용이며, 추후 변경 가능성이 큰 코드로 기입되어있다.)

private fun notifyCancelling(list: NodeList, cause: Throwable) {
    // first cancel our own children
    onCancellation(cause)
    notifyHandlers<JobCancellingNode<*>>(list, cause)
    // then cancel parent
    cancelParent(cause) // tentative cancellation -- does not matter if there is no parent
}

cancelParent는 아래 3개의 조건에 따라서 return의 결과가 달라진다. 앞에 종료 사항과 CancellationException의 상황이 아닌 경우에만 마지막의 childCancelled의 정보를 가지고 처리함을 알 수 있다.

그렇기에 필자가 찾은 코드가 꼭 이 코드를 탄다는 전제는 없고, 상황에 따라서 조금씩 다를 수 있다.

private fun cancelParent(cause: Throwable): Boolean {
    // CancellationException is considered "normal" and parent is not cancelled when child produces it.
    // This allow parent to cancel its children (normally) without being cancelled itself, unless
    // child crashes and produce some other exception during its completion.
    if (cause is CancellationException) return true
    if (!cancelsParent) return false
    return parentHandle?.childCancelled(cause) == true
}


결과를 다시 한번 살펴보자.

SupervisorJob으로 교체하면 어떻게 변경되는지 살펴보자.

class UICoroutineScopeTest {

    private val job = SupervisorJob()

    private val coroutineScope = CoroutineScope(Dispatchers.IO + job)

    @Test
    fun testException() = runBlocking {
        with(coroutineScope) {
            println("Parent job")
            val childJob = launch {
                println("First child is failing")
                throw Exception("First child is exception")
            }
            val secondJob = launch {
                println("Second child is success")
                delay(500)
                println("Second child is delay 500ms")
            }
            childJob.join()
            secondJob.join()
        }
    }
}

위에서는 Second children의 동작을 하지 않고, 전체 CoroutineScope이 종료되었지만 SupervisorJob으로 교체만 했을 뿐인데 전혀 다른 결과가 나왔음을 확인할 수 있다.

Parent job
First child is failing
Second child is success
Exception in thread "DefaultDispatcher-worker-1 @coroutine#2" java.lang.Exception: First child is exception
	at tech.thdev.coroutines.scope.UICoroutineScopeTest$testException$1$1$childJob$1.invokeSuspend(UICoroutineScopeTest.kt:22)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:32)
	at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:233)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:594)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.access$runSafely(CoroutineScheduler.kt:60)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:742)
Second child is delay 500ms


마무리

Job보다는 SupervisorJob 활용을 많이 하는 게 좋다는 결론이다. 별 이야기는 없지만 살짝 분석을 해보려고 나열하다 보니 긴 글이 되었다.

다음에는 또 다른 해결 방법에 대해서 살펴보려고 한다. SupervisorJob을 별도로 초기화하지 않아도 사실 동일한 결과를 얻는 몇 개의 Scope이 있다. 이들을 살펴보도록 하겠다.



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments