Kotlin 코루틴으로 앱 성능 향상

Kotlin 코루틴을 사용하면 네트워크 호출이나 디스크 작업과 같은 장기 실행 작업을 관리하면서 앱의 응답성을 유지하는 깔끔하고 간소화된 비동기 코드를 작성할 수 있습니다.

이 주제에서는 Android의 코루틴에 관해 자세히 설명합니다. 코루틴에 익숙하지 않다면 Android의 Kotlin 코루틴을 먼저 읽고 이 주제를 읽어야 합니다.

장기 실행 작업 관리

코루틴은 장기 실행 작업을 처리하는 두 작업을 추가하여 일반 함수를 기반으로 빌드됩니다. invoke(또는 call) 및 return 외에도 코루틴은 suspendresume을 추가합니다.

  • suspend는 현재 코루틴 실행을 일시중지하고 모든 로컬 변수를 저장합니다.
  • resume은 정지된 위치부터 정지된 코루틴을 계속 실행합니다.

suspend 함수는 다른 suspend 함수에서 호출하거나 코루틴 빌더(예: launch)를 사용하여 새 코루틴을 시작하는 방법으로만 호출할 수 있습니다.

다음 예는 가상 장기 실행 작업의 간단한 코루틴 구현을 보여 줍니다.

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

이 예에서 get()은 여전히 기본 스레드에서 실행되지만 네트워크 요청을 시작하기 전에 코루틴을 정지합니다. 네트워크 요청이 완료되면 get은 콜백을 사용하여 기본 스레드에 알리는 대신 정지된 코루틴을 재개합니다.

Kotlin은 스택 프레임을 사용하여 로컬 변수와 함께 실행 중인 함수를 관리합니다. 코루틴을 정지하면 현재 스택 프레임이 복사되고 저장됩니다. 재개되면 스택 프레임이 저장된 위치에서 다시 복사되고 함수가 다시 실행됩니다. 코드가 일반적인 순차 차단 요청처럼 보일 수도 있지만 코루틴은 네트워크 요청이 기본 스레드를 차단하지 않도록 합니다.

기본 안전을 위해 코루틴 사용

Kotlin 코루틴은 디스패처를 사용하여 코루틴 실행에 사용되는 스레드를 확인합니다. 코드를 기본 스레드 외부에서 실행하려면 기본 또는 IO 디스패처에서 작업을 실행하도록 Kotlin 코루틴에 지시하면 됩니다. Kotlin에서 모든 코루틴은 기본 스레드에서 실행 중인 경우에도 디스패처에서 실행되어야 합니다. 코루틴은 자체적으로 정지될 수 있으며 디스패처는 코루틴 재개를 담당합니다.

Kotlin은 코루틴을 실행할 위치를 지정하는 데 사용할 수 있는 세 가지 디스패처를 제공합니다.

  • Dispatchers.Main - 이 디스패처를 사용하여 기본 Android 스레드에서 코루틴을 실행합니다. 이 디스패처는 UI와 상호작용하고 빠른 작업을 실행하기 위해서만 사용해야 합니다. 예를 들어 suspend 함수를 호출하고 Android UI 프레임워크 작업을 실행하며 LiveData 객체를 업데이트합니다.
  • Dispatchers.IO - 이 디스패처는 기본 스레드 외부에서 디스크 또는 네트워크 I/O를 실행하도록 최적화되어 있습니다. 예를 들어 회의실 구성요소를 사용하고 파일에서 읽거나 파일에 쓰며 네트워크 작업을 실행합니다.
  • Dispatchers.Default - 이 디스패처는 CPU를 많이 사용하는 작업을 기본 스레드 외부에서 실행하도록 최적화되어 있습니다. 예를 들어 목록을 정렬하고 JSON을 파싱합니다.

계속해서 이전 예에서 디스패처를 사용하여 get 함수를 다시 정의할 수 있습니다. get의 본문 내에서 withContext(Dispatchers.IO)를 호출하여 IO 스레드 풀에서 실행되는 블록을 만듭니다. 블록 안에 넣은 코드는 항상 IO 디스패처를 통해 실행됩니다. withContext는 그 자체로 정지 함수이므로 get 함수도 정지 함수입니다.

suspend fun fetchDocs() {                      // Dispatchers.Main
    val result = get("developer.android.com")  // Dispatchers.Main
    show(result)                               // Dispatchers.Main
}

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

코루틴을 사용하면 세부적인 제어를 통해 스레드를 전달할 수 있습니다. withContext()를 사용하면 콜백을 도입하지 않고도 코드 줄의 스레드 풀을 제어할 수 있으므로 데이터베이스에서 읽기, 네트워크 요청 실행과 같은 매우 작은 함수에 이를 적용할 수 있습니다. withContext()를 사용하여 모든 함수가 기본적으로 안전한지, 즉 기본 스레드에서 함수를 호출할 수 있는지 확인하는 것이 좋습니다. 이 경우 호출자는 함수를 실행하는 데 사용해야 할 스레드를 생각할 필요가 없습니다.

이전 예에서 fetchDocs()는 기본 스레드에서 실행되지만 이 함수는 백그라운드에서 네트워크 요청을 실행하는 get을 안전하게 호출할 수 있습니다. 코루틴은 suspendresume을 지원하므로 withContext 블록이 차단되는 즉시 기본 스레드의 코루틴이 get 결과와 함께 재개됩니다.

withContext()의 성능

