바삭한 신입들의 동시성 이야기 - Kotlin 편

박세란

안녕하세요. 마이쿠키런 Android 파트 개발을 맡고 있는 박세란입니다.

마이쿠키런은 데브시스터즈에서 신규 개발중인 쿠키런 팬들을 위한 플랫폼 서비스입니다. 와글와글한 커뮤니티부터 두근두근 쿠키런 오리지널 웹툰까지 다양한 컨텐츠들을 서빙해야하는 마이쿠키런 앱에 쾌적한 사용자 경험을 위한 비동기 처리는 필수적입니다. 이에 마이쿠키런 Android 팀은 Coroutine을 사용하여 신규 개발을 진행하고 있습니다.

작년 여름 입사한 신입이 Coroutine을 직접 사용하며 차곡차곡 공부한 내용을 공유하고자 합니다. 이번 포스팅에서는 Kotlin Coroutine을 가독성과 성능 측면에서 다른 방법들과 비교해보고, 내부 동작 구조에 대해 알아보겠습니다.

Summary

  • Main-Safe하다는 것은 중요한 역할을 맡고 있는 메인스레드를 블락하지 않는다는 것이다.
  • 막힘 없는 앱 환경을 제공하기 위해서는 Main-Safe하게 개발해야하고, 이를 위해 여러 스레드를 활용하여 동시성 코드를 짜야한다.
  • 동시성 프로그래밍을 할 때 콜백보다 Coroutine을 사용하는 것이 가독성이 더 좋다.
  • CPS는 함수 호출 시에 Continuation을 전달하는 패러다임이며, Kotlin Coroutine 역시 CPS로 구현되었다. Kotlin 컴파일러는 suspend 함수를 CPS 패러다임을 구현한 코드로 변환해준다.
  • 멀티 스레드보다 Coroutine을 활용하는 것이 불필요한 스레드 생성과 blocking을 없애 성능이 더 좋다.

Intro

본론으로 들어가기 전, 막연한 질문부터 던져보겠습니다. 막힘 없는 앱은 얼마나 빨라야할까요?

16ms 마다 한 프레임

text
사람이 빠르게 움직이는 이미지를 동작으로 인식하는 매커니즘 Youtube@Andymation

사람의 눈과 뇌는 스냅샷이나 프레임의 개념이 아닌, 사람의 눈이 보내는 신호를 뇌가 끊임없이 받아들이는 구조입니다. 즉, 연속해서 빠르게 보여지는 이미지를 동작으로 인식하는 구조입니다 (어떻게 보면 착시현상이죠). 따라서 빠르게 보여지는 이미지를 빠짐없이 모두 인식하는 것엔 한계가 있는데요. 보통 사람의 눈은 초당 60프레임인 모션과 그 이상의 모션의 큰 차이를 느끼지 못한다고 합니다.

1000ms/ 60 frames = 16.666 ms/ frame

그래서 사용자가 “와 막힘 없다!”라고 느낄 수 있는 매끄러움을 위해선 초당 60 프레임을 그려야 하는 것이고, 이를 역으로 계산해보면, 16ms마다 한 프레임을 그리는 작업(입력, 처리, 통신, 렌더링)을 완료 해야한다는 것을 의미합니다.

하지만, 앱에 필요한 작업 중에서 16ms 보다 긴 처리 시간을 요구하는 작업을 흔히 찾아볼 수 있습니다. 네트워크 통신, 데이터베이스 작업, JSON 파싱 등등이 그 예 인데요. 이 긴 작업이 메인스레드에서 이뤄진다고 가정하면, 메인스레드의 본래 역할에 영향을 줄 수 있습니다. 메인스레드에서의 긴 작업은 화면 버벅임이나 ANR을 발생시켜 사용자 만족도를 떨어트리는 요인이 되곤 합니다.

