Kotlin 플레이그라운드의 코루틴 소개

1. 시작하기 전에

이 Codelab에서는 Android 개발자가 뛰어난 사용자 경험을 제공하기 위해 이해해야 하는 중요한 기술인 동시 실행을 소개합니다. 동시 실행은 앱에서 여러 태스크를 동시에 실행하는 것입니다. 예를 들어, 앱은 웹 서버에서 데이터를 가져오거나 기기에 사용자 데이터를 저장하는 동시에 사용자 입력 이벤트에 응답하고 적절하게 UI를 업데이트할 수 있습니다.

앱에서 동시에 작업하기 위해 Kotlin 코루틴을 사용합니다. 코루틴을 사용하면 코드 블록의 실행을 정지했다가 나중에 다시 시작할 수 있으며 그동안 다른 작업을 실행할 수 있습니다. 코루틴을 사용하면 비동기 코드를 더 쉽게 작성할 수 있습니다. 즉, 한 태스크를 완전히 완료하지 않아도 다음 태스크를 시작할 수 있으므로 여러 태스크를 동시에 실행할 수 있습니다.

이 Codelab에서는 Kotlin 플레이그라운드의 기본 예시를 통해 코루틴을 실습하며 비동기 프로그래밍에 더 익숙해집니다.

기본 요건

  • main() 함수를 사용하여 기본 Kotlin 프로그램을 만들 수 있음
  • 함수 및 람다를 비롯한 Kotlin 언어 기본사항에 관한 지식

빌드할 항목

  • 코루틴의 기본사항을 알아보고 실험하는 간단한 Kotlin 프로그램

학습할 내용

  • Kotlin 코루틴이 비동기 프로그래밍을 간소화할 수 있는 방법
  • 구조화된 동시 실행의 목적 및 구조화된 동시 실행이 중요한 이유

필요한 항목

2. 동기 코드

간단한 프로그램

동기 코드에서는 한 번에 하나의 개념 태스크만 진행됩니다. 순차 선형 경로라고 생각하면 됩니다. 다음 태스크가 시작되기 전에 한 태스크가 완전히 완료되어야 합니다. 다음은 동기 코드의 예입니다.

  1. Kotlin 플레이그라운드를 엽니다.
  2. 맑은 날씨의 일기 예보를 표시하는 프로그램을 위해 코드를 다음 코드로 바꿉니다. main() 함수에서 먼저 Weather forecast 텍스트를 출력합니다. 그런 다음 Sunny를 출력합니다.
fun main() {
    println("Weather forecast")
    println("Sunny")
}
  1. 코드를 실행합니다. 위 코드를 실행하면 다음과 같이 출력됩니다.
Weather forecast
Sunny

println()은 동기 호출입니다. 텍스트를 출력하는 태스크가 완료된 후에 실행이 다음 코드 줄로 이동할 수 있기 때문입니다. main()의 각 함수 호출이 동기식이므로 전체 main() 함수는 동기식입니다. 동기 함수인지 비동기 함수인지는 함수를 구성하는 부분에 따라 결정됩니다.

동기 함수는 태스크가 완전히 완료된 경우에만 반환됩니다. 따라서 main()의 마지막 print 문이 실행된 후에 모든 작업이 완료됩니다. main() 함수가 반환되고 프로그램이 종료됩니다.

지연 추가

맑은 날씨의 일기 예보를 가져오려면 원격 웹 서버에 네트워크 요청을 해야 한다고 가정하겠습니다. 코드에서 맑음 예보 출력 전에 지연을 추가하여 네트워크 요청을 시뮬레이션하세요.

  1. 먼저 코드 상단에서 main() 함수 앞에 import kotlinx.coroutines.*를 추가합니다. 그러면 Kotlin 코루틴 라이브러리에서 사용할 함수를 가져옵니다.
  2. 코드를 수정하여 delay(1000) 호출을 추가합니다. 그러면 main() 함수의 나머지 부분 실행이 1000밀리초(1초) 지연됩니다. 이 delay() 호출을 Sunny의 print 문 앞에 삽입합니다.
import kotlinx.coroutines.*

fun main() {
    println("Weather forecast")
    delay(1000)
    println("Sunny")
}

delay()는 Kotlin 코루틴 라이브러리에서 제공되는 특수 정지 함수입니다. main() 함수가 이 지점에서 실행이 정지되거나 일시중지되며 지정된 지연 시간(이 경우 1초)이 지나면 다시 시작됩니다.

이 지점에서 프로그램을 실행하려고 하면 컴파일 오류(Suspend function 'delay' should be called only from a coroutine or another suspend function)가 발생합니다.

Kotlin 플레이그라운드 내에서 코루틴을 학습하기 위해 코루틴 라이브러리의 runBlocking() 함수를 호출하여 기존 코드를 래핑할 수 있습니다. runBlocking()은 다시 시작할 준비가 되면 각 태스크를 중단된 지점에서 계속 진행하여 여러 태스크를 한 번에 처리할 수 있는 이벤트 루프를 실행합니다.

  1. main() 함수의 기존 콘텐츠를 runBlocking {} 호출의 본문으로 이동합니다. runBlocking{}의 본문은 새 코루틴에서 실행됩니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        delay(1000)
        println("Sunny")
    }
}

runBlocking()은 동기식입니다. 람다 블록 내의 모든 작업이 완료될 때까지는 반환되지 않습니다. 즉, delay() 호출의 작업이 완료될 때까지(1초가 경과할 때까지) 기다린 다음 Sunny print 문 실행을 계속합니다. runBlocking() 함수의 모든 작업이 완료되면 함수가 반환되어 프로그램이 종료됩니다.

  1. 프로그램을 실행합니다. 출력은 다음과 같습니다.
Weather forecast
Sunny

출력이 이전과 동일합니다. 코드가 여전히 동기식입니다. 다시 말해 선형으로 실행되며 한 번에 한 작업만 처리됩니다. 하지만 달라진 점은 지연으로 인해 보다 오랜 시간에 걸쳐 실행된다는 것입니다.

코루틴에서 '코-'는 함께 작동함을 의미합니다. 코드는 무언가를 대기하기 위해 정지될 때 함께 작동하여 기본 이벤트 루프를 공유하므로 그동안 다른 작업이 실행될 수 있습니다. ('코루틴'에서 '-루틴' 부분은 함수와 같은 명령의 세트를 의미합니다.) 이 예에서 코루틴은 delay() 호출에 도달하면 정지됩니다. 코루틴이 정지되면 1초 동안 다른 작업(이 프로그램에서는 다른 작업이 없지만)을 실행할 수 있습니다. 지연 시간이 경과하면 코루틴이 실행을 다시 시작하고 계속해서 Sunny를 출력할 수 있습니다.

정지 함수

