Shunz Android Dev Note

코틀린 코루틴에서의 취소 및 예외 처리 #2 - 취소 본문

Kotlin

코틀린 코루틴에서의 취소 및 예외 처리 #2 - 취소

_Shun_ 2023. 12. 4. 23:17

이 포스팅은 아래 게시글을 번역 및 일부 수정하여 작성하였습니다.

https://medium.com/androiddevelopers/cancellation-in-coroutines-aa6b90163629

 


 

인생에서와 마찬가지로, 우리는 개발에 있어서도 필요 이상의 일을 하는 것을 피하는 것이 중요하다는 것을 알고 있습니다. 이 원리는 코루틴에도 적용이 됩니다. 독자는 코루틴의 수명을 조절하고 더 이상 필요하지 않을 때 그것을 취소해야 합니다. 이것이 구조화된 동시성을 나타내는 것입니다. 코루틴 취소의 안팎에 대해 알아보려면 계속 읽어보세요.

 

만약 이 내용에 관하여 설명하는 Video를 보고 싶다면 아래 영상을 참고하세요. KotlinConf`19 에서 코루틴의 예외와 취소에 대하여 발표한 영상입니다.

https://youtu.be/w0kfnydnFWI

 

나머지 글을 문제없이 따라가기 위해서는 시리즈 1부를 읽고 이해하는 과정이 필요합니다.

https://android-devpia.tistory.com/10

 

코틀린 코루틴에서의 취소 및 예외 처리 #1 - CoroutineScope, Job, CoroutineContext

이 포스팅은 아래 게시글을 번역 및 일부 수정하여 작성하였습니다. https://medium.com/androiddevelopers/coroutines-first-things-first-e6187bf3bb21 이 일련의 포스팅은 코루틴의 취소와 예외에 대하여 자세히 설

android-devpia.tistory.com

 


어떻게 취소를 할 수 있을까요?

다중 코루틴을 실행할 때는 각각의 코루틴을 계속해서 추적하거나 취소하는 것이 쉽지 않습니다. 오히려 다음과 같이, 생성된 모든 하위 코루틴을 취소하므로 시작되는 전체 코루틴 스코프를 취소하는 것이 좋습니다.

// scope가 미리 생성되었다고 가정합니다.
...
val job1 = scope.launch { … }
val job2 = scope.launch { … }

scope.cancel()

 

 

Scope를 취소하면 그것의 자식들도 모두 취소합니다.

때로는 사용자 입력에 대한 반응으로 하나의 코루틴만 취소해야 할 수도 있습니다. job1.cancel()을 호출하면 해당 코루틴만 취소되고 다른 형제들은 영향을 받지 않습니다:

// scope는 미리 생성되었다고 가정합니다.

...
val job1 = scope.launch { … }
val job2 = scope.launch { … }

// job1 코루틴은 취소되지만, 다른 scope의 children은 영향을 받지 않습니다.
job1.cancel()

 

취소된 child는 다른 siblings에 영향을 미치지 않습니다.

 

코루틴은 CancellationException이라는 특별한 예외를 던져서 취소를 처리합니다. 취소 사유에 대하여 자세한 정보를 제공하려면 .cancel() 메서드를 호출할 때 CancellationException의 인스턴스를 제공할 수 있습니다. cancel() 메서드의 시그니처는 다음과 같습니다.

fun cancel(cause: CancellationException? = null)

 

만약 cancel()을 호출 할때 CancellationException의 인스턴스를 제공하지 않으면 디폴트 CancellationException이 생성됩니다. (전체 소스 코드 참조):

public override fun cancel(cause: CancellationException?) {
    cancelInternal(cause ?: defaultCancellationException())
}

 

CancellationException이 발생하기 때문에 이 메커니즘을 사용하여 코루틴 취소를 처리할 수 있습니다. 이 방법에 대한 자세한 내용은 아래 취소 부작용 처리 섹션에서 확인해주세요.

 

내부적으로, 자식 Job은 예외를 통해 부모에게 취소에 대해 알립니다. 부모는 취소의 원인을 사용하여 예외를 처리해야 하는지 여부를 결정합니다. CancellationException로 인해 자식이 취소된 경우 부모에게 다른 조치가 필요하지 않습니다.

 

한번 Scope를 취소하면, 취소된 Scope에서는 새로운 코루틴을 실행할 수 없습니다.

 

대부분의 AndroidX KTX 라이브러리를 사용하는 경우 직접 Scope를 생성하지 않으므로 해당 Scope를 취소할 책임이 없습니다. ViewModel에서 작업하는 경우 viewModelScope를 사용하거나 라이프사이클 Scope에 맞춰서 작업하는 경우 lifecycleScope를 사용합니다. viewModelScopelifecycleScope는 모두 적절한 시기에 취소되는 CoroutineScope 개체입니다. 예를 들어 ViewModel이 Clear되면 해당 스코프에서 시작된 코루틴들을 취소됩니다.

 

 

왜 코루틴이 멈추지 않고 여전히 동작하고 있을까?

우리가 단지 cancel()를 호출한다고 해서 코루틴 작업이 그냥 중단되는 것은 아닙니다. 여러 파일에서 읽는 것과 같이 비교적 무거운 연산을 수행하는 경우 자동적으로 코드가 작동하지 않게 되는 것은 없습니다.

 

좀 더 간단한 예를 보고 무슨 일이 일어나는지 보겠습니다. 우리가 코루틴을 사용하여 1초에 두 번 "안녕"을 표시해야 한다고 가정해 보겠습니다. 우리는 코루틴을 잠깐 실행시킨 다음 취소하려고 합니다. 구현의 한 가지 버전은 다음과 같습니다.

import kotlinx.coroutines.*
 
fun main(args: Array<String>) = runBlocking<Unit> {
   val startTime = System.currentTimeMillis()
    val job = launch (Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) {
            // 1초에 두 번 메시지를 표시합니다.
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("Hello ${i++}")
                nextPrintTime += 500L
            }
        }
    }
    delay(1000L)
    println("Cancel!")
    job.cancel()
    println("Done!")
}

 

어떤 일이 일어나는지 차근차근 살펴보겠습니다. launch()를 호출 할 때, 우리는 active 상태에서 코루틴을 생성하고 있습니다. 이후 코루틴을 1000ms 동안 실행하고 있습니다. 그래서 지금 우리는 출력된 것을 볼 수 있습니다.

Hello 0
Hello 1
Hello 2

 

정말 위처럼만 출력이 되고 있을까요?

 

job.cancel()이 호출되면 코루틴이 Cancelling 상태로 이동합니다. 그런데 Hello 3과 Hello 4가 이어서 출력이 되는것을 확인 할 수 있습니다. 작업이 완료된 후에만 코루틴이 Cancelled 상태로 이동합니다.

Hello 0
Hello 1
Hello 2
Cancel!
Done!
Hello 3
Hello 4

 

job.cancel()이 호출되면 코루틴 작업이 중단되는 것이 아닙니다. 오히려 코드를 수정하고 코루틴이 active 상태인지 주기적으로 확인을 해야 합니다.

 

코루틴 코드의 취소는 협력해야 합니다!

 

코루틴 작업을 취소가 가능하게 만들기

구현하는 모든 코루틴 작업이 취소에 협조적인지 확인해야 하므로 주기적으로 또는 장기간 실행 중인 작업을 시작하기 전에 취소 여부를 확인해야 합니다. 예를 들어 디스크에서 여러 파일을 읽는 경우 각 파일을 읽기 전에 코루틴이 취소되었는지 확인해야 합니다. 이처럼 더 이상 CPU 연산량이 많은 작업이 필요하지 않을 때는 하지 않도록 합니다.

val job = launch {
    for(file in files) {
        // TODO 취소 여부 체크
        readFile(file)
    }
}

 

kotlinx.coroutine의 모든 중단 함수(withContext, delay, etc...)는 취소가 가능합니다. 따라서 그 중 하나를 사용하는 경우 취소 여부를 확인하고 실행을 중지하거나 CancellationException을 던질 필요가 없습니다. 그러나 사용하지 않는 경우 코루틴 코드를 협력적으로 만드는 두 가지 옵션이 있습니다.

  • job.isActive를 체크하거나 ensureActive()를 호출하여 상황 체크
  • yield()를 사용하여 다른 작업을 수행

 

Job의 활성화 상태 확인

한가지 옵션은 코드(while(i<5))에서 코루틴 상태에 대한 다른 검사를 추가하는 것입니다.

// 시작 블록에 있기 때문에 job.isActive에 접근할 수 있습니다.
while (i < 5 && isActive)

 

이것은 우리의 작업이 코루틴이 활성화되어 있는 동안에만 실행되어야 한다는 것을 의미합니다. 또한, !isActive 조건문에 대한 추가 작업을 할 수도 있음을 의미합니다.

 

코루틴 라이브러리는 ensureActive()와 같은 유용한 메서드도 제공합니다. 구현된 코드는 다음과 같습니다.

fun Job.ensureActive(): Unit {
    if (!isActive) {
         throw getCancellationException()
    }
}

 

이 방법은 Job이 활성화되지 않은 경우 즉시 실행되므로, while 루프에서 가장 먼저 수행할 수 있습니다.

while (i < 5) {
    ensureActive()
    …
}

 

ensureActive()를 사용하면 isActive에 필요한 if 조건문을 직접 구현하지 않으므로 작성해야 하는 보일러 플레이트 코드의 양을 줄일 수 있지만 로깅과 같은 다른 작업을 수행할 수 있는 유연성을 잃게 됩니다.

 

yield() 메서드를 사용하여 다른 작업을 수행

현재 수행 중인 작업이 CPU 연산량이 많거나, 쓰레드 풀을 소진할 수 있으며 쓰레드 풀에 쓰레드를 추가 하지 않고 다른 작업을 시키려면 yield()를 사용합니다. yield가 수행하는 첫 번째 작업은 완료 여부를 확인하고 작업이 이미 완료된 경우 CancellationException을 던져 코루틴을 종료하는 것입니다. yield는 위에서 언급한 ensureActive()와 같이 주기적인 체크에서 호출되는 첫 번째 함수가 될 수 있습니다.

yield 코드는 이쪽 링크에서 참고 할 수 있습니다.
public suspend fun yield(): Unit = suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
    val context = uCont.context
    context.ensureActive()
    val cont = uCont.intercepted() as? DispatchedContinuation<Unit> ?: return@sc Unit
    ...
}

 

 

Job.join 대 Deferred.await 취소

코루틴에서 결과를 기다리는 방법은 두 가지가 있습니다. launch() 호출을 통해 반환된 Job과 async() 호출을 통해 반환된 Deferred (job의 서브 타입)가 대기 할 수 있는 방법을 제공해주는 객체입니다.

 

Job.join은 작업이 완료될때까지 코루틴을 중단합니다. job.cancel()과 함께 다음과 같이 동작합니다:

  • job.cancel을 호출한 다음 job.join을 호출하면 작업이 완료될 때까지 코루틴이 일시 중단됩니다.
  • job.join을 호출한 이후 job.cancel을 호출하면 job이 이미 완료 되었기 때문에 효과가 없습니다.

 

코루틴의 결과에 관심이 있을 때는 Deferred를 사용합니다. 이 결과는 코루틴이 완료되면 Deferred.await에 의해 반환됩니다. Deferred는 Job의 한 종류이며 취소될 수도 있습니다.

 

이미 취소된 Deferred에 대하여 await를 호출하면 JobCancellationException를 발생시킵니다.

fun main() = runBlocking {
    val deferredJob = async {
        println("async - start")
        delay(100)
        println("async - end")
    }
    deferredJob.cancel()
    deferredJob.await()
    println("end!")
}
Exception in thread "main" kotlinx.coroutines.JobCancellationException: 
  DeferredCoroutine was cancelled; job=DeferredCoroutine{Cancelled}@46238e3f

 

예외가 발생하는 이유는 다음과 같습니다. await의 역할은 결과가 계산될 때가지 코루틴을 일시 중단하는 것입니다. 코루틴이 취소되므로 결과가 계산될 수 없습니다. 따라서 cancel 후 await를 호출하면 "JobCancellationException: DeferredCoroutine was cancelled" 가 발생됩니다.

 

반면, 만약 독자가 deferred.await 호출 후 deferred.cancel을 호출하면 코루틴이 이미 완료 되었기 때문에 아무 일도 일어나지 않을 것입니다.

 

 

취소에 대한 Side Effect 처리

코루틴이 취소되었을 때 특정 작업을 실행하려고 한다고 가정해 보겠습니다. 사용 중인 리소스를 닫거나 취소 내용을 로깅하거나 무언가를 정리하는 코드를 실행하는 방법이 있습니다. 이런 동작을 하기 위한 몇 가지 방법이 있습니다:

 

!isActive 확인

주기적으로 isActive를 확인하고 있다면 while() 루프에서 벗어나면 리소스를 정리 할 수 있습니다. 위의 코드는 다음과 같이 업데이트 될 수 있습니다.

while (i < 5 && isActive) {
    // 1초에 두 번 메시지 출력
    if (…) {
        println(“Hello ${i++}”)
        nextPrintTime += 500L
    }
}
// 코루틴 작업이 완료되어 여기에서 리소스 정리 수행
println(“Clean up!”)

 

여기에서 동작을 확인 할 수 있습니다.

 

그래서 이제 코루틴이 더 이상 active 상태가 아닐 때는 while() 루프가 중지되어서 우리는 리소스 정리 작업을 할 수 있습니다.

 

try... catch... finally

CancellationException은 코루틴이 취소될 때 발생하기 때문에, 우리는 try/catch에서 중단된 작업을 마무리할 수 있고, finally{} 블록에서 우리는 리소스 정리 작업을 할 수 있습니다.

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      println(“Clean up!”)
    }
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

 

하지만 우리가 실행해야 할 정리 작업이 중단되면, 위의 코드는 더 이상 동작하지 않을 것입니다. 일단 코루틴이 Cancelling 상태에 있으면 더 이상 중단할 수 없습니다. 전체 코드를 살펴 보세요.

 

취소 상태에 있는 코루틴은 일시 중단할 수 없습니다!

 

코루틴이 취소되었을 때 일시 중단 함수를 호출할 수 있으려면 NonCancellable CoroutineContext에서 정리 작업을 하도록 전환해야 합니다. 이렇게 하면 코드가 일시 중단되고 작업이 완료될 때까지 코루틴은 Cancelling 상태로 유지됩니다.

val job = launch {
   try {
      work()
   } catch (e: CancellationException){
      println(“Work cancelled!”)
    } finally {
      withContext(NonCancellable){
         delay(1000L) // or some other suspend fun 
         println(“Cleanup done!”)
      }
    }
}
delay(1000L)
println(“Cancel!”)
job.cancel()
println(“Done!”)

 

어떻게 동작하는지 여기에서 확인 해보세요.

 

suspendCancellableCoroutine and invokeOnCancellation

suspendCoroutine 메서드를 사용하여 콜백을 코루틴으로 변환한 경우, suspendCancellableCoroutine을 사용하는 것을 선호합니다. continuation.invokeOnCancellation을 사용하여 취소 시 수행할 작업을 구현할 수 있습니다.

suspend fun work() {
   return suspendCancellableCoroutine { continuation ->
       continuation.invokeOnCancellation { 
          // 정리 작업 수행
       }
       // 나머지 구현 코드
   }
}

 

 


 

구조화된 동시성(structured concurrency)의 이점을 실현하고 불필요한 작업을 하지 않도록 하려면 코드를 취소할 수 있도록 해야 합니다.

 

Jetpack에 정의된 CoroutineScopes를 사용해보세요. viewModelScope 또는 lifecycleScope는 Scope 생성이 완료 되었을때 그들의 work를 취소 할 수 있습니다. 자신만의 CoroutineScope를 만드는 경우, Job에 연결하고 필요할 때 취소를 호출해야 합니다.

 

코루틴 코드의 취소는 협조가 필요하므로 취소 여부를 확인하고 필요 이상의 작업을 하지 않도록 코드를 업데이트해야 합니다.

 

 


 

이상으로 두번째 포스팅을 마치겠습니다.