Overview

안드로이드 생태계에서 코틀린이 2011년 등장해서 2017년에 구글에서 공식 언어로 추가 한 이후에 매우 빠르게 안드로이드 개발자에게 전파되었습니다. Android Studio 3.0 부터 코틀린을 정식으로 지원하기 시작하였습니다. 2019 Google I/O에서 Kotlin first로 선언하였습니다. 공식 개발자 사이트에서도 코틀린 샘플이 먼저 문서화되기 시작하였습니다.

코틀린은 출시한지 6년만에 플랫폼 공식 언어로 지정되어 현재는 신규 프로젝트의 경우 대부분 코틀린으로 진행되고 있습니다. 꾸준히 성장하는 코틀린은 2020년도 StackOverflow 개발자 설문조사에서는 프로그래밍 언어 인기 순위 13위에 랭크되기도 하였습니다.

코틀린이 대세로 정해짐에 따라서 비동기 처리를 구현하는 방식이 AsyncTask, RxJava 등을 이용하는 방식에서 코틀린에서 제공해 주는 코루틴을 사용하는 것이 현재 개발자 사이에서 점차 전파되고 있습니다.

현재 브랜디에서는 신규 코드 작성 시 90% 이상을 코틀린으로 작성하고 있습니다. 아직은 RxAndroid 등을 사용하고 있고, 추후에 코루틴을 검토하기 위해서 개발자들이 열심히 준비하고 있습니다. 이를 위해서 Coroutines 기본적인 내용을 정리하여 보았습니다.

코루틴이란

코루틴을 코틀린을 통해서 처음 접하게 되는 경우는 코틀린에서 생긴 새로운 개념으로 생각하기 쉽지만, 코루틴은 파이썬, C#, Go, Javascript 등 여러 언어에서 지원하고 있는 개념입니다. 코루틴은 새로운 개념, 새로운 기술이 아니라 프로그래밍이 세상에 나온 초창기부터 존재하던 개념입니다.

*코루틴은 서브 루틴을 일시 정지하고 재개할 수 있는 구성 요소를 말한다.

쉽게 말해 필요에 따라 일시 정지할 수 있는 함수를 말합니다.

Thread vs Coroutine

Thread

  • OS의 Native Thread에 직접 링크되어 동작하여 많은 시스템 자원을 사용한다.
  • Thread간 전환 시에도 CPU의 상태 체크가 필요하므로 그만큼의 비용이 발생한다.

Coroutine

  • 코루틴은 즉시 실행하는 게 아니며, Thread와 다르게 OS의 영향을 받지 않아 그만큼 비용이 들어가지 않는다.
  • 코루틴 전환시 Context Switch가 일어나지 않는다.
  • 개발자가 직접 루틴을 언제 실행할지, 언제 종료할지 모두 지정이 가능하다.
  • 이렇게 생성한 루틴은 작업 전환 시에 시스템의 영향을 받지 않아 그에 따른 비용이 발생하지 않는다.

Android의 Kotlin 코루틴

코루틴은 비동기적으로 실행되는 코드를 간소화하기 위해 Android에서 사용할 수 있는 동시 실행 설계 패턴입니다. 코루틴은 Kotlin 버전 1.3에 추가되었으며 다른 언어에서 확립된 개념을 기반으로 합니다.

  • 경량: 코루틴을 실행 중인 스레드를 차단하지 않는 정지를 지원하므로 단일 스레드에서 많은 코루틴을 실행할 수 있습니다. 정지는 많은 동시 작업을 지원하면서도 차단보다 메모리를 절약합니다.
  • 메모리 누수 감소구조화된 동시 실행을 사용하여 범위 내에서 작업을 실행합니다.
  • 기본으로 제공되는 취소 지원: 실행 중인 코루틴 계층 구조를 통해 자동으로 취소가 전달됩니다.
  • Jetpack 통합: 많은 Jetpack 라이브러리에 코루틴을 완전히 지원하는 확장 프로그램이 포함되어 있습니다. 일부 라이브러리는 구조화된 동시 실행에 사용할 수 있는 자체 코루틴 범위도 제공합니다.

구글에서는 Jetpack을 통해서 여러가지 라이브러리를 제공해 주고 있습니다. Jetpack 예제 등을 보면 코루틴 등이 이미 광범위하게 사용되고 있습니다. Jetpack을 제대로 사용하기 위해서도 코루틴을 준비해야 될 것 같습니다.

coroutines

일반적으로 러닝커브는 RxJava에 비해 코루틴이 초기에는 더 쉽고, Channel 등을 활용하는 경우에는 러닝커브가 급속하게 늘어나는 것으로 알려져 있습니다.

안드로이드에서 비동기를 처리하는 대표적인 AsyncTask / RxJava 와 실제 코드를 비교해 보시면 코루틴의 장점을 쉽게 확인해 보실 수 있습니다.

