Kotlin CoroutineScope을 알아보고, cancel 처리에 대해서 알아보자.



개인 광고 영역

runBlocking과 GlobalScope에 대해서 알아보았다.

이 두 가지의 코루틴은 이유 없이 사용해서는 안 되는 것들이다. 그럼 사용할 수 있는 건 가장 기본적인 CoroutineScope이 남는다.

필자가 한참 코루틴을 학습할 때 적었던 내용에 CoroutineScope의 활용 방법은 이미 다양하게 나온다.

이 글에서는 CoroutineScope의 사용 방법을 좀 더 알아보고(MainScope, CoroutineScope), 무한 루프 상의 cancel 처리 동작을 알아보려고 한다.


이 글에서 알아볼 내용

  • CoroutineScope 사용 방법을 알아본다.
  • CoroutineScope 안에서 동작하는 무한 루프에서의 cancel의 동작을 알아본다.
  • AAC-ViewModel의 viewModelScop을 알아본다.


CoroutineScope의 활용

GlobalScope을 활용할 때는 상황에 따라 다를 수 있지만 장시간 동작해야 하는 프로그램에서는 사용 시 주의가 필요하고, 메모리 문제와 앱의 동작에 영향을 미치지 않는 사용법이 필요함은 이전 글에서 알 수 있었다.

이 글에서는 CoroutineScope을 알아볼 건데,

문서를 보면 Android에서의 활용할 수 있는 MainScope()에 대한 부분을 소개하고 있는데, 그대로 가져와보았다.

class MyUIClass {
    val scope = MainScope() // the scope of MyUIClass

    fun destroy() { // destroys an instance of MyUIClass
        scope.cancel() // cancels all coroutines launched in this scope
        // ... do the rest of cleanup here ...
    }

    /*
     * Note: if this instance is destroyed or any of the launched coroutines
     * in this method throws an exception, then all nested coroutines are cancelled.
     */
        fun showSomeData() = scope.launch { // launched in the main thread
           // ... here we can use suspending functions or coroutine builders with other dispatchers
           draw(data) // draw in the main thread
        }
    }

이 MainScope은 내부 코드를 보면 결국 CoroutineScope을 활용하고 있음을 알 수 있고, 안드로이드 관련 코드를 추가로 담아두었다는 점이다.

@Suppress("FunctionName")
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

한 번 더 타고 들어가면 CoroutineScope을 상속받고, 이를 활용할 수 있는 context만 주입하는 공통적인 코드를 사용하고 있다.

참고로 이 코드는 internal이기에 외부에서는 사용하지 못한다.

internal class ContextScope(context: CoroutineContext) : CoroutineScope {
    override val coroutineContext: CoroutineContext = context
    // CoroutineScope is used intentionally for user-friendly representation
    override fun toString(): String = "CoroutineScope(coroutineContext=$coroutineContext)"
}

만약 MainScope 대신 CoroutineScope을 직접 상속받는다면 아래처럼 작업해야 한다.

class YourActivity : AppCompatActivity(), CoroutineScope {

    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + SupervisorJob()

    override fun onDestroy() {
        super.onDestroy()
        cancel()
    }
}

결국 cancels을 하기 위한 작업은 해줘야 한다.


무조건 cancel을 부른다고 종료하는 것은 아니다.

이 코드를 보고 예측을 해보자.

class CoroutineCancel {

    @Test
    fun testCancel() = runBlocking {
        val job = SupervisorJob()
        CoroutineScope(Dispatchers.IO).launch {
            while (isActive) {
                println("Job active")
                delay(1)
            }
        }

        delay(50)
        job.cancel()
        delay(500)
    }
}

이 코드 내부에 있는 CoroutineScope은 종료하지 않는다.

참고 테스트 코드라서 500ms가 지나면 종료된다.

  • while은 쉬지 않고 돌아간다.
  • 내부의 CoroutineScope에 대한 종료를 호출하는 코드가 없기 때문에 종료하지 못한다.

먼저 이 코드의 while 문을 별도의 suspend 함수로 변경해보자.

private suspend fun suspendLoop() = withContext(Dispatchers.IO) {
    while (isActive) {
        println("Job active")
        delay(1)
    }
}

이 역시 500ms가 넘어가야 종료되는 건 동일하다.

그럼 종료할 수 있도록 수정을 해야 하는데 job을 아래와 같이 추가하면 자연스럽게 종료한다.

val job = SupervisorJob()
CoroutineScope(Dispatchers.IO + job).launch {
    suspendLoop()
}


while에서의 종료

job만 가지고 잘 처리하더라도 종료는 할 수 있다. 이 부분은 간단히 해결할 수 있지만 사실 while 문을 활용할 때 문제가 있는데, 언제 cancel이 일어나는지가 중요하다.

