코루틴 소개

반응형 UI는 뛰어난 앱의 필수 요소입니다. 지금까지 빌드한 앱에서는 이러한 UI를 당연하게 생각했을 수 있지만 좀 더 고급 기능(예: 네트워킹 또는 데이터베이스 기능)을 이제 추가하므로 작동도 하고 성능도 좋은 코드를 작성하기가 점점 더 어려워질 수 있습니다. 아래 예는 인터넷에서 이미지 다운로드와 같은 장기 실행 작업이 올바르게 처리되지 않으면 어떤 일이 발생할 수 있는지 보여줍니다. 이미지 기능이 작동하는 동안 스크롤이 버벅거려 UI가 응답하지 않는 것처럼 보입니다(비전문적으로도 보임).

9f8c54ba29f548cd.gif

위 앱에 발생하는 문제를 방지하려면 스레드에 관해 알아야 합니다. 스레드는 약간 추상적인 개념이지만 앱에서 코드를 실행하는 단일 경로로 생각하면 됩니다. 개발자가 작성하는 각 코드 줄은 동일한 스레드에서 순서대로 실행될 명령어입니다.

이미 Android에서 스레드로 작업하고 있습니다. 모든 Android 앱에는 '기본' 스레드가 있습니다. 일반적으로 UI 스레드입니다. 지금까지 작성한 코드는 모두 기본 스레드에 있습니다. 각 명령어(즉, 코드 줄)는 다음 줄이 실행되기 전에 이전 줄이 완료되기를 기다립니다.

그러나 실행 중인 앱에는 기본 스레드 외에도 스레드가 더 있습니다. 내부적으로 프로세서는 실제로 별도의 스레드로 작동하지 않고 일련의 여러 명령어 간에 전환하여 멀티태스킹의 모양을 제공합니다. 스레드는 코드를 작성할 때 각 명령어가 선택해야 하는 실행 경로를 결정하는 데 사용할 수 있는 추상화입니다. 기본 스레드가 아닌 스레드로 작업하면 앱의 사용자 인터페이스 응답성을 유지하면서 앱이 이미지 다운로드와 같은 복잡한 작업을 백그라운드에서 실행할 수 있습니다. 이를 동시 실행 코드 또는 간단히 동시 실행이라고 합니다.

이 Codelab에서는 스레드에 관해 그리고 코루틴이라는 Kotlin 기능을 사용하여 명확한 비차단 동시 실행 코드를 작성하는 방법을 알아봅니다.

기본 요건

  • 과정 1: Kotlin 소개에서 학습한 루프와 함수를 비롯한 기본적인 Kotlin 프로그래밍 개념에 관해 알아야 합니다.
  • 과정 3: Kotlin의 컬렉션에서 학습한 Kotlin에서 람다 함수를 사용하는 방법을 알아야 합니다.

학습할 내용

  • 동시 실행의 정의와 동시 실행이 중요한 이유
  • 코루틴과 스레드를 사용하여 비차단 동시 실행 코드를 작성하는 방법
  • 백그라운드에서 작업을 실행할 때 기본 스레드에 액세스하여 UI 업데이트를 안전하게 실행하는 방법
  • 다른 동시 실행 패턴(Scope/Dispatchers/Deferred)의 사용 방법과 사용 시기
  • 네트워크 리소스와 상호작용하는 코드를 작성하는 방법

빌드할 항목

  • 이 Codelab에서는 Kotlin에서 스레드와 코루틴을 사용하는 방법을 알아보는 작은 프로그램을 작성합니다.

필요한 항목

  • 최신 버전의 Chrome과 같은 최신 웹브라우저가 설치된 컴퓨터
  • 인터넷이 연결된 컴퓨터

멀티스레딩 및 동시 실행

지금까지는 Android 앱을 단일 실행 경로가 있는 프로그램으로 간주했습니다. 단일 실행 경로로 많은 작업을 할 수 있지만 앱이 커짐에 따라 동시 실행을 고려해봐야 합니다.

동시 실행을 통해 여러 코드 단위를 순서에 맞지 않거나 병렬로 실행할 수 있어 리소스 사용의 효율성이 높아집니다. 운영체제는 시스템, 프로그래밍 언어, 동시 실행 단위의 특성을 사용하여 멀티태스킹을 관리할 수 있습니다.

fe71122b40bdb5e3.png