날씨 데이터를 가져오기 위한 네트워크 요청을 실행하는 실제 로직이 더 복잡해지면 로직을 자체 함수로 추출하는 것이 좋습니다. 코드를 리팩터링하여 효과를 살펴보겠습니다.

  1. 날씨 데이터를 위한 네트워크 요청을 시뮬레이션하는 코드를 추출하여 printForecast()라는 자체 함수로 이동합니다. runBlocking() 코드에서 printForecast()를 호출합니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

fun printForecast() {
    delay(1000)
    println("Sunny")
}

지금 프로그램을 실행하면 앞서와 같은 컴파일 오류가 표시됩니다. 정지 함수는 코루틴이나 다른 정지 함수에서만 호출할 수 있으므로 printForecast()suspend 함수로 정의합니다.

  1. printForecast() 함수 선언에서 fun 키워드 바로 앞에 suspend 수정자를 추가하여 정지 함수로 만듭니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

delay()는 정지 함수이며 이제 printForecast()도 정지 함수로 만들었습니다.

정지 함수는 일반 함수와 비슷하지만, 정지되었다가 나중에 다시 시작할 수 있습니다. 이를 위해, 이러한 기능을 지원하는 다른 정지 함수에서만 정지 함수를 호출할 수 있습니다.

정지 함수에는 정지 지점을 0개 이상 포함할 수 있습니다. 정지 지점은 함수 내에서 함수 실행을 정지할 수 있는 위치입니다. 실행이 다시 시작되면 코드에서 마지막에 중단한 지점부터 다시 시작되어 함수의 나머지 부분이 진행됩니다.

  1. 코드에서 printForecast() 함수 선언 아래에 다른 정지 함수를 추가하여 연습합니다. 이 새 정지 함수 printTemperature()를 호출합니다. 이 함수가 일기 예보를 위한 온도 데이터를 가져오는 네트워크 요청을 한다고 가정해 보겠습니다.

함수 내에서 실행을 1000밀리초 지연한 다음 온도 값을 예를 들어 30도(섭씨)로 출력합니다. 이스케이프 시퀀스 "\u00b0"을 사용하여 온도 기호(°)를 출력할 수 있습니다.

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
}
  1. main() 함수의 runBlocking() 코드에서 새 printTemperature() 함수를 호출합니다. 전체 코드는 다음과 같습니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
        printTemperature()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. 프로그램을 실행합니다. 출력은 다음과 같이 표시됩니다.
Weather forecast
Sunny
30°C

이 코드에서 코루틴은 먼저 printForecast() 정지 함수의 지연으로 인해 정지되었다가 1초의 지연 후에 다시 시작됩니다. Sunny 텍스트가 출력됩니다. printForecast() 함수가 호출자에 반환됩니다.

그런 다음 printTemperature() 함수가 호출됩니다. 이 코루틴은 delay() 호출에 도달하면 정지되었다가 1초 후에 다시 시작된 후 온도 값을 출력하고 종료됩니다. printTemperature() 함수가 모든 작업을 완료하고 반환됩니다.

runBlocking() 본문에 실행할 추가 태스크가 없으므로 runBlocking() 함수가 반환되고 프로그램이 종료됩니다.

앞서 언급했듯이 runBlocking()은 동기식이며 본문의 각 호출은 순차적으로 이루어집니다. 잘 설계된 정지 함수는 모든 작업이 완료된 후에만 반환됩니다. 따라서 이러한 정지 함수는 하나씩 차례로 실행됩니다.

  1. (선택사항) 지연을 포함하여 이 프로그램을 실행하는 데 얼마나 오래 걸리는지 확인하려면 measureTimeMillis() 호출에서 코드를 래핑하면 됩니다. 그러면 전달된 코드 블록을 실행하는 데 걸리는 시간(밀리초)이 반환됩니다. 이 함수에 액세스할 수 있도록 import 문(import kotlin.system.*)을 추가합니다. 실행 시간을 출력하고 1000.0으로 나누어 밀리초를 초로 변환합니다.
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            printForecast()
            printTemperature()
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}
suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 

출력:

Weather forecast
Sunny
30°C
Execution time: 2.128 seconds

실행하는 데 약 2.1초가 걸린 것으로 출력됩니다. 정확한 실행 시간은 약간 다를 수 있습니다. 각 정지 함수에 1초의 지연이 있기 때문에 이는 합당해 보입니다.

지금까지 코루틴의 코드는 기본적으로 순차적으로 호출되는 것을 확인했습니다. 여러 작업을 동시에 실행하려면 명시적으로 표현해야 합니다. 다음 섹션에서 그 방법을 알아보겠습니다. 협력적인 이벤트 루프를 사용하여 여러 태스크를 동시에 실행하면 프로그램의 실행 시간이 단축됩니다.

3. 비동기 코드

launch()

코루틴 라이브러리의 launch() 함수를 사용하여 새 코루틴을 실행합니다. 태스크를 동시에 실행하려면 동시에 여러 코루틴이 진행될 수 있도록 코드에 여러 launch() 함수를 추가합니다.

Kotlin의 코루틴은 구조화된 동시 실행이라는 핵심 개념을 따릅니다. 즉, 코드가 기본적으로 순차적이며 동시 실행을 명시적으로 요청(예: launch() 사용)하지 않는 한 기본 이벤트 루프와 협력합니다. 함수를 호출하면 구현 세부정보에 사용된 코루틴 수와 상관없이 함수는 반환되기 전까지 작업을 완전히 완료해야 한다고 가정합니다. 예외와 함께 실패하더라도 예외가 발생하면 함수에서 더 이상 대기 중인 태스크가 없게 됩니다. 따라서 예외가 발생했는지 아니면 작업이 성공적으로 완료되었는지와 상관없이 제어 흐름이 함수에서 반환되면 모든 작업이 완료됩니다.

  1. 이전 단계의 코드로 시작합니다. launch() 함수를 사용하여 printForecast() 호출과 printTemperature() 호출을 각각 자체 코루틴으로 이동합니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. 프로그램을 실행합니다. 출력은 다음과 같습니다.
Weather forecast
Sunny
30°C

출력이 동일하지만 프로그램 실행이 더 빠른 것을 볼 수 있습니다. 이전에는 printForecast() 정지 함수가 완전히 완료될 때까지 기다려야 printTemperature() 함수로 이동할 수 있었습니다. 이제 printForecast()printTemperature()가 별개의 코루틴에 있으므로 동시에 실행할 수 있습니다.

println(Weather Forecast) 문은 다이어그램 상단의 상자에 있습니다. 그 아래에는 직선으로 아래를 가리키는 세로 화살표가 있습니다. 이 세로 화살표에서 오른쪽 방향으로 나온 화살표가 printForecast() 문이 포함된 상자를 가리킵니다. 세로 화살표에서 오른쪽 방향으로 나온 화살표가 하나 더 있으며 이는 printTemperature() 문이 포함된 상자를 가리킵니다.