AsyncTask

val asyncTask = object : AsyncTask<Unit, Unit, String>() {
    override fun doInBackground(vararg params: Unit?): String {
        return load()
    }

    override fun onPreExecute() {
        super.onPreExecute()
    }

    override fun onPostExecute(result: String?) {
        super.onPostExecute(result)
    }
}
asyncTask.execute()

RxJava 2.0

load()
      .subscribeOn(Schedulers.io())
      .observeOn(Schedulers.io())
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe {
      }

Coroutine

CoroutineScope(Dispatchers.Main).launch {
    var data = ""
    CoroutineScope(Dispatchers.Default).async {
        // background thread
        data = load()
    }.await()
}

코루틴을 사용하는 방법은 간단합니다.

특정 Scope에 실행될 Dispatcher를 넘겨 주고 launch, async 등을 이용해서 이를 실행하면 됩니다. suspend 함수는 중지되거나 취소될 수 있기 때문에 코루틴 안에서 실행되어야 합니다. suspend 함수를 코루틴 밖에서 호출 시에는 에러가 발생하게 됩니다.

코루틴 빌더 Launch & Async

launch와 async는 CoroutineScope의 확장함수이며, 넘겨 받은 코드 블록으로 코루틴을 만들고 실행해주는 코루틴 빌더 입니다.

공통점

  1. 새로운 코루틴을 만든다.
  2. 하나의 Dispatcher를 가진다.
  3. 스코프 안에서 실행된다.
  4. suspend 함수가 아니다.
    • Launch – 현재 스레드를 차단하지 않고 새로운 코루틴을 싱행하고 코루틴을 제거하는데 사용 할 수 있는 작업으로 코루틴에 대한 참조를 반환합니다. Job을 리턴, 결과 값이 필요하지 않은 경우에 사용합니다.
    • Async – 새로운 코루틴을 실행하고 지연후 결과를 반환합니다. async()는 Deferred라는 객체를 반환합니다. 그리고 await()를 호출하면 그때 async() 블록이 실행되고 그 실행의 결과를 반환하게 됩니다.

지연 실행

launch 코루틴 블록과 async 코루틴 블록은 모두 처리 시점을 뒤로 미룰수 있습니다.

각 코루틴 블록 함수의 start 인자에 CoroutineStart.LAZY 를 사용하면 해당 코루틴 블록은 지연 되어 실행됩니다. launch 코루틴 블록을 지연 실행 시킬 경우 Job 클래스 의 start() 함수 를 호출하거나 join() 함수를 호출하는 시점에 launch 코드 블록이 수행됩니다.

val job = launch (start = CoroutineStart.LAZY) {
    ...
}
또는
val deferred = async (start = CoroutineStart.LAZY) {
    ...
}
job.start() , deferred.start()
또는
job.join() , deferred.await()


Dispatchers

CoroutineContext 을 상속받아 어떤 스레드를 이용해서 어떻게 동작할것인지를 미리 정의해 두었습니다. 코루틴의 Dispatcher는 Dispatchers.IO를 포함하여 총 4가지가 있습니다.

Dispatchers.Main / Dispatchers.IO /Dispatchers.Default / Dispatchers.Unconfined

IO는 네트워크나 디스크 작업 즉, Input/Output 작업에 최적화되어있습니다.

Default는 CPU를 많이 사용하는 작업에 사용합니다. ex) 리스트 정렬

Unconfined는 다른 Dispatcher 와는 달리 특정 스레드 또는 특정 스레드 풀을 지정하지 않습니다. 일반적으로는 사용하지 않으며 특정 목적을 위해서만 사용됩니다.

마지막으로 Main은 UI 작업이나, 쓰레드를 막지 않고 빨리 실행되는 작업에 사용합니다.

Scope

스코프는 그 스코프에서 생성된 코루틴을 계속해서 주시하면서 실행을 취소하거나, 실패 시 예외를 처리할 수 있게 해줍니다.

구글에서는 Jetpack에서 코루틴을 쉽게 사용할 수 있도록 컴포넌트 별로 생명주기에 따른 Scope를 아래와 같이 제공해줍니다.

  • Activity, Fragment — lifecycleScope
  • View (in ⍺)— ViewTreeLifecycleOwner + lifecycleScope
  • ViewModel — viewModelScope
  • Service — LifecycleService + lifecycleScope
  • Application — ProcessLifecycleOwner + lifecycleScope

일반적으로 제일 많이 사용하는 케이스인 ViewModel에서 viewModelScope를 제공해주고 있어서 RxJava에서 처럼 Disposable를 추가적으로 하지 않아도 됩니다.

실제로 가장 많이 사용하는 retrofit과 연동하여서 API를 호출하는 부분을 살펴 봅니다.

