Android에서 사용하는 CoroutineScope을 알아보자



개인 광고 영역

Kotlin coroutines은 기존 Java Thread, 안드로이드에서 제공하는 AsyncTask, ReactiveX 패러다임을 일부 구현한 RxJava를 대신할 수 있는 Asynchronous/Non-blocking programming을 제공한다.

참고로 기존에 작성하였던 글에서 Kotlin Coroutines을 알아보고, 안드로이드에 library 적용하기 Java Thread/AsyncTask/RxJava 활용에 대한 내용이 포함되어 있다.

coroutines은 사용하기 쉽고, 적용하기도 쉽다.

우선 안드로이드 환경에서 코틀린을 적용하는 방법은 간단하다.

Github의 kotlinx.coroutines에서 Apache License, Version 2.0으로 공개되어 있는 coroutines 소스를 확인할 수 있다.

build.gradle 파일에 아래 coroutines-android를 적용함으로써 코루틴의 사용 준비는 끝이 난다.

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'

사이트에 나와있지만 당연히 Kotlin 개발 환경에서 위 dependencies 적용이 가능하다.


Coroutine 접근하는 방법

필자가 작성한 앞선 글에서도 확인할 수 있지만, 코루틴은 두 가지 접근 방법을 통해 사용이 가능하다.

코루틴을 활용하는 데 있어 CoroutineScope이 interface로 정의되어 있다. 이 interface 정의를 통해 매번 원하는 형태의 CoroutineContext를 정의할 수 있고, Coroutines의 생명 주기를 관리할 수 있다.

CoroutineScope.kt 파일 안에는 이러한 interface 정의되어 있으며, CoroutineScope을 상속 받아 CoroutineScope과 GlobalScope 등에서 이를 활용하고 있다.

public interface CoroutineScope {
    /**
     * Context of this scope.
     */
    public val coroutineContext: CoroutineContext
}

어플리케이션이 동작하는 동안 별도의 생명 주기를 관리하지 않고 사용할 수 있는 GlobalScope이 있다. 이는 안드로이드 앱이 처음 시작부터 종료 할때까지 하나의 CoroutineContext 안에서 동작하도록 할 수 있다.

이러한 CoroutineScope과 GlobalScope을 각각 알아보도록 한다.


필요할 때 선언하고, 종료하자

필요할 때 선언하고, 종료하는 게 필요하다. 예를 들면 아래와 같을 수 있겠다.

현 재 화면을 벗어나면 더 이상 통신을 할 필요가 없다.

  • 리스트를 갱신하기 위한 최신 데이터를 불러온다.
  • 리스트를 불러오는 중 서버 응답이 느려 답답해진 사용자는 창을 닫아버린다.

이 경우는 백그라운드에서 계속 다운로드할 필요가 없어진다.(캐싱을 하는 앱이라면 이후 처리가 필요하므로 별도의 작업을 하겠지만) 이 경우 굳이 GlobalScope을 활용하여 이 작업을 살려둘 필요가 없어진다.

이 경우에 매번 새롭게 생성하는 CoroutineScope을 활용함으로써 효율을 높일 수 있다.

이러한 CoroutineScope의 내부 초기화 코드는 아래와 같은데, 이는 fun으로 시작해서 함수이다.

@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job())

그래서 내부에 정의되어 있는 클래스는 ContextScope이라는 클래스를 초기화해서 사용하고 있음을 알 수 있다.

internal class ContextScope(context: CoroutineContext) : CoroutineScope {
    override val coroutineContext: CoroutineContext = context
}

결국 CoroutineScope은 CoroutineContext(Main(안드로이드에서는 UI thread)/IO thread)를 통해 원하는 형태로 초기화하는 하나의 Block을 만들어 사용함을 알 수 있다.

이를 사용할때는 아래와 같다.


앱이 동작하는 동안 사용해보자.

이번엔 앱이 동작하는 동안 사용할 수 있는 GlobalScope을 알아보자.