printForecast()의 모든 작업이 완료되기 전에 launch { printForecast() } 호출을 반환할 수 있습니다. 그것이 코루틴의 장점입니다. 다음 launch() 호출로 이동하여 다음 코루틴을 시작할 수 있습니다. 마찬가지로 launch { printTemperature() }는 모든 작업이 완료되기 전에도 반환됩니다.

  1. (선택사항) 현재 프로그램이 얼마나 더 빠른지 확인하고 싶다면 measureTimeMillis() 코드를 추가하여 실행 시간을 확인하면 됩니다.
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            launch {
                printForecast()
            }
            launch {
                printTemperature()
            }
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}

...

출력:

Weather forecast
Sunny
30°C
Execution time: 1.122 seconds

실행 시간이 2.1초에서 1.1초로 단축되었음을 확인할 수 있습니다. 즉, 동시 작업을 추가하면 프로그램을 더 빠르게 실행할 수 있습니다. 다음 단계로 넘어가기 전에 이 시간 측정 코드를 삭제할 수 있습니다.

두 번째 launch() 호출 후 runBlocking() 코드가 끝나기 전에 다른 print 문을 추가하면 어떻게 될까요? 메시지를 출력에서 어느 지점에 표시할까요?

  1. runBlocking() 코드를 수정하여 블록 종료 전에 print 문을 추가합니다.
...

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
        println("Have a good day!")
    }
}

...
  1. 프로그램을 실행하면 다음과 같이 출력됩니다.
Weather forecast
Have a good day!
Sunny
30°C

이 출력에서 printForecast()printTemperature()의 새 코루틴 두 개가 실행된 후 Have a good day!를 출력하는 다음 명령을 진행할 수 있음을 볼 수 있습니다. 이는 launch()의 'fire-and-forget' 특성을 보여줍니다. 다시 말해 launch()로 새 코루틴을 실행한 후 작업이 언제 완료될지 걱정할 필요가 없습니다.

나중에 코루틴이 작업을 완료하고 나머지 출력 문을 출력합니다. runBlocking() 호출 본문의 모든 작업(모든 코루틴 포함)이 완료되면 runBlocking()이 반환되고 프로그램이 종료됩니다.

이제 동기 코드를 비동기 코드로 변경했습니다. 비동기 함수가 반환된다면 태스크가 아직 완료되지 않았을 수도 있습니다. launch()의 경우 확인되는 화면입니다. 함수가 반환되었지만 아직 작업이 완료되지 않았습니다. launch()를 사용하면 코드에서 여러 태스크를 동시에 실행할 수 있으며, 이는 개발하는 Android 앱에 사용할 수 있는 강력한 기능입니다.

async()

실제 환경에서 예보 및 온도에 관한 네트워크 요청을 처리하는 데 얼마나 오래 걸릴지 알 수 없습니다. 두 태스크가 모두 완료되었을 때 통합 날씨 보고를 표시하려는 경우에는 launch()를 사용하는 현재 접근 방식으로는 충분하지 않습니다. 여기서 async()가 필요합니다.

코루틴이 완료되는 시점에 관심이 있고 코루틴의 반환 값이 필요하다면 코루틴 라이브러리의 async() 함수를 사용합니다.

async() 함수는 Deferred 유형의 객체를 반환합니다. 준비가 되면 결과가 표시될 것이라는 프로미스와 같습니다. await()를 사용한 Deferred 객체의 결과에 액세스할 수 있습니다.

  1. 먼저 정지 함수를 변경하여 예보 및 온도 데이터를 출력하는 대신 String을 반환합니다. 함수 이름을 printForecast()printTemperature()에서 getForecast()getTemperature()로 업데이트합니다.
...

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 두 코루틴에 launch() 대신 async()를 사용하도록 runBlocking() 코드를 수정합니다. String 유형의 결과를 보유하는 Deferred 객체인 forecasttemperature라는 변수에 각 async() 호출의 반환 값을 저장합니다. 유형을 지정하는 것은 Kotlin의 유형 추론으로 인해 선택사항이지만 아래에서는 async() 호출에서 반환되는 항목을 더 명확하게 확인할 수 있도록 유형 지정이 포함되어 있습니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        ...
    }
}

...
  1. 나중에 코루틴에서 두 개의 async() 호출 후에 Deferred 객체에서 await()를 호출하여 코루틴의 결과에 액세스할 수 있습니다. 이 경우 forecast.await()temperature.await()를 사용하여 각 코루틴의 값을 출력할 수 있습니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        println("${forecast.await()} ${temperature.await()}")
        println("Have a good day!")
    }
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 프로그램을 실행하면 다음과 같이 출력됩니다.
Weather forecast
Sunny 30°C
Have a good day!

간단합니다. 동시에 실행되어 예보 데이터와 온도 데이터를 가져오는 두 코루틴을 만들었습니다. 각 코루틴이 완료되면 값이 반환되었습니다. 그런 다음 두 반환 값을 단일 print 문 Sunny 30°C에 결합했습니다.

병렬 분해

이 날씨 예시를 한 단계 더 발전시켜 코루틴이 작업 병렬 분해에 어떻게 유용한지 확인할 수 있습니다. 병렬 분해는 문제를 병렬로 해결할 수 있는 더 작은 하위 태스크로 세분화하는 것입니다. 하위 태스크의 결과가 준비되면 최종 결과로 결합할 수 있습니다.

코드에서 runBlocking() 본문의 날씨 보고 로직을 Sunny 30°C의 결합 문자열을 반환하는 단일 getWeatherReport() 함수로 추출합니다.

  1. 코드에 새 정지 함수 getWeatherReport()를 정의합니다.
  2. 결과적으로 날씨 보고를 가져오는 로직을 포함하고 있는 빈 람다 블록을 사용하여 함수를 coroutineScope{} 함수 호출의 결과와 동일하게 설정합니다.
...

suspend fun getWeatherReport() = coroutineScope {
    
}

...

coroutineScope{}는 이 날씨 보고 태스크의 로컬 범위를 만듭니다. 이 범위 내에서 실행된 코루틴은 이 범위 내에 그룹화되며, 취소와 예외의 영향에 관해서는 곧 배우겠습니다.

  1. coroutineScope()의 본문 내에서 async()를 사용하여 예보 데이터와 온도 데이터를 각각 가져오는 두 개의 새 코루틴을 만듭니다. 두 코루틴의 결과를 결합하여 날씨 보고 문자열을 만듭니다. async() 호출에서 반환된 각 Deferred 객체에서 await()를 호출하면 됩니다. 이렇게 하면 이 함수에서 반환되기 전에 각 코루틴이 작업을 완료하고 결과를 반환합니다.
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