withContext()는 상응하는 콜백 기반 구현에 비해 오버헤드를 추가하지 않습니다. 또한 일부 상황에서 상응하는 콜백 기반 구현을 능가하도록 withContext() 호출을 최적화할 수 있습니다. 예를 들어, 함수가 네트워크를 10회 호출하는 경우 외부 withContext()를 사용하여 스레드를 한 번만 전환하도록 Kotlin에 지시할 수 있습니다. 그러면 네트워크 라이브러리에서 withContext()를 여러 번 사용하더라도 동일한 디스패처에 유지되고 스레드가 전환되지 않습니다. 또한 Kotlin은 가능한 한 스레드 전환을 방지하도록 Dispatchers.DefaultDispatchers.IO 간의 전환을 최적화합니다.

코루틴 시작

다음 두 가지 방법 중 하나로 코루틴을 시작할 수 있습니다.

  • launch는 새 코루틴을 시작하고 호출자에게 결과를 반환하지 않습니다. '실행 후 삭제'로 간주되는 모든 작업은 launch를 사용하여 시작할 수 있습니다.
  • async는 새 코루틴을 시작하고 await라는 정지 함수를 사용하여 결과를 반환할 수 있게 합니다.

보통 일반 함수는 await를 호출할 수 없으므로 일반 함수에서 새 코루틴을 launch해야 합니다. async는 다른 코루틴 내부에서만 사용하거나 정지 함수 내에서 병렬 분해를 실행할 때 사용합니다.

병렬 분해

suspend 함수 내에서 시작되는 모든 코루틴은 함수가 반환되면 중지되어야 하므로 반환 전에 그러한 코루틴이 완료되도록 보장해야 할 수 있습니다. Kotlin에서 구조화된 동시 실행을 사용하여 하나 이상의 코루틴을 시작하는 coroutineScope를 정의할 수 있습니다. 그런 다음 await()(단일 코루틴의 경우) 또는 awaitAll()(여러 코루틴의 경우)을 사용하여 함수에서 반환하기 전에 이러한 코루틴이 완료되도록 보장할 수 있습니다.

예를 들어 두 문서를 비동기식으로 가져오는 coroutineScope를 정의해보겠습니다. 각 지연된 참조에서 await()를 호출하여 값을 반환하기 전에 두 async 작업이 모두 완료되도록 보장합니다.

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

아래 예제와 같이 컬렉션에서 awaitAll()을 사용할 수도 있습니다.

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

fetchTwoDocs()async를 통해 새 코루틴을 시작하지만 함수는 awaitAll()을 사용하여 실행된 코루틴이 완료되도록 기다렸다가 반환합니다. 하지만 awaitAll()을 호출하지 않았더라도 coroutineScope 빌더는 모든 새 코루틴이 완료될 때까지 fetchTwoDocs를 호출한 코루틴을 재개하지 않습니다.

또한 coroutineScope는 코루틴이 발생시키는 예외를 인식한 후 호출자에게 다시 라우팅합니다.

병렬 분해에 관한 자세한 내용은 정지 함수 작성을 참조하세요.

코루틴 개념

CoroutineScope

CoroutineScopelaunch 또는 async를 사용하여 만든 코루틴을 추적합니다. 진행 중인 작업, 즉 실행 중인 코루틴은 언제든지 scope.cancel()을 호출하여 취소할 수 있습니다. Android에서 일부 KTX 라이브러리는 특정 수명 주기 클래스에 자체 CoroutineScope를 제공합니다. 예를 들어 ViewModel에는 viewModelScope가 있고 Lifecycle에는 lifecycleScope가 있습니다. 하지만 디스패처와 달리 CoroutineScope는 코루틴을 실행하지 않습니다.

viewModelScope코루틴이 있는 Android의 백그라운드 스레딩에 있는 예에도 사용됩니다. 그러나 자체 CoroutineScope를 만들어 앱의 특정 레이어에서 코루틴의 수명 주기를 제어해야 한다면 다음과 같이 만들면 됩니다.

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

취소된 범위는 코루틴을 더 만들 수 없습니다. 따라서 수명 주기를 제어하는 클래스가 제거되는 경우에만 scope.cancel()을 호출해야 합니다. viewModelScope를 사용할 때 ViewModel 클래스는 ViewModel의 onCleared() 메서드에서 자동으로 범위를 취소합니다.

작업

Job는 코루틴의 핸들입니다. launch 또는 async로 만드는 각 코루틴은 코루틴을 고유하게 식별하고 수명 주기를 관리하는 Job 인스턴스를 반환합니다. 다음 예와 같이 JobCoroutineScope에 전달하여 코루틴의 수명 주기를 추가로 관리할 수도 있습니다.

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

CoroutineContext는 일련의 다음 요소를 사용하여 코루틴의 동작을 정의합니다.

범위 내에 만들어진 새 코루틴의 경우 새 Job 인스턴스가 새 코루틴에 할당되고 다른 CoroutineContext 요소는 포함 범위에서 상속됩니다. 새 CoroutineContextlaunch 또는 async 함수에 전달하여 상속된 요소를 재정의할 수 있습니다. Joblaunch 또는 async에 전달해도 아무런 효과가 없습니다. Job의 새 인스턴스가 항상 새 코루틴에 할당되기 때문입니다.

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

추가 코루틴 리소스

코루틴 리소스를 더 보려면 다음 링크를 참조하세요.