그러므로 앱 개발자에겐 사용자가 앱을 쓰는 동안에는 항상 초당 60 프레임 업데이트를 유지해야 한다는 의무가 주어지고, 이를 지키기 위해 안드로이드 개발에 있어 메인스레드를 blocking하지 않는 것은 매우 중요한 일이 됩니다. 이 중요성은 UI 스레드인 메인스레드에서 네트워킹 작업을 실행하면, NetworkOnMainThreadException를 발생시키며 애플리케이션을 중단하는 등 안드로이드 프레임워크가 뒷받침해 주고 있습니다. (안드로이드 3.0 이상 부터)

한편 메인스레드를 blocking하지 않는 것은 왜 중요할까요 ?

Main-Safety

Unsplash@jannerboy62
Unsplash@jannerboy62

메인스레드가 생성되고 시작되는 곳, main 함수를 찾아가 보겠습니다. android.app.ActivityThread 클래스의 main 함수에서 메인스레드가 컴포넌트 생명주기 메서드와 그 안의 메서드 호출, UI 작업을 실행한다는 것을 확인할 수 있습니다.

그 중 UI 작업은 메인스레드에서만 실행될 수 있다는 특징이 있습니다. 이렇게 특정 작업이 하나의 스레드에서만 실행될 수 있게 제한하는 것을 Single-Thread 모델이라고 하는데, 자원 접근에 대한 동기화를 신경 쓰지 않아도 되고, 경쟁 상태(race condition)와 교착 상태(deadlock)도 예방할 수 있다는 이점이 있습니다. 또한 작업 전환(context switching) 비용을 요구하지 않기 때문에 애플리케이션의 응답성을 유지하는 데 도움을 줍니다. 이러한 이점을 얻기 위해, UI 작업은 메인스레드에서만 실행되도록 제한되었으며, UI 스레드라는 용어가 생기게 되었습니다.

16ms 마다 한 프레임의 이야기를 다른 관점에서 이해해보면,UI 작업은 메인스레드에서만 이뤄질 수 있기때문에, 메인스레드가 방해되면 원활한 UI 작업이 어려워지게 된다는 점을 이해할 수 있습니다. UI는 사용자와 맞닿는 부분으로 앱 응답성과 직결되므로 중요도가 높은 작업입니다. 따라서 메인스레드가 항상 이 역할을 수행할 수 있도록 앱을 설계해야합니다.

결국 Main-Safe하다는 것은, 중요한 역할을 맡은 메인스레드가 제 역할을 하도록 blocking 하지 않는 것을 의미합니다.

Android Concurrency

Main-Safe하게 앱의 여러 작업을 수행하는 방법의 하나로, 동시성을 사용하는 방법이 있습니다. 하지만 동시성 코드를 제대로 작성하는 것은 좀처럼 간단한 일이 아닙니다. 경쟁 상태, 원자성 위반, 교착 상태, 라이브락과 같은 동시성 이슈들이 개발자들을 괴롭히기 때문입니다. 한편 언어 자체가 동시성 코드를 작성하는 것을 어렵게 하기도 합니다. 언어 문법이 너무 장황하거나, 다양한 상황에 대응할 수 있는 유연함이 없다면 말이죠.