...
  1. runBlocking()에서 이 새로운 getWeatherReport() 함수를 호출합니다. 전체 코드는 다음과 같습니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 프로그램을 실행하면 다음과 같이 출력됩니다.
Weather forecast
Sunny 30°C
Have a good day!

출력은 동일하지만, 몇 가지 주목할 만한 사항이 있습니다. 앞서 언급했듯이 coroutineScope()는 실행된 코루틴을 포함한 모든 작업이 완료된 후에만 반환됩니다. 이 경우에는 코루틴 getForecast()getTemperature()가 모두 완료되고 각 결과를 반환해야 합니다. 그러면 Sunny 텍스트와 30°C가 결합되고 범위에서 반환됩니다. 이 Sunny 30°C 날씨 보고가 출력되고 호출자는 Have a good day!의 마지막 print 문으로 진행할 수 있습니다.

coroutineScope()를 사용하면 함수가 내부적으로 동시에 작업을 하고 있더라도 모든 작업 완료 전까지는 coroutineScope가 반환되지 않으므로 호출자에는 함수가 동기 작업처럼 보입니다.

구조화된 동시 실행에 관한 주요 정보는 여러 개의 동시 작업을 단일 동기 작업에 적용할 수 있다는 점입니다. 여기서 동시 실행은 구현 세부정보입니다. 호출 코드의 유일한 요구사항은 호출 코드가 정지 함수 또는 코루틴에 있어야 한다는 점입니다. 이 점 외에, 호출 코드 구조에 동시 실행 세부정보를 고려할 필요가 없습니다.

4. 예외 및 취소

이제 오류가 발생하거나 일부 작업이 취소될 수 있는 상황에 관해 알아보겠습니다.

예외 소개

예외란 코드 실행 중에 발생하는 예상치 못한 이벤트입니다. 앱이 비정상 종료되어 사용자 환경에 부정적인 영향을 미치지 않도록 하려면 이러한 예외를 처리하는 적절한 방법을 구현해야 합니다.

다음은 예외와 함께 조기 종료되는 프로그램의 예입니다. 이 프로그램의 목적은 numberOfPizzas / numberOfPeople을 나눠 각 사람이 먹는 피자의 수를 계산하는 것입니다. 실수로 numberOfPeople의 값을 실제 값으로 설정하지 않았다고 가정해 보겠습니다.

fun main() {
    val numberOfPeople = 0
    val numberOfPizzas = 20
    println("Slices per person: ${numberOfPizzas / numberOfPeople}")
}

프로그램을 실행하면 산술 예외가 발생하여 비정상 종료됩니다. 숫자를 0으로 나눌 수 없기 때문입니다.

Exception in thread "main" java.lang.ArithmeticException: / by zero
 at FileKt.main (File.kt:4)
 at FileKt.main (File.kt:-1)
 at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0 (:-2)

이 문제는 간단하게 해결할 수 있습니다. numberOfPeople의 초깃값을 0이 아닌 숫자로 변경하면 됩니다. 그러나 코드가 점점 더 복잡해지면 모든 예외를 예상하고 방지할 수 없는 경우가 생깁니다.

코루틴 중 하나가 예외와 함께 실패하면 어떻게 될까요? 날씨 프로그램의 코드를 수정하여 확인해 보세요.

코루틴 예외

  1. 이전 섹션의 날씨 프로그램으로 시작합니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}

정지 함수 중 하나에서 의도적으로 예외를 발생시켜 어떤 결과가 나타나는지 확인합니다. 이는 서버에서 데이터를 가져올 때 있음직한 예상치 못한 오류가 발생하는 것을 시뮬레이션합니다.

  1. getTemperature() 함수에서 예외를 발생시키는 코드 줄을 추가합니다. Kotlin에서 throw 키워드를 사용한 후 Throwable에서 확장되는 새로운 예외 인스턴스를 사용하여 throw 표현식을 작성합니다.

예를 들어 AssertionError를 발생시키고 오류를 자세히 설명하는 메시지 문자열(throw AssertionError("Temperature is invalid"))을 전달할 수 있습니다. 이 예외를 발생시키면 getTemperature() 함수의 추가 실행이 중지됩니다.

...

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}

getTemperature() 메서드의 지연 시간을 500밀리초로 변경할 수도 있습니다. 그러면 다른 getForecast() 함수가 작업을 완료하기 전에 예외가 발생한다는 것을 알 수 있습니다.

  1. 프로그램을 실행하여 결과를 확인합니다.
Weather forecast
Exception in thread "main" java.lang.AssertionError: Temperature is invalid
 at FileKt.getTemperature (File.kt:24)
 at FileKt$getTemperature$1.invokeSuspend (File.kt:-1)
 at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)

이 동작을 이해하려면 코루틴 간에 상위-하위 관계가 있음을 알아야 합니다. 코루틴(상위 요소)에서 다른 코루틴(하위 요소)을 실행할 수 있습니다. 이러한 코루틴에서 더 많은 코루틴을 실행하면 코루틴의 전체 계층 구조를 빌드할 수 있습니다.

getTemperature()를 실행하는 코루틴과 getForecast()를 실행하는 코루틴은 동일한 상위 코루틴의 하위 코루틴입니다. 코루틴에서 예외가 발생하는 동작은 구조화된 동시 실행 때문입니다. 하위 코루틴 중 하나가 예외와 함께 실패하면 상위로 전파됩니다. 상위 코루틴이 취소되고 결국 다른 모든 하위 코루틴(여기서는 getForecast()를 실행하는 코루틴)도 취소됩니다. 마지막으로 오류가 위쪽으로 전파되고 프로그램이 AssertionError와 함께 비정상 종료됩니다.

Try-catch 예외

코드의 특정 부분에서 예외가 발생할 수 있음을 알고 있는 경우 코드를 try-catch 블록으로 둘러싸면 됩니다. 앱에서 예외를 포착하여 사용자에게 유용한 오류 메시지를 표시하는 등 더 적절하게 처리할 수 있습니다. 다음은 이러한 예를 보여주는 코드 스니펫입니다.

try {
    // Some code that may throw an exception
} catch (e: IllegalArgumentException) {
    // Handle exception
}

이 접근 방식은 코루틴이 있는 비동기 코드에도 작동합니다. 계속 try-catch 표현식을 사용하여 코루틴에서 예외를 포착하고 처리할 수 있습니다. 구조화된 동시 실행을 사용하면 순차 코드가 여전히 동기 코드이므로 try-catch 블록이 예측 가능한 동일한 방식으로 계속 작동하기 때문입니다.

...

fun main() {
    runBlocking {
        ...
        try {
            ...
            throw IllegalArgumentException("No city selected")
            ...
        } catch (e: IllegalArgumentException) {
            println("Caught exception $e")
            // Handle error
        }
    }
}