retrofit 2.6.0 부터는 공식적으로 suspend 를 지원합니다. 내부에서 백그라운드에서 안전하게 처리되도록 되어 있습니다.

@GET("users/{id}")
suspend fun user(@Path("id") id: Long): User

리턴 타입도 Response에서 User로 Response로 싸여진 형태가 아닌 직접적으로 모델데이터를 전달 받을 수 있습니다.

retrofit이나 room을 사용하는 경우에 Dispatchers를 별도로 코루틴 Scope에 전달하지 않아도 내부에서 Dispatchers.IO로 처리를 해 줍니다.

class ActivityViewModel: ViewModel() {
    private fun getMarsRealEstateProperties() {
        viewModelScope.launch {
            try {
                val listResult = MarsApi.retrofitService.getProperties()
                _response.value = "Success: ${listResult.size} Mars properties retrieved"
            } catch (e: Exception) {
                _response.value = "Failure: ${e.message}"
            }
        }
    }
}


Job

Coroutines을 컨트롤하기 위해서 job을 제공해줍니다. 이러한 Job을 바탕으로 코루틴의 상태를 확인할 수 있고, 제어할 수 있다. 명시적으로 코루틴의 작업이 완료되는 것을 기다릴때는 join()함수를, 작업을 취소할때는 cancel()함수를 이용할 수 있습니다.

val job = launch {
    repeat(1000) { i ->
        println("job: I'm sleeping $i ...")
        delay(500L)
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")

// 실행 결과
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

취소가 가능한 코루틴 블록 만들기 - job.cancel()을 호출한다 해도 반드시 취소가 되는 것은 아닙니다.

아래와 같이 연속된 계산작업인 경우에는 취소되지 않습니다.

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (i < 5) { // computation loop, just wastes CPU
				// yield() //여기에 추가하면 정상적으로 취소된다
        // print a message twice a second
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")

이런 computation code를 취소시키는 방법은 두가지입니다.

  • yield function을 이용하여 추기적으로 취소를 체크하는 suspend function을 invoke 시킨다.
  • 명시적으로 취소 상태를 체크한다. (isActive 이용)

일일히 Job으로 cancel하지 않고, 특정 시간이후에 취소하도록 하려면 withTimeout을 사용하면 됩니다.

android에서 withTimeout 쓸때는 꼭 try-catch로 묶어서 사용해야 앱이 중지 되는걸 방지할 수 있습니다. 아니면 withTimeoutOrNull을 사용하여 exception throw 대신 null을 return 받도록 하면 됩니다.

withTimeout(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
}
I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms

val result = withTimeoutOrNull(1300L) {
    repeat(1000) { i ->
        println("I'm sleeping $i ...")
        delay(500L)
    }
    "Done" // will get cancelled before it produces this result
}
println("Result is $result")
                                        wait children
    +-----+ start  +--------+ complete   +-------------+  finish  +-----------+
    | New | -----> | Active | ---------> | Completing  | -------> | Completed |
    +-----+        +--------+            +-------------+          +-----------+
                     |  cancel / fail       |
                     |     +----------------+
                     |     |
                     V     V
                 +------------+                           finish  +-----------+
                 | Cancelling | --------------------------------> | Cancelled |
                 +------------+                                   +-----------+

job states

State isActive isCompleted isCancelled
New (optional initial state)
Active (default initial state)
Completing (transient state)
Cancelling (transient state)
Cancelled (final state)
Completed (final state)

Job이 활성화되면 활성 상태인 Active 상태가 되지만, Job() 팩토리 함수에 인자로 CoroutineStart.LAZY를 설정하면 활성상태가 아닌 New 상태로 생성됩니다. New 상태의 job을 Active 상태로 만들기 위해서는 start()나 join() 함수를 호출해야 합니다.

Conclusion

Android는 코틀린 도입 이후 Jetpack 등을 통해서 역동적으로 변화하고 있습니다. 매년 새로운 기술이 적용된 신규 라이브러리가 추가되고 이를 구글에서는 샘플 등으로 제공해 주고 있습니다. Kotlin에서는 비동기 처리를 쉽게 하기 위해서 기존에는 RxAndroid와 같은 외부 라이브러리를 사용했던 것을 Kotlin Coroutines과 같이 라이브러리로 제공해주고 있습니다. 저희 브랜디 MA팀에서도 이러한 신규 기술 트렌드를 따라가기 위해서 매우 열심히 노력하고 있습니다. Kotlin Coroutines도 열심히 학습하여서 이를 브랜디에 반영하여서 더욱 더 좋은 앱을 만들도록하겠습니다.

참고 사이트

https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html
https://github.com/Kotlin/kotlinx.coroutines/blob/master/coroutines-guide.md


장순철 | MA팀
jangsc@brandi.co.kr
브랜디, 오직 예쁜 옷만