동시 실행을 사용해야 하는 이유는 무엇인가요? 앱이 점점 복잡해짐에 따라 코드가 차단되지 않는 것이 중요합니다. 즉, 네트워크 요청과 같은 장기 실행 작업을 실행하더라도 앱에서 다른 작업의 실행이 중지되지 않습니다. 동시 실행을 올바르게 구현하지 않으면 앱이 사용자에게 응답하지 않는 것으로 보일 수 있습니다.

Kotlin의 동시 실행 프로그래밍을 보여주는 예를 몇 가지 살펴보겠습니다. 모든 예는 Kotlin 플레이그라운드에서 실행할 수 있습니다.

https://developer.android.com/training/kotlinplayground

스레드는 프로그램 범위 내에서 예약하고 실행할 수 있는 코드의 최소 단위입니다. 다음은 동시 실행 코드를 실행할 수 있는 예입니다.

람다를 제공하여 간단한 스레드를 만들 수 있습니다. 플레이그라운드에서 다음을 시도해보세요.

fun main() {
    val thread = Thread {
        println("${Thread.currentThread()} has run.")
    }
    thread.start()
}

함수가 start() 함수 호출에 도달할 때까지 스레드가 실행되지 않습니다. 출력은 다음과 같이 표시됩니다.

Thread[Thread-0,5,main] has run.

currentThread()는 스레드의 이름, 우선순위, 스레드 그룹을 반환하는 문자열 표현으로 변환되는 Thread 인스턴스를 반환합니다. 위 출력 내용이 약간 다를 수 있습니다.

여러 스레드 만들기 및 실행

간단한 동시 실행을 보여주기 위해, 실행할 스레드를 몇 가지 만들어보겠습니다. 이 코드는 이전 예의 정보 줄을 출력하는 스레드 3개를 만듭니다.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

플레이그라운드의 출력:

Thread[Thread-2,5,main] has started Thread[Thread-2,5,main] - Starting Thread[Thread-0,5,main] - Doing Task 1 Thread[Thread-1,5,main] - Doing Task 1 Thread[Thread-2,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Doing Task 2 Thread[Thread-2,5,main] - Doing Task 2 Thread[Thread-0,5,main] - Ending Thread[Thread-2,5,main] - Ending Thread[Thread-1,5,main] - Ending Thread[Thread-0,5,main] has started
Thread[Thread-0,5,main] - Starting
Thread[Thread-1,5,main] has started
Thread[Thread-1,5,main] - Starting

AS(콘솔)의 출력

Thread[Thread-0,5,main] has started
Thread[Thread-1,5,main] has started
Thread[Thread-2,5,main] has started
Thread[Thread-1,5,main] - Starting
Thread[Thread-0,5,main] - Starting
Thread[Thread-2,5,main] - Starting
Thread[Thread-1,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 1
Thread[Thread-2,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 2
Thread[Thread-1,5,main] - Doing Task 2
Thread[Thread-2,5,main] - Doing Task 2
Thread[Thread-0,5,main] - Ending
Thread[Thread-2,5,main] - Ending
Thread[Thread-1,5,main] - Ending

코드를 여러 번 실행합니다. 출력이 다양하게 표시됩니다. 어떤 때는 스레드가 순서대로 실행되는 것처럼 보이고 어떤 때는 콘텐츠가 여기저기 흩어져 있습니다.

스레드를 사용하면 간단하게 여러 작업과 동시 실행을 사용할 수 있지만 문제가 없는 것은 아닙니다. Thread를 코드에서 직접 사용하면 여러 문제가 발생할 수 있습니다.

많은 리소스가 필요한 스레드

스레드를 만들고 전환하고 관리하는 데는 동시에 관리할 수 있는 원시 스레드 수를 제한하는 시스템 리소스와 시간이 사용됩니다. 만들기 비용은 매우 늘어날 수 있습니다.

실행 중인 앱에는 여러 스레드가 있지만 각 앱에는 전용 스레드가 하나 있고 특히 앱의 UI를 담당합니다. 이 스레드를 기본 스레드 또는 UI 스레드라고도 합니다.

이 스레드는 앱의 UI 실행을 담당하므로 기본 스레드가 앱이 원활하게 실행되도록 성능 기준에 맞는 것이 중요합니다. 장기 실행 작업은 완료될 때까지 스레드를 차단하여 앱이 응답하지 않는 원인이 됩니다.

운영체제는 사용자에게 응답성을 유지하기 위해 여러 작업을 시도합니다. 현재 휴대전화는 UI 업데이트를 초당 60회~120회(최소 60회) 시도합니다. 한정된 짧은 시간에 UI를 준비하고 그려야 합니다(초당 60프레임, 각 화면 업데이트에 걸리는 시간은 16ms 이하여야 함). Android는 프레임을 드롭하거나 단일 업데이트 주기를 완료하려는 시도를 중단하여 따라잡으려고 합니다. 일부 프레임 드롭 및 변동은 일반적이지만 너무 많으면 앱이 응답하지 않게 됩니다.

경합 상태 및 예측할 수 없는 동작

앞에서 설명했듯이 스레드는 프로세서가 어떻게 한 번에 여러 작업을 처리하는 것처럼 보이는지에 관한 추상화입니다. 프로세서가 여러 스레드의 명령어 집합 간에 전환할 때 스레드가 실행되는 정확한 시간과 스레드가 일시중지되는 시점은 개발자가 제어할 수 없습니다. 스레드를 직접 사용할 때 항상 예측 가능한 출력을 기대할 수는 없습니다.

예를 들어 다음 코드는 간단한 루프를 사용하여 1에서 50까지 세지만 이 경우에는 숫자가 증가할 때마다 새 스레드가 만들어집니다. 예상되는 출력 내용을 생각해본 후 코드를 몇 번 실행합니다.

fun main() {
   var count = 0
   for (i in 1..50) {
       Thread {
           count += 1
           println("Thread: $i count: $count")
       }.start()
   }
}

예상한 대로 출력되었나요? 매번 내용이 같은가요? 다음은 출력 예입니다.

Thread: 50 count: 49 Thread: 43 count: 50 Thread: 1 count: 1
Thread: 2 count: 2
Thread: 3 count: 3
Thread: 4 count: 4
Thread: 5 count: 5
Thread: 6 count: 6
Thread: 7 count: 7
Thread: 8 count: 8
Thread: 9 count: 9
Thread: 10 count: 10
Thread: 11 count: 11
Thread: 12 count: 12
Thread: 13 count: 13
Thread: 14 count: 14
Thread: 15 count: 15
Thread: 16 count: 16
Thread: 17 count: 17
Thread: 18 count: 18
Thread: 19 count: 19
Thread: 20 count: 20
Thread: 21 count: 21
Thread: 23 count: 22
Thread: 22 count: 23
Thread: 24 count: 24
Thread: 25 count: 25
Thread: 26 count: 26
Thread: 27 count: 27
Thread: 30 count: 28
Thread: 28 count: 29
Thread: 29 count: 41
Thread: 40 count: 41
Thread: 39 count: 41
Thread: 41 count: 41
Thread: 38 count: 41
Thread: 37 count: 41
Thread: 35 count: 41
Thread: 33 count: 41
Thread: 36 count: 41
Thread: 34 count: 41
Thread: 31 count: 41
Thread: 32 count: 41
Thread: 44 count: 42
Thread: 46 count: 43
Thread: 45 count: 44
Thread: 47 count: 45
Thread: 48 count: 46
Thread: 42 count: 47
Thread: 49 count: 48

코드가 나타내는 것과 달리 마지막 스레드가 첫 번째로 실행되고 다른 스레드 중 일부가 순서에 맞지 않게 실행된 것 같습니다. 일부 반복의 'count'를 보면 여러 스레드 후에도 변경되지 않고 유지되는 것을 알 수 있습니다. 더 이상한 점은 출력을 통해 실행할 두 번째 스레드일 뿐임을 알 수 있음에도 스레드 43에서 숫자가 50에 도달한다는 것입니다. 출력만 놓고 보면 count의 최종값을 알 수 없습니다.

이는 스레드가 예측할 수 없는 동작으로 이어질 수 있는 한 가지 방법일 뿐입니다. 여러 스레드로 작업할 때는 경합 상태도 발생할 수 있습니다. 여러 스레드가 동시에 메모리의 동일한 값에 액세스하려고 할 때 발생합니다. 경합 상태로 인해 무작위로 보이는 버그를 재현하기 어려울 수 있고 이로 인해 예상치 못한 앱의 비정상 종료를 유발할 수 있습니다.

성능 문제, 경합 상태, 재현하기 어려운 버그는 스레드를 직접 사용하라고 권장하지 않는 이유입니다. 대신 동시 실행 코드 작성에 도움이 되는 코루틴이라는 Kotlin의 기능에 관해 알아봅니다.

백그라운드 작업을 위한 스레드를 직접 만들고 사용하는 것은 Android에서 이루어지지만 Kotlin은 동시 실행을 더 유연하고 쉽게 관리할 수 있는 코루틴도 제공합니다.

코루틴은 멀티태스킹을 지원하지만 단순히 스레드로 작업하는 것보다 다른 수준의 추상화를 제공합니다. 코루틴의 주요 기능 중 하나는 상태를 저장하여 중단했다가 재개할 수 있다는 것입니다. 코루틴은 실행되거나 실행되지 않을 수 있습니다.

연속으로 표시되는 상태를 통해 코드 일부가 제어권을 넘겨주거나 재개되기 전에 다른 코루틴이 작업을 완료할 때까지 기다려야 하는 시기를 나타낼 수 있습니다. 이 흐름을 협력적인 멀티태스킹이라고 합니다. Kotlin의 코루틴 구현은 멀티태스킹을 지원하는 여러 기능을 추가합니다. 연속 외에도 코루틴을 만드는 것에는 CoroutineScope 내에서 수명 주기가 있는 취소 가능한 작업 단위인 Job의 작업이 포함됩니다. CoroutineScope는 하위 요소와 그 하위 요소에 취소 및 기타 규칙을 반복적으로 적용하는 컨텍스트입니다. Dispatcher는 코루틴이 실행에 사용할 지원 스레드를 관리하므로 개발자가 새 스레드를 사용할 시기와 위치를 파악하지 않아도 됩니다.

Job

취소 가능한 작업 단위(예: launch() 함수로 만든 작업 단위)입니다.

CoroutineScope

launch()async()와 같은 새 코루틴을 만드는 데 사용되는 함수는 CoroutineScope를 확장합니다.

Dispatcher

코루틴이 사용할 스레드를 결정합니다. Main 디스패처는 항상 기본 스레드에서 코루틴을 실행하지만 DefaultIO, Unconfined와 같은 디스패처는 다른 스레드를 사용합니다.

이 내용은 나중에 자세히 알아보겠지만 Dispatchers는 코루틴이 좋은 성능을 발휘할 수 있는 방법 중 하나입니다. 새 스레드를 초기화하는 데 드는 성능 비용이 발생하지 않도록 합니다.

앞의 예를 조정하여 코루틴을 사용해보겠습니다.

import kotlinx.coroutines.*

fun main() {
    repeat(3) {
        GlobalScope.launch {
            println("Hi from ${Thread.currentThread()}")
        }
    }
}
Hi from Thread[DefaultDispatcher-worker-2@coroutine#2,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#1,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#3`,5,main]

위 스니펫은 기본 Dispatcher를 사용하여 Global Scope에서 코루틴 세 개를 만듭니다. GlobalScope는 앱이 실행되는 한 내부의 코루틴이 실행되도록 허용합니다. 기본 스레드에 관해 언급했던 이유로 인해 예 코드 외부에서는 권장되지 않습니다. 앱에서 코루틴을 사용할 때는 다른 범위가 사용됩니다.

launch() 함수는 취소 가능한 Job 객체에 래핑된 닫힌 코드에서 코루틴을 만듭니다. launch()는 반환 값이 코루틴의 범위 밖에서 필요하지 않을 때 사용됩니다.

코루틴의 중요한 다음 개념을 이해하기 위해 launch()의 전체 서명을 살펴보겠습니다.

fun CoroutineScope.launch {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
}

실제로 개발자가 실행을 위해 전달한 코드 블록은 suspend 키워드로 표시됩니다. 정지는 코드 또는 함수 블록이 일시중지되거나 재개될 수 있음을 나타냅니다.

runBlocking에 관한 단어

다음 예에서는 이름에서 알 수 있듯이 새 코루틴을 시작하고 완료될 때까지 현재 스레드를 차단하는 runBlocking()을 사용합니다. 주로 기본 함수와 테스트에서 차단 코드와 비차단 코드 사이를 연결하는 데 사용됩니다. 일반적인 Android 코드에서는 자주 사용하지 않습니다.

import kotlinx.coroutines.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

val formatter = DateTimeFormatter.ISO_LOCAL_TIME
val time = { formatter.format(LocalDateTime.now()) }

suspend fun getValue(): Double {
    println("entering getValue() at ${time()}")
    delay(3000)
    println("leaving getValue() at ${time()}")
    return Math.random()
}

fun main() {
    runBlocking {
        val num1 = getValue()
        val num2 = getValue()
        println("result of num1 + num2 is ${num1 + num2}")
    }
}

getValue()는 설정된 지연 시간 후에 랜덤 숫자를 반환하며 DateTimeFormatter를 사용하여 적절한 출입 시간을 보여줍니다. 기본 함수는 getValue()를 두 번 호출하고 합계를 반환합니다.

entering getValue() at 17:44:52.311
leaving getValue() at 17:44:55.319
entering getValue() at 17:44:55.32
leaving getValue() at 17:44:58.32
result of num1 + num2 is 1.4320332550421415

실제 동작을 확인하려면 main() 함수를 다음으로 바꿉니다(다른 코드는 모두 유지).

fun main() {
    runBlocking {
        val num1 = async { getValue() }
        val num2 = async { getValue() }
        println("result of num1 + num2 is ${num1.await() + num2.await()}")
    }
}

두 번의 getValue() 호출은 독립적이므로 정지하는 데 코루틴이 필요하지는 않습니다. Kotlin에는 launch와 유사한 async 함수가 있습니다. async() 함수는 다음과 같이 정의됩니다.

Fun CoroutineScope.async() {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
}: Deferred<T>

async() 함수는 Deferred 유형의 값을 반환합니다. Deferred는 미래 값 참조를 보유할 수 있는 취소 가능한 Job입니다. Deferred를 사용하면 즉시 값을 반환하는 것처럼 함수를 계속 호출할 수 있습니다. Deferred는 자리표시자 역할만 합니다. 비동기 작업이 언제 반환될지 확실히 알 수 없기 때문입니다. Deferred(다른 언어에서는 Promise나 Future라고도 함)는 나중에 이 객체에 값이 반환된다고 보장합니다. 반면 비동기 작업은 기본적으로 실행을 차단하거나 기다리지 않습니다. 현재 코드 줄이 Deferred의 출력을 기다리도록 하려면 코드 줄에서 await()를 호출하면 됩니다. 그러면 원시 값이 반환됩니다.

entering getValue() at 22:52:25.025
entering getValue() at 22:52:25.03
leaving getValue() at 22:52:28.03
leaving getValue() at 22:52:28.032
result of num1 + num2 is 0.8416379026501276

함수를 정지로 표시하는 시기

앞의 예에서 알 수 있듯이 getValue() 함수도 suspend 키워드로 정의됩니다. suspend 함수이기도 한 delay()를 호출하기 때문입니다. 함수가 또 다른 suspend 함수를 호출하면 언제든지 그 함수는 suspend 함수여야 합니다.

그렇다면 이 예에서 main() 함수가 suspend로 표시되지 않은 이유는 무엇인가요? 결국 getValue()를 호출합니다.

꼭 그렇지는 않습니다. getValue()launch()async()에 전달된 함수와 비슷한 runBlocking()에 전달된 함수인 suspend 함수에서 호출됩니다. 그러나 getValue()main() 자체에서 호출되지도 않고 runBlocking()suspend 함수도 아니므로 main()suspend로 표시되지 않습니다. 함수가 suspend 함수를 호출하지 않으면 그 자체가 suspend 함수가 아니어도 됩니다.

이 Codelab를 시작할 때 여러 스레드가 사용된 다음 예를 살펴봤습니다. 코루틴에 관해 알아본 내용을 바탕으로 Thread 대신 코루틴을 사용하도록 코드를 다시 작성합니다.

참고: Thread를 참조하더라도 println() 문을 수정할 필요는 없습니다.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}
import kotlinx.coroutines.*

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       GlobalScope.launch {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               delay(5000)
           }
       }
   }
}

학습한 내용

  • 동시 실행이 필요한 이유
  • 스레드의 정의 및 스레드가 동시 실행에 중요한 이유
  • 코루틴을 사용하여 Kotlin에서 동시 실행 코드를 작성하는 방법
  • 함수를 'suspend'로 표시하는 시기와 표시하지 않는 시기
  • CoroutineScope, Job, Dispatcher의 역할
  • Deferred와 Await의 차이점