...

예외를 처리하는 데 익숙해지려면 이전에 추가한 예외를 포착하도록 날씨 프로그램을 수정하고 예외를 출력합니다.

  1. runBlocking() 함수 내에서 getWeatherReport()를 호출하는 코드 주위에 try-catch 블록을 추가합니다. 포착된 오류를 출력하고 일기 예보를 사용할 수 없다는 메시지도 출력합니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        try {
            println(getWeatherReport())
        } catch (e: AssertionError) {
            println("Caught exception in runBlocking(): $e")
            println("Report unavailable at this time")
        }
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. 프로그램을 실행하면 이제 오류가 적절하게 처리되고 프로그램의 실행이 성공적으로 완료될 수 있습니다.
Weather forecast
Caught exception in runBlocking(): java.lang.AssertionError: Temperature is invalid
Report unavailable at this time
Have a good day!

출력에서 getTemperature()가 예외를 발생시키는 것을 확인할 수 있습니다. runBlocking() 함수의 본문에서 println(getWeatherReport()) 호출을 try-catch 블록으로 둘러쌉니다. 예상되는 예외 유형을 포착합니다(이 예에서는 AssertionError). 그런 다음 예외를 "Caught exception"과 그 뒤에 오는 오류 메시지 문자열로 출력합니다. 이 오류를 처리하려면 추가 println() 문(Report unavailable at this time)을 사용하여 일기 예보를 사용할 수 없다고 사용자에게 알립니다.

이 동작은 기온을 가져오지 못하면 유효한 예보를 검색했더라도 일기 예보가 전혀 표시되지 않는다는 것을 의미합니다.

원하는 프로그램 동작 방식에 따라 날씨 프로그램에서 예외를 처리할 수 있는 다른 방법이 있습니다.

  1. 기온을 가져오기 위해 async()에서 실행한 코루틴 내에서 실제로 try-catch 동작이 발생하도록 오류 처리를 이동합니다. 이렇게 하면 기온을 가져오지 못하더라도 일기 예보에서는 예보를 계속 출력할 수 있습니다. 코드는 다음과 같습니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. 프로그램을 실행합니다.
Weather forecast
Caught exception java.lang.AssertionError: Temperature is invalid
Sunny { No temperature found }
Have a good day!

출력에서 getTemperature()를 호출하면 예외와 함께 실패하지만 async() 내 코드가 기온을 찾을 수 없다는 String을 코루틴이 계속 반환하도록 하여 해당 예외를 포착해 적절하게 처리했음을 확인할 수 있습니다. 일기 예보는 Sunny라는 성공적인 예보와 함께 계속 출력될 수 있습니다. 일기 예보에 기온이 누락되어 있지만 대신 기온을 찾을 수 없다는 메시지가 표시됩니다. 이는 오류로 인해 프로그램이 다운되는 것보다 나은 사용자 환경입니다.

이 오류 처리 방식을 고려하는 유용한 방법은 async()가 코루틴이 시작될 때 생산자라는 것입니다. await()는 코루틴의 결과를 소비하기 위해 대기하므로 소비자입니다. 생산자가 작업을 실행하고 결과를 생산합니다. 소비자는 결과를 소비합니다. 생산자에 예외가 있으면 소비자는 예외가 처리되지 않을 경우 해당 예외를 얻고 코루틴이 실패합니다. 하지만 생산자가 예외를 포착하여 처리할 수 있으면 소비자에게는 예외가 표시되지 않고 유효한 결과가 표시됩니다.

다음은 참조용 getWeatherReport() 코드입니다.

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

이 경우 생산자(async())는 예외를 포착해 처리할 수 있었지만 여전히 "{ No temperature found }"String 결과를 반환합니다. 소비자(await())는 이 String 결과를 수신하므로 예외가 발생했는지 알 필요도 없습니다. 이는 코드에서 발생할 수 있을 것으로 예상되는 예외를 적절하게 처리하는 또 다른 옵션입니다.

이제 처리되지 않는 한 코루틴 트리에서 예외가 위로 전파되는 것을 확인했습니다. 또한 예외가 계층 구조의 루트까지 전파되어 앱 전체가 비정상 종료될 수 있으므로 주의해야 합니다. 코루틴 예외 블로그 게시물 및 코루틴 예외 처리 도움말에서 예외 처리에 관해 자세히 알아보세요.

취소

예외와 유사한 주제는 코루틴 취소입니다. 이 시나리오는 일반적으로 이벤트로 인해 앱이 이전에 시작한 작업을 취소하게 된 경우 사용자 주도적입니다.

예를 들어 사용자가 앱에 더 이상 온도 값이 표시되지 않는 환경설정을 앱에서 선택했다고 가정해 보겠습니다. 사용자는 일기예보(예: Sunny)만 알고 싶으며 정확한 온도는 알고 싶지 않습니다. 따라서 현재 온도 데이터를 가져오는 코루틴을 취소합니다.

  1. 먼저 아래의 초기 코드로 시작합니다(취소 없음).
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 어느 정도 시간이 지난 후에는 일기 보고에서 예보만 표시하도록 온도 정보를 가져오는 코루틴을 취소합니다. coroutineScope 블록의 반환 값을 일기 예보 문자열로만 변경합니다.
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    
    delay(200)
    temperature.cancel()

    "${forecast.await()}"
}

...
  1. 프로그램을 실행합니다. 이제 다음과 같이 출력됩니다. 일기 보고는 Sunny 일기 예보로만 구성되며 온도는 포함되지 않습니다. 해당 코루틴이 취소되었기 때문입니다.
Weather forecast
Sunny
Have a good day!

여기서 배운 내용은 코루틴은 취소될 수 있지만 동일한 범위의 다른 코루틴에 영향을 미치지 않으며 상위 코루틴은 취소되지 않는다는 것입니다.

이 섹션에서는 코루틴에서 취소 및 예외가 작동하는 방식과 코루틴 계층 구조와 연결된 방식을 확인했습니다. 중요한 모든 부분이 어떤 역할을 하는지 파악할 수 있도록 코루틴 이면의 공식 개념을 자세히 알아보겠습니다.

5. 코루틴 개념

비동기식으로 또는 동시에 작업을 실행할 때는 작업 실행 방법, 코루틴의 지속 시간, 취소되거나 오류로 인해 실패하는 경우 발생하는 현상 등에 대한 질문에 답해야 합니다. 코루틴은 구조화된 동시 실행의 원칙을 따르므로, 메커니즘의 조합을 사용하여 코드에서 코루틴을 사용할 때 이러한 질문에 답해야 합니다.

작업

launch() 함수로 코루틴을 실행하면 Job 인스턴스가 반환됩니다. Job은 코루틴에 대한 핸들 또는 참조를 보유하므로 그 수명 주기를 관리할 수 있습니다.