당연히 앱을 사용하면서 장시간 동작해야 할 thread가 필요하다면 매번 생성하는 CoroutineScope보다는 최초 접근 시 만들어지는 GlobalScope이 효율적이다.

이러한 GlobalScope을 사용하더라도, 안드로이드 환경에서는 백그라운드 잡이 또 필요하여 WorkManager을 활용해야 한다.

CoroutineScope + WorkManager 활용 예)

다만, GlobalScope은 Job을 컨트롤하기에 접합하지 않음에 주의해야 하는데, 하나하나의 launch/actor 등에 CoroutineContext와 Job을 함께 사용하지 않으면 제어가 쉽지 않다.

이러한 GlobalScope은 interface CoroutineScope을 상속받아 구현되어있는데, 기본 CoroutineContext를 EmptyCoroutineContext을 활용하고 있다.

public object GlobalScope : CoroutineScope {
    /**
     * Returns [EmptyCoroutineContext].
     */
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

GlobalScope을 안드로이드의 테스트 코드에서 동작하면 아래와 같은 ThreadType을 확인할 수 있는데, 별도로 지정하지 않은 상태에서는 IO가 기본으로 동작한다.

Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main]

이런 GlobalScope의 좋은 예는 API 상으로 아래와 같이 소개하고 있다.

fun ReceiveChannel<Int>.sqrt(): ReceiveChannel<Double> = GlobalScope.produce(Dispatchers.Unconfined) {
  for (number in this) {
    send(Math.sqrt(number))
  }
}


Android 환경에서의 CoroutineScope 활용하기

Android 환경에서는 CoroutineScope을 활용하기 위해서 CoroutineScope을 상속받아고, 이를 Android Lifecycle에 맞게 사용하는 걸 권장하고 있고 Activity가 완전히 내려가는 onDestroy에서 Job을 종료하는 걸 추천하고있다.

참고로 필자가 구현한 방법은 CoroutineScope을 Activity/Fragment/ViewModel/LifecycleObserver 등에서 활용할 수 있도록 하다 보니 Kotlin delegation을 활용하여 구현하여 일부 코드를 여기에 붙였다.

여기에 나오는 코드들은 아래 링크를 통해 확인이 가능하다.

CoroutinesUIExtensions


CoroutineScope을 interface로 정의

interface BaseCoroutineScope : CoroutineScope {

    val job: Job

    /**
     * Coroutine job cancel
     */
    fun releaseCoroutine()
}

이 코드에서 중요한 부분은 Job 부분이다. CoroutineScope을 상속받아 사용할 경우에는 CoroutineScope의 동작을 제어할 녀석이 필요하다. 이를 Job을 이용하여 할 수 있다.

이러한 Job은 안드로이드 상에서는 Lifecycle을 활용할 수 있도록 도와주는데 아래와 같은 job의 동작 방법을 알 수 있다.

coroutine-job

Job에 대해서는 다음 글에서 좀 더 자세히 다루어보겠다.


BaseCoroutineScope을 상속받아 CoroutineScope을 구현

필자는 Delegation 패턴을 활용하기 위해서 미리 UICoroutineScope을 구현해보았다.

UICoroutineScope에서는 Job을 추하였고, CoroutineContext를 Main 스케줄러를 활용하도록 초기화하였다.

class UICoroutineScope(private val dispatchers: CoroutineContext = DispatchersProvider.main) : BaseCoroutineScope {

    override val job: Job = Job()

    override val coroutineContext: CoroutineContext
        get() = dispatchers + job

    override fun releaseCoroutine() {
        if (DEBUG) {
            Log.d("UICoroutineScope", "onRelease coroutine")
        }
        job.cancel()
    }
}

UICoroutineScope을 delegation으로 적용하면 위 코드를 그대로 가져가 job과 CoroutineContext을 함께 활용하게 된다.


Activity에서 상속 구현

BaseCoroutineScope을 바로 변수 scope인 UICoroutineScope을 활용하도록 만들었다.

abstract class CoroutineScopeActivity @JvmOverloads constructor(scope: BaseCoroutineScope = UICoroutineScope())
    : AppCompatActivity(), BaseCoroutineScope by scope {

    override fun onDestroy() {
        super.onDestroy()

        releaseCoroutine()
    }
}