이 코드는 isActive를 체크하다 보니 isActive의 상태에 따라 종료한다.

private suspend fun suspendLoop() = withContext(Dispatchers.IO) {
    while (isActive) {
        println("Job active")
        delay(1)
    }
}

이 부분은 과거에 작성한 job의 상태를 정리한 글을 참고하시면 될 것 같다.

Kotlin Coroutines의 Job 동작을 알아보자

만약 아래와 같이 단순히 무한 루프를 동작 시켰다면 동작하지 않는다.

private suspend fun suspendLoop() = withContext(Dispatchers.IO) {
    while (true) {
        println("Job active")
        delay(1)
    }
}

이 코드를 보면 무한 루프에서는 최소한 isActive를 이용해 제어해야 함을 알 수 있다.


AAC ViewModel의 코루틴 사용 추천 방법

이 번엔 안드로이드에서 가장 많이 사용하는 ViewModel에서의 코루틴 사용 방법을 알아본다.

Use Kotlin coroutines with Architecture components의 viewModelScope을 활용할 수 있는데 다음과 같다.

class MyViewModel: ViewModel() {
    init {
        viewModelScope.launch {
            // Coroutine that will be canceled when the ViewModel is cleared.
        }
    }
}

여기서 말하는 viewModelScop은 어떤 것일까?

이 viewModelScop은 ViewModel ktx를 이용해 사용할 수 있는 extension에 해당한다.

implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:lastVersion'

그래서 내부 코드를 조금 살펴보면 아래와 같은데, 앞에서 보았던 MainScope()의 코드와 동일하지만 Dispatches 사용법은 조금 다름을 알 수 있다.

/**
 * [CoroutineScope] tied to this [ViewModel].
 * This scope will be canceled when ViewModel will be cleared, i.e [ViewModel.onCleared] is called
 *
 * This scope is bound to
 * [Dispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate]
 */
val ViewModel.viewModelScope: CoroutineScope
        get() {
            val scope: CoroutineScope? = this.getTag(JOB_KEY)
            if (scope != null) {
                return scope
            }
            return setTagIfAbsent(JOB_KEY,
                CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
        }

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    override fun close() {
        coroutineContext.cancel()
    }
}

그럼 이 코루틴은 언제 종료할까? 끝에 있는 Closeable의 상속 부분을 중요하게 봐야 한다.

Kotlin extensions use를 알아보고, 사용법을 알아보자. - Closeable 참고

Closeable을 구현하고, AAC-ViewModel의 내부 코드에 보면 clear가 있고, 이 코드에서 closeWithRuntimeException 이란 코드가 있다.

이 코드 내부에서 확인할 수 있듯 Closeable인 경우 close를 한 번 더 호출할 수 있음을 알 수 있다.

정리하면 Android onDestroy가 호출되면 ViewModel의 clear가 호출되고, closeWithRuntimeException이 호출되어 자연스럽게 AAC-ViewModel의 viewModelScop을 함께 종료한다.

public abstract class ViewModel {

  // 생략

  @MainThread
  final void clear() {
      mCleared = true;
      // Since clear() is final, this method is still called on mock objects
      // and in those cases, mBagOfTags is null. It'll always be empty though
      // because setTagIfAbsent and getTag are not final so we can skip
      // clearing it
      if (mBagOfTags != null) {
          synchronized (mBagOfTags) {
              for (Object value : mBagOfTags.values()) {
                  // see comment for the similar call in setTagIfAbsent
                  closeWithRuntimeException(value);
              }
          }
      }
      onCleared();
  }

  private static void closeWithRuntimeException(Object obj) {
      if (obj instanceof Closeable) {
          try {
              ((Closeable) obj).close();
          } catch (IOException e) {
              throw new RuntimeException(e);
          }
      }
  }
}


마무리

CoroutineScope의 다양한 접근 방법을 확인해 보았다.

일부는 알아서 처리해 주는 것도 있지만 결국 사용자가 잘 알고 써야 할 부분들이 많다. 위에서 보았든 MainScope을 쓰고, CoroutineScope을 썼다면 종료 처리는 필수이다.

그리고 cancel을 한다고 해서 무조건 종료하는 것 역시 아님을 알 수 있었다.

정리하면

  • Lifecycle에 따른 Scope 활용이 필요하다.
  • Lifecycle에 따라 Scope의 cancel은 꼭 호출해야 한다.
  • cancel을 한다고 해서 모든 Scope가 무조건 종료하는 것은 아니다.
  • 무한 루프에서는 cancel을 하기 위해서는 isActive를 체크해 줘야 한다.


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

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

Comments