val job = launch { ... }

이 작업은 수명 주기 또는 코루틴의 지속 시간을 제어하는 데 사용할 수 있습니다(예: 더 이상 작업이 필요하지 않은 경우 코루틴 취소).

job.cancel()

작업을 사용하면 활성, 취소, 완료 등의 상태를 확인할 수 있습니다. 코루틴 및 이 코루틴이 실행한 코루틴이 모든 작업을 완료하면 작업이 완료됩니다. 취소되거나 예외와 함께 실패하는 등 다른 이유로 코루틴이 완료되었을 수도 있지만 그렇더라도 작업은 해당 지점에서 완료된 것으로 간주됩니다.

또한 코루틴 간의 상위-하위 관계를 추적합니다.

작업 계층 구조

코루틴이 다른 코루틴을 실행할 때 새 코루틴에서 반환되는 작업을 원래 상위 작업의 하위 요소라고 합니다.

val job = launch {
    ...            

    val childJob = launch { ... }

    ...
}

이러한 상위-하위 관계는 각 작업이 작업을 실행할 수 있는 작업 계층 구조를 형성합니다.

이 다이어그램은 작업의 트리 계층 구조를 보여줍니다. 계층 구조의 루트에 상위 작업이 있습니다. 하위 작업 1, 하위 작업 2, 하위 작업 3의 세 개 하위 요소가 있습니다. 하위 작업 1에 두 하위 요소, 즉 하위 작업 1a 및 하위 작업 1b가 있습니다. 하위 작업 2에 하위 작업 2a라는 단일 하위 요소가 있습니다. 마지막으로 하위 작업 3에 두 하위 요소, 즉: 하위 작업 3a 및 하위 작업 3b가 있습니다.

이 상위-하위 관계는 하위 요소와 상위 요소 및 동일한 상위 요소에 속한 다른 하위 요소의 특정 동작을 지정하므로 중요합니다. 날씨 프로그램을 사용한 이전 예에서 이 동작을 확인했습니다.

  • 상위 작업이 취소되면 그 하위 작업도 취소됩니다.
  • 하위 작업이 job.cancel()을 사용하여 취소되면 종료되지만 이로 인해 상위 작업이 취소되지는 않습니다.
  • 작업이 예외와 함께 실패하면 이 예외로 상위 항목이 취소됩니다. 이를 오류 상향 전파라고 합니다(상위 요소 및 그 상위 요소로 전파). .

CoroutineScope

코루틴은 일반적으로 CoroutineScope로 실행됩니다. 이렇게 하면 코루틴이 관리되지 않아 손실되는 일이 없으므로 리소스 낭비를 방지합니다.

launch()async()CoroutineScope확장 함수입니다. 범위에서 launch() 또는 async()를 호출하여 이 범위 내에서 새 코루틴을 만듭니다.

CoroutineScope는 수명 주기와 연결되어 범위 내의 코루틴이 유지되는 기간에 경계를 설정합니다. 범위가 취소되면 작업이 취소되고 취소가 하위 작업에 전파됩니다. 범위의 하위 작업이 예외와 함께 실패하면 다른 하위 작업이 취소되고, 상위 작업이 취소되며, 호출자에 예외가 다시 발생합니다.

Kotlin 플레이그라운드의 CoroutineScope

이 Codelab에서는 프로그램의 CoroutineScope를 제공하는 runBlocking()을 사용했습니다. coroutineScope { }를 사용하여 getWeatherReport() 함수 내에 새 범위를 만드는 방법도 배웠습니다.

Android 앱의 CoroutineScope

Android는 Activity(lifecycleScope) 및 ViewModel(viewModelScope) 같이 수명 주기가 잘 정의된 항목에서 코루틴 범위를 지원합니다. 이러한 범위 내에서 시작된 코루틴은 상응하는 항목의 수명 주기(예: Activity 또는 ViewModel)를 따릅니다.

예를 들어 lifecycleScope라는 제공된 코루틴 범위로 Activity에서 코루틴을 시작한다고 가정하겠습니다. 활동이 소멸되면 lifecycleScope가 취소되고 해당되는 하위 코루틴도 모두 자동으로 취소됩니다. Activity의 수명 주기 이후에 발생하는 코루틴이 원하는 동작인지만 결정하면 됩니다.

작업할 Race Tracker Android 앱에서는 코루틴의 범위를 컴포저블의 수명 주기로 지정하는 방법을 알아봅니다.

CoroutineScope 구현 세부정보

소스 코드에서 Kotlin 코루틴 라이브러리에 CoroutineScope.kt가 어떻게 구현되는지 확인하면 CoroutineScope가 인터페이스로 선언되었고 변수로 CoroutineContext를 포함하고 있는지 살펴볼 수 있습니다.

launch() 함수와 async() 함수는 이 범위 내에 새로운 하위 코루틴을 만들고 하위 요소는 이 범위에서 컨텍스트를 상속합니다. 컨텍스트 내에 포함되는 사항에 관해서는 다음 단계에 살펴보겠습니다.

CoroutineContext

CoroutineContext는 코루틴이 실행될 컨텍스트에 관한 정보를 제공합니다. CoroutineContext는 본질적으로 각 요소에 고유한 키가 있는 요소를 저장하는 맵입니다. 필수 필드는 아니지만 컨텍스트에 포함될 수 있는 항목의 예는 다음과 같습니다.

  • 이름 - 코루틴을 고유하게 식별하는 이름
  • 작업 - 코루틴의 수명 주기를 제어함
  • 디스패처 - 작업을 적절한 스레드에 전달함
  • 예외 핸들러 - 코루틴에서 실행되는 코드에서 발생하는 예외를 처리함

컨텍스트의 각 요소는 + 연산자와 함께 추가될 수 있습니다. 예를 들어 다음과 같이 CoroutineContext을 정의할 수 있습니다.

Job() + Dispatchers.Main + exceptionHandler

이름이 제공되지 않았으므로 기본 코루틴 이름이 사용됩니다.

코루틴 내에서 새 코루틴을 실행하는 경우 하위 코루틴이 상위 코루틴의 CoroutineContext를 상속하지만 구체적으로 방금 만든 코루틴의 작업을 대체합니다. 상위 컨텍스트에서 상속된 요소를 재정의할 수 있습니다. 변경하려는 컨텍스트 부분에 관해 launch() 함수나 async() 함수에 인수를 전달하면 됩니다.

scope.launch(Dispatchers.Default) {
    ...
}

CoroutineContext에 관한 자세한 내용 및 상위 요소에서 컨텍스트가 상속되는 방법은 이 KotlinConf 회의 동영상 대화를 참고하세요.