앞으로 CoroutineScopeActivity을 활용하는 경우에는 항상 Android Main thread를 동반하고, launch에 Job이 포함되어 하위 잡들까지 컨트롤이 가능한 형태를 만들었다.


원래 형태는

위 코드는 필자가 필요로 인해서 간단하게 구현한 베이스에 해당한다. 그렇다면 위 코드를 모두 합쳐서 사용하면 어떻게 해야 할까?

간단하게 아래와 같이 CoroutineScope을 상속받아, 직접 구현해주면 되겠다.

좀 더 쉽게 사용하기 위해서 Delegation 패턴을 활용하였을 뿐 크게 다른 부분은 없다.

class MyActivity : AppCompatActivity(), CoroutineScope {

  // Job을 등록할 수 있도록 초기화
  lateinit var job: Job

  // 기본 Main Thread 정의와 job을 함께 초기화
  override val coroutineContext: CoroutineContext
      get() = Dispatchers.Main + job

  override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      job = Job()
  }

  // 작업 중이던 모든 job을 종 children을 종료 처리
  override fun onDestroy() {
      super.onDestroy()
      job.cancel() // Cancel job on activity destroy. After destroy all children jobs will be cancelled automatically
  }
}


그럼 샘플은?

위와 같이 만들었다면 별도의 CoroutineScope을 초기화 할 필요 없이 아래와 같이 launch {}로 바로 사용이 가능하다. 필요하다면 launch()에 thread를 변경할 수 있다.

/*
 * Note how coroutine builders are scoped: if activity is destroyed or any of the launched coroutines
 * in this method throws an exception, then all nested coroutines are cancelled.
 */
fun loadDataFromUI() = launch { // <- extension on current activity, launched in the main thread
   val ioData = async(Dispatchers.IO) { // <- extension on launch scope, launched in IO dispatcher
       // blocking I/O operation
   }
   // do something else concurrently with I/O
   val data = ioData.await() // wait for result of I/O
   draw(data) // can draw in the main thread
}


코루틴의 스레드 형태를 어떻게 가져갈지 정의(Dispatchers.Main, Dispatchers.Default)

앞에서도 이야기하지 않은 Dispatches라는 게 있다. 이는 코루틴의 스레드를 어떠한 형태로 가져갈지를 지정할 수 있다.

일반적으로 IO 스레드와 Main 스레드가 있다. 안드로이드 환경에서는 IO는 백그라운드 잡을 말하고, Main은 UI thread를 뜻한다.

사용방법은 간단한데 CoroutineScope을 정의할 때 아래와 같이 사용 가능하다.

CoroutineScope(Dispatches.IO).launch(Dispatches.Main) {

}

이 코드에서는 CoroutineScope은 IO thread에서 동작하도록 지정해주었고, 이어서 launch에서 Main으로 변경해버렸다. 실제 동작은 IO가 아닌 Main에서 동작하게 된다.

IO로 글로벌하게 잡아두고, 특정 영역에서만 Main으로 교체하고 싶다면 아래와 같이 할 수 있다.

CoroutineScope(Dispatches.IO).launch {
  launch(Dispatches.Main) {
    // UI Thread
  }

  launch {
    // IO Thread
  }

  // IO Thread
}

Dispatchers.Main

Dispatchers.Main은 안드로이드 용으로 제공하는 thread. Java Handler가 기본으로 초기화되어 사용된다.

Dispatches execution onto Android main thread and provides native [delay] support.

Dispatchers.Default

Dispatchers.Default은 모든 launch, async, etc에서 사용하는데, ContinuationInterceptor을 지정하지 않을 경우 기본으로 사용하는 CoroutineDispatcher이다.


마무리

기존에 작성했던 코루틴 관련 글 중에 최신화 부분을 다시 정리하였다. 다음 글에서 Job 부분을 위해서 좀 더 심도 있게 정리해보았다.



About Taehwan

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

Comments