Understand Kotlin Coroutines on Android (Google I/O'19)
Understand Kotlin Coroutines on Android (Google I/O'19)

Google I/O'19 에 따르면, 구글은 매년 개발자 대상으로 그들이 어떻게 지내는지, 그들의 문제는 무엇인지 설문 조사를 한다고 합니다. 2018 Developer Survey 를 통해서 많은 개발자가 Threading, Concurrency에 어려움을 겪고 있다는 것을 파악한 구글은 더 나은 액션을 정의하기 위해 Concurrency UXR (User Experience Research)를 진행했습니다. UXR을 통해 RxJava, LiveData, Coroutine과 같은 기존 솔루션들을 검토하며 알게 된 개발자들의 니즈를 바탕으로, 구글은 2가지 액션을 취하기로 했습니다.

Understand Kotlin Coroutines on Android (Google I/O'19)
Understand Kotlin Coroutines on Android (Google I/O'19)

First Class Coroutines Support : 제트팩에서 Coroutine에 대한 최고 수준의 품질 지원

RxJava Guidance in Docs : 공식문서에 RxJava에 대한 더 많은 지원

이 중에서 구글으로부터 최고 수준의 품질 지원을 받는 Coroutine에 집중해볼 예정인데요. Kotlin Coroutine은 어떤 특징이 있는지 살펴보면서 왜 Coroutine이 선택되었는지 알아보도록 하겠습니다.

Kotlin Coroutine

가독성 이야기 : Callback vs Coroutine

콜백은 백그라운드 스레드를 사용하여 긴 작업을 실행하고, 백그라운드 스레드에서 작업이 완료되면 메인스레드에 정의된 콜백을 호출하여 작업 결과를 알려주는 방법입니다. 긴 작업의 완료 여부를 메인스레드가 busy waiting하고 있지 않기 때문에, 기본적으로 콜백 구조를 사용하면 Main-Safe하게 긴 작업을 처리할 수 있습니다. 한편, 콜백은 Main-Safe한 작업을 하게 해주지만, 반복적인 들여쓰기로 인해 “가독성이 안 좋아질 수 있다”는 단점을 가지고 있습니다. 흔히 콜백 지옥이라고 불리기도 하는데요. 콜백 지옥은 코드를 추론하기 어렵게 만드는 요인 중 하나가 되기도 합니다.

// 순차적인 네트워크 호출을 나타내는 코드 

fun getGingerBrave(api: CookieService): gingerBrave {
    api.makeDough{ dough -> 
        api.addMagicPowder(dough){ -> magicDough
            api.escapeOven(magicDough) { -> cookie
                api.fetchGingerBrave(cookie) { -> gingerBrave
                    Log.d("You can't catch me! I'm the Gingerbre.. I'm Ginger Brave!")
                    return gingerBrave
                }
            }
        }
    }
}

콜백과 Coroutine은 Main-Safe한 작업을 하게 해준다는 점에서 같은 일을 하지만, Coroutine을 사용한다면 위에서 언급한 콜백의 가독성 문제를 없앨 수 있습니다. Coroutine은 콜백 기반 코드를 sequential code로 바꾸어주기 때문에 비동기 코드를 단순화할 수 있습니다.

suspend fun getGingerBrave(api: CookieService): gingerBrave {
    val dough = api.makeDough()
    val magicDough = api.addMagicPowder(dough)
    val cookie = api.escapeOven(magicDough)
    val gingerBrave = api.fetchGingerBrave(cookie)
    Log.d("You can't catch me! I'm the Gingerbre.. I'm Ginger Brave!")
    return gingerBrave
}

코드를 보면서 Coroutine과 콜백을 가독성 측면에서 비교해보도록 하겠습니다. 동기 코드처럼 표현된 Coroutine에서는 들여쓰기가 사라져 코드 가독성이 좋아졌으며, 중간의 콜백 람다 블록들이 사라져 현재 위치를 더 추론하기 쉬워졌습니다. 코드의 가독성과 추론성은 예측 가능한 프로그램을 만드는 데에 도움을 준다는 점에서 에러 핸들링과 디버깅과 같은 작업에서 큰 이점을 줍니다.

그럼 Coroutine은 어떻게 콜백 없이 동작할 수 있는걸까요? 간단히 요약하자면, 놀랍게도 Kotlin의 suspend 키워드는 내부적으로 콜백을 생성합니다. 즉, suspend 키워드를 만난 Kotlin 컴파일러는 suspend-resume을 위한 최적화된 콜백 코드를 생성합니다. 한번 변환 과정을 살펴볼까요?

suspend의 속사정

Kotlin 컴파일러가 suspend 키워드를 만나면 어떻게 동작하는지 간단하게 모방해보겠습니다. 생소하게 느껴질 수 있는 개념이어서, 임의로 4개의 단계로 나누어 설명합니다.

Step 1. CPS 패러다임 - Continuation 객체를 사용하다!

Coroutine에는 CPS(Continuation Passing Style) 패러다임이 적용되어 있습니다. CPS란 호출되는 함수에 Continuation을 전달하고, 각 함수의 작업이 완료되는 대로 전달받은 Continuation을 호출하는 패러다임을 의미합니다. 이러한 의미에서 Continuation을 일종의 콜백으로 생각할 수 있습니다.

그럼 이 Continuation이 무엇이고, CPS에서 Continuation을 전달하는 이유는 무엇일까요?

/**
 * Interface representing a continuation after a suspension point that returns a value of type `T`.
 */
@SinceKotlin("1.3")
public interface Continuation<in T> {
    /**
     * The context of the coroutine that corresponds to this continuation.
     */
    public val context: CoroutineContext

    /**
     * Resumes the execution of the corresponding coroutine passing a successful or failed [result] as the
     * return value of the last suspension point.
     */
    public fun resumeWith(result: Result<T>)
}

https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/src/kotlin/coroutines/Continuation.kt

Continuation은 다음에 무슨 일을 해야 할지 담고 있는 확장된 콜백입니다. 명확한 이해를 위해, Kotlin에 정의된 Continuation 인터페이스를 확인해봐야겠죠? Continuation 인터페이스엔 크게 context 객체와 resumeWith라는 함수가 있습니다.

  • resumeWith는 특정 함수 a가 suspend 되어야 할 때, 현재 함수에서 a의 결과 값을 T로 받게 해주는 함수입니다.
  • context는 각 Continuation이 특정 스레드 혹은 스레드 풀에서 실행되는 것을 허용해줍니다.

감이 오시나요? CPS에선 Continuation을 전달하면서, 현재 suspend된 부분에서의 resume을 가능하게 해줍니다. Continuation은 resume되었을 때의 동작 관리를 위한 객체로, 연속적인 상태간의 communicator라고 생각할 수 있습니다.

요약하자면 Continuation은 호출 함수간의 suspend-resume을 위한 communicator이고, CPS는 함수 호출 시에 이 Continuation을 전달하는 패러다임입니다. Kotlin Coroutine 역시 CPS로 구현되었습니다.

Step 2. suspend fun의 시그니처가 변경되다!

Continuation은 Kotlin 컴파일러가 suspend 함수의 시그니처를 변경하면서 사용됩니다.

(1) Continuation 객체가 기존 suspend 함수의 파라미터에 추가되고 suspend 키워드가 사라집니다.

(2) 이 객체는 suspend 함수 계산의 결과를 호출한 Coroutine에 전달하는 데 사용됩니다.

(3) 또한 함수의 리턴 타입 또한 gingerBrave에서 Unit으로 변경되었는데요. 결과는 resume 함수의 매개변수를 통해 얻을 수 있게 됩니다.

suspend fun getGingerBrave(api: CookieService): gingerBrave { // (1) 
    val dough = api.makeDough()
    val magicDough = api.addMagicPowder(dough)
    val cookie = api.escapeOven(magicDough)
    val gingerBrave = api.fetchGingerBrave(cookie)
    Log.d("You can't catch me! I'm the Gingerbre.. I'm Ginger Brave!")
    return gingerBrave //(2)(3)
}
fun getGingerBrave(api: CookieService, completion: Continuation<Any?>) { // (1) 
    val dough = api.makeDough()
    val magicDough = api.addMagicPowder(dough)
    val cookie = api.escapeOven(magicDough)
    val gingerBrave = api.fetchGingerBrave(cookie)
    Log.d("You can't catch me! I'm the Gingerbre.. I'm Ginger Brave!")
    completion.resume(gingerBrave) //(2)(3)
}

Step 3. State machine - Suspension Points를 기준으로 코드 블록이 구분되다!

자 그래서 대체 어떻게 suspend, resume 연산이 가능한 걸까요?

fun getGingerBrave(api: CookieService, completion: Continuation<Any?>) {
    val dough = api.makeDough() // suspending fun
    val magicDough = api.addMagicPowder(dough) // suspending fun
    val cookie = api.escapeOven(magicDough) // suspending fun
    val gingerBrave = api.fetchGingerBrave(cookie) // suspending fun
    Log.d("You can't catch me! I'm the Gingerbre.. I'm Ginger Brave!")
    completion.resume(gingerBrave)
}
fun getGingerBrave(api: CookieService, completion: Continuation<Any?>) {
    when (label) {
        0 -> // Label 0 -> first execution !
            val dough = api.makeDough() // suspend

        1 -> // Label 1 -> resume from makeDough
            val magicDough = api.addMagicPowder(dough) // suspend

        2 -> // Label 2 -> resume from addMagicPowder
            val cookie = api.escapeOven(magicDough) // suspend

        3 -> // Label 3 -> resume from escapeOven
            val gingerBrave = api.fetchGingerBrave(cookie) // suspend

        4 -> { // Label 4 -> resume from fetchGingerBrave
            Log.d("You can't catch me! I'm the Gingerbre.. I'm Ginger Brave!")
            completion.resume(gingerBrave)
        }
    }
}

Kotlin은 모든 중단 가능 지점을 찾아 when으로 표현합니다. Kotlin 컴파일러는 함수가 내부적으로 중단 가능 지점을 식별하고, 이 지점으로 코드를 분리합니다. 분리된 코드들은 부여된 각각의 label로 인식되며, when을 사용하여 작성됩니다. (label에 대한 설명은 아래에서 계속됩니다.)

이렇게 코드를 명확히 구분해두어, 중단 가능 지점에서 suspend되고 해당 작업이 끝나 resume되면 다시 다음 블록을 실행하게 될 수 있게 합니다. 이런 방식은 상태를 관리하는 하나의 방법으로 상태머신(state machine)이라고 불리기도 합니다.

중단 가능 지점을 표현하는 화살표
중단 가능 지점을 표현하는 화살표

한편 중단 가능 지점은 Android Studio, IntelliJ IDE의 화살표 표시로도 확인할 수 있습니다.

요약하면, Kotlin 컴파일러는 suspend 함수 내부를 상태머신으로 표현합니다. suspend 함수를 호출할 때마다 구역을 나누고, labeling하여 when문으로 표현합니다. 한편 현재 실행 차례를 가리키는 label과, suspend 함수 내부에 선언된 변수들을 어떻게 관리될까요?

Step 4. State machine - label과 각 suspend fun 내부 변수들이 관리되다!

fun getGingerBrave(api: CookieService, completion: Continuation<Any?>) {
    class GetGingerBraveStateMachine(completionL Continuation<Any?>): CoroutineImpl(completion) {
        // 기존 함수 내부에 선언된 변수들 
        var dough: Dough? = null
        var magicDough: MagicDough? = null
        var cookie: Cookie? = null
        var gingerBrave: GingerBrave? = null

        var result: Any? = null
        var label: Int = 0

        override fun invokeSuspend(result: Any?) {
            this.result = result
            getGingerBrave(api, this)
        }
    }
}

Kotlin 컴파일러는 getGingerBrave 함수안에 GetGingerBraveStateMachine이라는 클래스를 생성합니다. 이 클래스는 Continuation의 자식인 CoroutineImpl을 구현하고 있으며, 원래 suspend 함수에 선언된 변수들과, 실행 결과인 result, 현재의 진행 상태인 label을 가지고 있습니다. 이 클래스가 어떻게 사용되는지 아래 코드에서 확인해보겠습니다.

fun getGingerBrave(api: CookieService, completion: Continuation<Any?>) {
    val continuation = completion as? GetGingerBraveStateMachine ?: GetGingerBraveStateMachine
    (completion)

    when (continuation.label) {
        0 -> {// Label 0 -> first execution !
            throwOnFailure(continuation.result)
            continuation.label = 1
            api.makeDough(continuation) // suspend
        }
        1 -> {// Label 1 -> resume from makeDough
            throwOnFailure(continuation.result)
            continuation.dough = continuation.result as Dough
            continuation.label = 2
            api.addMagicPowder(continuation.dough, continuation) // suspend
        }
        2 -> {// Label 2 -> resume from addMagicPowder
            throwOnFailure(continuation.result)
            continuation.magicDough = continuation.result as MagicDough
            continuation.label = 3
            api.escapeOven(continuation.magicDough, continuation) // suspend
        }
        3 -> { // Label 3 -> resume from escapeOven
            throwOnFailure(continuation.result)
            continuation.cookie = continuation.result as Cookie
            continuation.label = 4
            api.fetchGingerBrave(continuation.cookie, continuation) // suspend
        }
        4 -> { // Label 4 -> resume from fetchGingerBrave
            throwOnFailure(continuation.result)
            continuation.gingerBrave = continuation.result as GingerBrave
            Log.d("You can't catch me! I'm the Gingerbre.. I'm Ginger Brave!")
            completion.resume(continuation.gingerBrave)
        }
        else -> throw IllegalStateException
    }
}

Step 3에서 중단 가능 지점을 기준으로 코드를 나눈 이유가 여기서 더 와닿으실 것 같습니다. 컴파일러가 생성해준 GetGingerBraveStateMachine의 label이 when 절안에서 사용되는데요. suspend 지점에 따라 코드를 나눴고 그것을 label로 구분했으니, 이제 resume 을 위해 label을 사용할 차례입니다. 각 label 분기에선 label의 값을 증분 시켜 다음 실행 지점을 가리키고, 이를 통해 resume이 됩니다.

한편 GetGingerBraveStateMachine을 살펴보면 기존 suspend 함수 내부에 선언된 변수들이 null로 초기화 되어있는 모습을 확인할 수 있는데요. 또한 when 절안에서 resume시 suspend되고 실행된 작업의 결과가 continuation.result로 받아오는 모습도 확인할 수 있습니다. continuation.result를 각 타입으로 캐스팅하여 함수 내부에서 선언된 변수들을 관리하고 있습니다. 이렇게 각 블록을 하나하나 실행할 때마다 함수의 상태가 점점 누적되고 마지막 resume을 통해 최종 결과 값이 전달되게 됩니다. 이렇게 하나의 Coroutine이 끝나게 됩니다.

지금까지 Kotlin 컴파일러가 suspend 키워드를 만났을때 어떻게 동작하는지를 4단계로 알아보았습니다.

  • Coroutine은 Continuation을 주고 받는 CPS 패러다임을 사용합니다.
  • Kotlin 컴파일러는 suspend fun의 시그니처를 변경합니다. 매개변수에 Continuation을 추가합니다.
  • Kotlin 컴파일러는 suspend fun의 내부 코드들을 분석하여 중단 가능 지점을 찾아 구분합니다.
  • Kotlin 컴파일러는 다음 실행 지점을 나타내는 label과 내부 변수들을 관리하는 상태머신 클래스를 생성합니다.

아니요
아니요

여기서의 의의는 Kotlin 컴파일러가 suspend 키워드를 만나면 CPS 패러다임을 구현하여, Continuation이라는 일종의 콜백을 주고 받도록 코드를 변환해준다는 것입니다.

한편, 이렇게 모든 suspend 연산은 Continuation을 보내고 받도록 변환됩니다. 복잡한 변환 작업을 컴파일러가 뒤에서 수행해주고 있던 것이죠. 간단한 suspend 키워드 너머 이렇게 복잡한 변환 과정이 이뤄진다니 놀랍지 않나요? 이렇게 Kotlin 팀은 동시성을 지원하기 위해 가능한 작은 변화를 주도록 노력했고, 단 하나의 suspend 키워드로 Kotlin 개발자들에게 쉽게 동시성을 사용하도록 했다고 하네요. 거..참 쉽..죠 ..?

성능 이야기 : Thread vs Coroutine

스레드에서 실행되지만, 스레드에 종속적이지 않은 Coroutine을 보여주는 자료
스레드에서 실행되지만, 스레드에 종속적이지 않은 Coroutine을 보여주는 자료

왜 Coroutine은 light-weight thread라고 불릴까요?

기본적으로 애플리케이션에는 하나 이상의 프로세스가 있고, 각각의 프로세스는 적어도 하나의 스레드를 갖고 있습니다. 그렇다면 Coroutine은 어디서 실행될까요? Coroutine은 스레드 안에서 실행됩니다. 같은 스레드에 10개, 100개의 Coroutine이 있을 수 있습니다. 하지만 이것이 한 시점에 여러 Coroutine이 실행된다는 의미는 아닙니다. 동시성 프로그래밍에서 한 시점에는 하나의 Coroutine만 실행됩니다. 한편 main 함수에서 시작되는 메인스레드부터 백그라운드 스레드까지, 앱은 Main-Safe하게 여러 스레드를 활용해야합니다. Coroutine은 메인스레드 뿐만아니라 다른 백그라운드 스레드에서도 동작할 수 있으므로, Coroutine을 활용하여 Main-Safe하게 앱을 만들 수 있게 됩니다.

즉, Coroutine은 스레드 안에서 실행되고 Coroutine을 활용하여 Main-Safe하게 앱을 만들 수 있습니다.

스레드는 프로세스에 종속이 되는데, 그렇다면 Coroutine도 스레드에 종속이 될까요? 여기서 멀티스레드 방식과 Coroutine의 큰 차이점이 나타나는데요, Coroutine은 특정 스레드에 종속되지 않는다는 점이 큰 특징입니다. 즉, Coroutine은 resume될 때마다 다른 스레드에서 실행될 수 있습니다.

작업이 스레드에 종속되지 않는 특징은 스레드를 blocking 하지 않으면서 작업의 실행을 잠시 중단할 수 있게합니다. 즉, 스레드 B가 스레드 A의 작업이 끝날때까지 대기해야하는 작업을 잠시 중단하고, 그동안 다른 작업을 할수 있게 합니다.

이 특징은 스레드를 blocking하지 않아 더 빠른 연산으로 이어지게하고, 메모리 사용량을 줄여 많은 동시성 작업을 수행할 수 있게 합니다. Coroutine 생성 또한 스레드 생성보다 빠르고 적은 비용으로 이뤄질 수 있습니다.

이를 강조하기 위해 Kotlin 공식 문서에선 Coroutine을 light-weight thread라고 부르는 것입니다. 프로세서가 실행할 명령어 집합을 정의한다는 점 및 생명주기가 스레드와 비슷하다는 점에서 “thread", 하지만 스레드 보다 빠르고 적은 메모리를 사용한다는 점에서 “light-weight”으로 표현되었다고 이해할 수 있습니다.

자 그럼 정말 light weight 한지 코드로 확인해볼까요?

import kotlin.system.measureTimeMillis
import kotlinx.coroutines.*

fun main() = runBlocking {
    val amount = 10
    println("✅ Test when the amount is $amount")
    println("🛫 ${Thread.activeCount()} threads active at the start")
    val time = measureTimeMillis { createThreads(amount) }
    println("⏰ Running time for createThreads is $time ms")
}

suspend fun createCoroutines(amount: Int) = coroutineScope {
    val jobs = ArrayList<Job>()

    for (i in 1..amount) {
        jobs += launch(Dispatchers.Default) {
            delay(1000L)
        }
    }
    println("🛬 ${Thread.activeCount()} threads active at the end")
    jobs.forEach { it.join() }
}

fun createThreads(amount: Int) {
    val jobs = ArrayList<Thread>()

    for (i in 1..amount) {
        jobs += Thread {
            Thread.sleep(1000L)
        }.also { it.start() }
    }
    println("🛬 ${Thread.activeCount()} threads active at the end")
    jobs.forEach { it.join() }
}

성능 차이를 측정해보기 위해 스레드와 Coroutine으로 같은 작업을 하는 코드를 작성해보았습니다. create-으로 시작하는 함수는 main 함수에서 실행되어, 1초간 대기하는 작업을 실행하는 스레드 혹은 Coroutine을 amount 개수 만큼 생성하고 실행시킵니다. 아래는 amount가 100, 1000 ,10000 일 때의 실행 결과(시간과 스레드 생성 개수)입니다.

1️. amount = 100

createCoroutines(100)을 호출했을때
createCoroutines(100)을 호출했을때

createThreads(100)을 호출했을때
createThreads(100)을 호출했을때

2. amount = 1000

createCoroutines(1000)을 호출했을때
createCoroutines(1000)을 호출했을때

createThreads(1000)을 호출했을때
createThreads(1000)을 호출했을때

3️. amount = 10000

createCoroutines(10000)을 호출했을때
createCoroutines(10000)을 호출했을때

createThreads(10000)을 호출했을때
createThreads(10000)을 호출했을때

아래 두 표는 위 코드의 실행 결과를 요약한 표입니다. amount 값에 따라 생성된 스레드의 개수와 소요된 실행 시간의 변화를 나타내고 있습니다.

amount 값에 따른 createThreads와 createCoroutines의 실행 결과
amount 값에 따른 createThreads와 createCoroutines의 실행 결과

메모리 측면에서 큰 차이를 보여주고 있습니다. Coroutine을 사용하면 불필요한 blocking을 하지 않아, 스레드 생성을 줄일 수 있기 때문인데요. 이는 createThreads에서 OOM 이 발생하는 원인과 관련이 있습니다. amount가 100일때, createCoroutinescreateThreads 호출을 통한 스레드 생성 횟수는 30배가 넘는 차이가 발생했습니다. Coroutine이 멀티스레드 방식보다 30배 적은 thread 객체를 생성하고 있으므로, 30배 적은 메모리를 사용하고 있다고 말할 수 있을 것 같습니다. 한편 실행 시간 측면에선 큰 차이를 보여주진 않았습니다.

Outro

마이쿠키런에서 Coroutine을 활용하여 신규개발을 진행하면서 개인적으로 느낀 점, 궁금한 점들이 많았는데요. (ex. Coroutine은 정말 최고야! JetBrains 감사합니다.) 그 중 궁금한 점들을 직접 정의하고 답을 찾아갈 수 있던 좋은 기회였습니다.

저와 같은 물음표를 가진 분들에게 느낌표를 드릴 수 있는 글이 되었으면 좋겠습니다.

한편 Understand Kotlin Coroutines on Android (Google I/O'19)를 보면서 개발자들에게 큰 편의를 주는 이러한 기술적인 지원이 개발자들의 경험인 UXR와 이를 바탕으로 기획한 것들이라는 것을 알게 되었습니다. 개발자들의 의견에 귀기울이며 발전해가는 안드로이드 생태계의 일원이라는 것에 큰 자부심을 느낀 순간이었습니다.

더 좋은 방향으로 나아가기 위해 치열하게 고민하는 개발자분들에게 감사하는 마음으로 이번 포스팅을 마치겠습니다. 읽어주셔서 감사합니다.

References

데브시스터즈는 최고의 인재를 찾고 있습니다.

자세한 내용은 채용 사이트를 확인해주세요!

© 2024 Devsisters Corp. All Rights Reserved.