디스패처를 여러 번 언급했습니다. 디스패처의 역할은 스레드에 작업을 전달하거나 할당하는 것입니다. 스레드와 디스패처를 더 자세히 살펴보겠습니다.

디스패처

코루틴은 디스패처를 사용하여 실행에 사용할 스레드를 결정합니다. 스레드를 시작할 수 있고, 스레드가 작업을 실행(코드 실행)한 후에 더 이상 할 작업이 없으면 종료됩니다.

사용자가 앱을 시작하면 Android 시스템에서 새 프로세스와 앱의 단일 실행 스레드(기본 스레드라고 함)가 만들어집니다. 기본 스레드는 Android 시스템 이벤트, 화면에 UI 그리기, 사용자 입력 이벤트 처리 등 앱의 여러 중요한 작업을 처리합니다. 따라서 작성하는 대부분의 앱 코드는 기본 스레드에서 실행될 수도 있습니다.

코드의 스레딩 동작과 관련하여 알아야 하는 두 가지 용어는 차단비차단입니다. 일반 함수는 작업이 완료될 때까지 호출 스레드를 차단합니다. 즉, 작업이 완료될 때까지 호출 스레드를 생성하지 않으므로 그동안 다른 작업을 실행할 수 없습니다. 반대로 비차단 코드는 특정 조건이 충족될 때까지 호출 스레드를 생성하므로 그동안 다른 작업을 할 수 있습니다. 비동기 함수를 사용하여 비차단 작업을 실행할 수 있습니다. 작업이 완료되기 전에 반환되기 때문입니다.

Android 앱의 경우 매우 빠르게 실행되는 경우에만 기본 스레드에서 차단 코드를 호출해야 합니다. 새 이벤트가 트리거되면 즉시 작업이 실행될 수 있도록 기본 스레드를 차단 해제 상태로 유지하는 것이 목표입니다. 이 기본 스레드는 활동의 UI 스레드이며 UI 그리기 및 UI 관련 이벤트를 담당합니다. 화면에 변경사항이 있으면 UI를 다시 그려야 합니다. 화면의 애니메이션 같은 항목의 경우 매끄러운 전환으로 보이도록 UI를 자주 다시 그려야 합니다. 기본 스레드가 장기 실행 작업 블록을 실행해야 하는 경우에는 화면이 자주 업데이트되지 않고 사용자에게 갑작스러운 전환('버벅거림'이라고 함)이 표시되거나 앱이 중단되거나 느리게 반응할 수도 있습니다.

따라서 장기 실행 작업 항목을 기본 스레드 외부로 이동하여 다른 스레드에서 처리해야 합니다. 앱은 단일 기본 스레드부터 시작하지만, 추가 작업을 실행할 여러 스레드를 만들 수 있습니다. 이러한 추가 스레드를 작업자 스레드라고 할 수 있습니다. 장기 실행 태스크로 인해 작업자 스레드가 오랫동안 차단되어도 괜찮습니다. 그동안 기본 스레드가 차단 해제되어 사용자에게 적극적으로 응답할 수 있기 때문입니다.

Kotlin에서 제공하는 몇 가지 기본 제공 디스패처가 있습니다.

  • Dispatchers.Main - 이 디스패처를 사용하여 기본 Android 스레드에서 코루틴을 실행합니다. 이 디스패처는 주로 UI 업데이트 및 상호작용을 처리하고 빠른 작업을 실행하는 데 사용됩니다.
  • Dispatchers.IO - 이 디스패처는 기본 스레드 외부에서 디스크 또는 네트워크 I/O를 실행하도록 최적화되어 있습니다. 예를 들어 파일에서 읽거나 파일에 쓰고 네트워크 작업을 실행합니다.
  • Dispatchers.Default - 컨텍스트에 디스패처가 지정되지 않은 상태에서 launch()async()를 호출할 때 사용되는 기본 디스패처입니다. 이 디스패처를 사용하여 계산이 많은 작업을 기본 스레드 외부에서 실행할 수 있습니다. 예를 들어 비트맵 이미지 파일을 처리합니다.

Kotlin 플레이그라운드에서 다음 예를 참고하여 코루틴 디스패처를 더 잘 이해할 수 있습니다.

  1. Kotlin 플레이그라운드에 있는 코드를 다음 코드로 바꿉니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        launch {
            delay(1000)
            println("10 results found.")
        }
        println("Loading...")
    }
}
  1. 실행된 코루틴의 콘텐츠를 withContext() 호출로 래핑하여 코루틴이 실행되는 CoroutineContext를 변경하고 구체적으로 디스패처를 재정의합니다. 현재 프로그램의 나머지 코루틴 코드에 사용되는 Dispatchers.Main 대신 Dispatchers.Default를 사용하도록 전환합니다.
...

fun main() {
    runBlocking {
        launch {
            withContext(Dispatchers.Default) {
                delay(1000)
                println("10 results found.")
            }
        }
        println("Loading...")
    }
}

withContext() 자체가 정지 함수이므로 디스패처 전환이 가능합니다. 새 CoroutineContext를 사용하여 제공된 코드 블록을 실행합니다. 새 컨텍스트는 상위 작업의 컨텍스트(외부 launch() 블록)에서 가져옵니다. 단, 상위 컨텍스트에 사용된 디스패처가 Dispatchers.Default에 지정된 컨텍스트로 재정의됩니다. 이렇게 하면 Dispatchers.Main을 사용해 작업을 실행하는 방식에서 Dispatchers.Default를 사용하는 방식으로 전환할 수 있습니다.

  1. 프로그램을 실행합니다. 출력은 다음과 같이 표시됩니다.
Loading...
10 results found.
  1. Thread.currentThread().name을 호출하여 현재 스레드를 확인하는 print 문을 추가합니다.
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("${Thread.currentThread().name} - runBlocking function")
                launch {
            println("${Thread.currentThread().name} - launch function")
            withContext(Dispatchers.Default) {
                println("${Thread.currentThread().name} - withContext function")
                delay(1000)
                println("10 results found.")
            }
            println("${Thread.currentThread().name} - end of launch function")
        }
        println("Loading...")
    }
}
  1. 프로그램을 실행합니다. 출력은 다음과 같이 표시됩니다.
main @coroutine#1 - runBlocking function
Loading...
main @coroutine#2 - launch function
DefaultDispatcher-worker-1 @coroutine#2 - withContext function
10 results found.
main @coroutine#2 - end of launch function

이 출력에서 코드의 대부분이 기본 스레드의 코루틴에서 실행되는 것을 확인할 수 있습니다. 그러나 withContext(Dispatchers.Default) 블록의 코드 부분에서는 기본 스레드가 아닌 기본 디스패처 작업자 스레드의 코루틴에서 실행됩니다. withContext()가 반환되면 코루틴이 다시 기본 스레드에서 실행됩니다(출력 문에 main @coroutine#2 - end of launch function서 확인됨). 이 예에서는 코루틴에 사용되는 컨텍스트를 수정하여 디스패처를 전환할 수 있음을 보여줍니다.

기본 스레드에서 시작된 코루틴이 있고 특정 작업을 기본 스레드 외부로 이동하려면 withContext를 사용하여 작업에 사용되는 디스패처를 전환하면 됩니다. 작업 유형에 따라 사용 가능한 디스패처인 Main, Default, IO 중에서 적절하게 선택합니다. 그런 다음 이 작업을 목적에 지정된 스레드(또는 스레드 풀이라고 하는 스레드 그룹)에 할당할 수 있습니다. 코루틴은 자체적으로 정지될 수 있으며, 디스패처 또한 코루틴이 다시 시작되는 방식에 영향을 줍니다.

Room 및 Retrofit과 같이 많이 사용되는 라이브러리로 작업할 때는(이 단원 및 다음 단원) 라이브러리 코드에서 이미 Dispatchers.IO. 같은 대체 코루틴 디스패처를 사용하여 이 작업을 처리한다면 개발자가 명시적으로 디스패처를 전환하지 않아도 됩니다. 이러한 경우 라이브러리가 표시하는 suspend 함수는 이미 기본 안전 함수일 수도 있으며 기본 스레드에서 실행되는 코루틴에서 호출할 수 있습니다. 라이브러리 자체에서 디스패처를 작업자 스레드를 사용하는 디스패처로 전환하는 작업이 처리됩니다.

이제 코루틴의 중요한 부분 및 CoroutineScope, CoroutineContext, CoroutineDispatcher, Jobs가 코루틴의 수명 주기 및 동작을 구성하는 데 담당하는 역할을 개략적으로 알아보았습니다.

6. 결론

코루틴의 이 어려운 주제를 배웠습니다. 코루틴은 실행 정지가 가능하고 기본 스레드에서 다른 작업을 실행할 수 있으며 나중에 코루틴을 다시 시작할 수 있기 때문에 매우 유용하다는 것을 배웠습니다. 이를 통해 코드에서 동시 작업을 실행할 수 있습니다.

Kotlin의 코루틴 코드는 구조화된 동시 실행의 원칙을 따릅니다. 기본적으로 순차적이므로, 동시 실행을 원하면 명시적으로 지정해야 합니다(예: launch() 또는 async() 사용). 구조화된 동시 실행을 사용하면 여러 개의 동시 작업을 단일 동기 작업에 적용할 수 있습니다. 여기서 동시 실행은 구현 세부정보입니다. 호출 코드의 유일한 요구사항은 호출 코드가 정지 함수 또는 코루틴에 있어야 한다는 점입니다. 이 점 외에, 호출 코드 구조에 동시 실행 세부정보를 고려할 필요가 없습니다. 따라서 더 쉽게 비동기 코드를 읽고 추론할 수 있습니다.

구조화된 동시 실행은 앱에서 실행된 각 코루틴을 추적하고 코루틴이 손실되지 않도록 합니다. 코루틴에는 계층 구조가 있을 수 있습니다. 즉, 태스크가 하위 태스크를 실행할 수 있으며 이 하위 태스크의 하위 태스크를 실행할 수 있습니다 작업을 사용해 코루틴 간의 상위-하위 관계를 유지하며 코루틴의 수명 주기를 제어할 수 있습니다.

시작, 완료, 취소, 실패는 코루틴 실행의 일반적인 4가지 작업입니다. 동시 프로그램을 보다 쉽게 유지할 수 있도록, 구조화된 동시 실행은 계층 구조의 일반적인 작업이 관리되는 방식의 기반이 되는 원칙을 정의합니다.

  1. 시작: 코루틴이 유지되는 기간에 정의된 경계가 있는 범위로 코루틴을 실행합니다.
  2. 완료: 작업은 하위 작업이 완료되기 전까지는 완료되지 않습니다.
  3. 취소: 이 작업은 아래로 전파되어야 합니다. 코루틴이 취소되면 하위 코루틴도 취소되어야 합니다.
  4. 실패: 이 작업은 위로 전파되어야 합니다. 코루틴에서 예외가 발생하면 상위 요소는 모든 하위 요소를 취소하고, 자체를 취소하고, 예외를 상위 요소에 전파합니다. 이 절차는 실패가 포착되고 처리될 때까지 계속됩니다. 이에 따라 코드의 오류가 손실되지 않고 제대로 신고됩니다.

코루틴을 실습하고 코루틴 이면의 개념을 이해함으로써 Android 앱에서 동시 코드를 작성할 수 있는 능력을 강화했습니다. 비동기 프로그래밍에 코루틴을 사용하면 더 쉽게 코드를 읽고 추론할 수 있으며, 취소 및 예외 상황에서 더 견고하고, 최종 사용자에게 응답성이 뛰어난 환경을 제공합니다.

요약

  • 코루틴을 사용하면 새로운 스타일의 프로그래밍을 배우지 않고도 동시에 실행되는 장기 실행 코드를 작성할 수 있습니다. 코루틴의 실행은 설계상 순차적입니다.
  • 코루틴은 구조화된 동시 실행의 원칙을 따릅니다. 이에 따라 작업이 손실되지 않고 작업 지속 기간에 대한 일정한 경계가 있는 범위에 한정되지 않습니다. 코드는 기본적으로 순차적이며 동시 실행을 명시적으로 요청(예: launch() 또는 async() 사용)하지 않는 한 기본 이벤트 루프와 협력합니다. 함수를 호출하면 구현 세부정보에 사용된 코루틴 수와 상관없이 함수는 (예외와 함께 실패하지 않는 한) 반환되기 전까지 작업을 완전히 완료해야 한다고 가정합니다.
  • suspend 수정자는 실행을 정지하고 이후 지점에서 다시 시작할 수 있는 함수를 표시하는 데 사용됩니다.
  • suspend 함수는 다른 정지 함수 또는 코루틴에서만 호출할 수 있습니다.
  • CoroutineScope에서 launch() 또는 async() 확장 함수를 사용하여 새 코루틴을 시작할 수 있습니다.
  • 작업은 코루틴의 수명 주기를 관리하고 상위-하위 관계를 유지하여 구조화된 동시 실행을 보장하는 중요한 역할을 합니다.
  • CoroutineScope는 Job을 통해 코루틴의 전체 기간을 제어하고 하위 요소와 그 하위 요소에 취소 및 기타 규칙을 재귀적으로 적용합니다.
  • CoroutineContext는 코루틴의 동작을 정의하고 작업 및 코루틴 디스패처에 관한 참조를 포함할 수 있습니다.
  • 코루틴은 CoroutineDispatcher를 사용하여 실행에 사용할 스레드를 결정합니다.

자세히 알아보기