Skip to content

일시 중단 함수 구성하기

이 섹션에서는 일시 중단 함수(suspending functions)를 구성하는 다양한 접근 방식에 대해 설명합니다.

기본적으로 순차적

어딘가에 정의된 두 개의 일시 중단 함수가 원격 서비스 호출이나 계산과 같은 유용한 작업을 수행한다고 가정해 봅시다. 이 예제에서는 유용한 작업을 하는 것처럼 보이지만, 실제로는 각 함수가 1초 동안 지연됩니다.

kotlin
suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // pretend we are doing something useful here, too
    return 29
}

이 함수들을 순차적으로 호출해야 한다면 어떻게 해야 할까요? — 먼저 doSomethingUsefulOne을 호출한 다음 doSomethingUsefulTwo를 호출하고, 그 결과의 합계를 계산해야 한다면요? 실제로 이러한 작업은 첫 번째 함수의 결과를 사용하여 두 번째 함수를 호출해야 할지 또는 어떻게 호출할지에 대한 결정을 내려야 할 때 수행합니다.

일반적인 코드와 마찬가지로 코루틴 내부의 코드도 기본적으로 _순차적_이므로 일반적인 순차적 호출을 사용합니다. 다음 예제는 두 일시 중단 함수를 실행하는 데 걸리는 총 시간을 측정하여 이를 보여줍니다.

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

fun main() = runBlocking<Unit> {
    val time = measureTimeMillis {
        val one = doSomethingUsefulOne()
        val two = doSomethingUsefulTwo()
        println("The answer is ${one + two}")
    }
    println("Completed in $time ms")
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // pretend we are doing something useful here, too
    return 29
}

전체 코드는 여기에서 확인할 수 있습니다.

결과는 다음과 같습니다.

text
The answer is 42
Completed in 2017 ms

async를 사용한 동시 실행

doSomethingUsefulOnedoSomethingUsefulTwo의 호출 사이에 종속성이 없고, 두 함수를 동시적으로 실행하여 더 빨리 결과를 얻고 싶다면 어떻게 해야 할까요? 바로 이때 async가 도움이 됩니다.

개념적으로 asynclaunch와 같습니다. async는 다른 모든 코루틴과 동시에 작동하는 경량 스레드인 별도의 코루틴을 시작합니다. 차이점은 launchJob을 반환하고 결과 값을 전달하지 않는 반면, asyncDeferred — 나중에 결과를 제공하겠다는 약속을 나타내는 경량 비블로킹 퓨처(future)를 반환한다는 것입니다. Deferred 값에 .await()를 사용하여 최종 결과를 얻을 수 있지만, Deferred는 또한 Job이므로 필요하면 취소할 수도 있습니다.

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

fun main() = runBlocking<Unit> {
    val time = measureTimeMillis {
        val one = async { doSomethingUsefulOne() }
        val two = async { doSomethingUsefulTwo() }
        println("The answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // pretend we are doing something useful here, too
    return 29
}

전체 코드는 여기에서 확인할 수 있습니다.

결과는 다음과 같습니다.

text
The answer is 42
Completed in 1017 ms

두 코루틴이 동시에 실행되므로 두 배 더 빠릅니다. 코루틴을 통한 동시성(concurrency)은 항상 명시적(explicit)이라는 점에 유의하세요.

지연 시작 async

선택적으로 asyncstart 매개변수를 CoroutineStart.LAZY로 설정하여 지연(lazy)되도록 만들 수 있습니다. 이 모드에서는 await를 통해 결과가 필요하거나 Jobstart 함수가 호출될 때만 코루틴을 시작합니다. 다음 예제를 실행해 보세요.

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

fun main() = runBlocking<Unit> {
    val time = measureTimeMillis {
        val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
        val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
        // some computation
        one.start() // start the first one
        two.start() // start the second one
        println("The answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // pretend we are doing something useful here, too
    return 29
}

전체 코드는 여기에서 확인할 수 있습니다.

결과는 다음과 같습니다.

text
The answer is 42
Completed in 1017 ms

여기서는 두 코루틴이 정의되었지만 이전 예제처럼 바로 실행되지 않고, start를 호출하여 정확히 언제 실행을 시작할지에 대한 제어권이 프로그래머에게 주어집니다. 먼저 one을 시작하고, 다음으로 two를 시작한 후, 각 코루틴이 끝날 때까지 기다립니다.

만약 각 코루틴에 대해 start를 먼저 호출하지 않고 println에서 await만 호출하면, await가 코루틴 실행을 시작하고 완료될 때까지 기다리므로 순차적인 동작이 발생할 것입니다. 이는 지연(laziness)의 의도된 사용 사례가 아닙니다. async(start = CoroutineStart.LAZY)의 사용 사례는 값 계산에 일시 중단 함수가 포함되는 경우 표준 lazy 함수를 대체하는 것입니다.

비동기 스타일 함수

이 비동기 함수 프로그래밍 스타일은 다른 프로그래밍 언어에서 인기 있는 스타일이기 때문에 여기에 예시로만 제공됩니다. 아래에 설명된 이유로 인해 이 스타일을 Kotlin 코루틴과 함께 사용하는 것은 강력히 권장되지 않습니다.

GlobalScope를 사용하여 구조화된 동시성에서 벗어나도록 async 코루틴 빌더를 사용하여 doSomethingUsefulOnedoSomethingUsefulTwo비동기적으로 호출하는 비동기 스타일 함수를 정의할 수 있습니다. 이러한 함수는 비동기 계산을 시작할 뿐이며, 결과를 얻기 위해서는 결과 Deferred 값을 사용해야 한다는 점을 강조하기 위해 "...Async" 접미사를 붙입니다.

GlobalScope는 사소하지 않은 방식으로 역효과를 낼 수 있는 섬세한 API이며, 그 중 하나는 아래에서 설명될 것입니다. 따라서 @OptIn(DelicateCoroutinesApi::class)를 사용하여 GlobalScope 사용에 명시적으로 옵트인(opt-in)해야 합니다.

kotlin
// The result type of somethingUsefulOneAsync is Deferred<Int>
@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulOneAsync() = GlobalScope.async {
    doSomethingUsefulOne()
}

// The result type of somethingUsefulTwoAsync is Deferred<Int>
@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulTwoAsync() = GlobalScope.async {
    doSomethingUsefulTwo()
}

xxxAsync 함수들은 _일시 중단 함수(suspending functions)_가 아닙니다. 이 함수들은 어디에서든 사용할 수 있습니다. 그러나 이 함수들을 사용하는 것은 항상 호출하는 코드와 해당 액션의 비동기(여기서는 동시적 의미) 실행을 암시합니다.

다음 예제는 코루틴 외부에서의 사용법을 보여줍니다.

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

// note that we don't have `runBlocking` to the right of `main` in this example
fun main() {
    val time = measureTimeMillis {
        // we can initiate async actions outside of a coroutine
        val one = somethingUsefulOneAsync()
        val two = somethingUsefulTwoAsync()
        // but waiting for a result must involve either suspending or blocking.
        // here we use `runBlocking { ... }` to block the main thread while waiting for the result
        runBlocking {
            println("The answer is ${one.await() + two.await()}")
        }
    }
    println("Completed in $time ms")
}

@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulOneAsync() = GlobalScope.async {
    doSomethingUsefulOne()
}

@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulTwoAsync() = GlobalScope.async {
    doSomethingUsefulTwo()
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // pretend we are doing something useful here, too
    return 29
}

전체 코드는 여기에서 확인할 수 있습니다.

val one = somethingUsefulOneAsync() 줄과 one.await() 표현식 사이에 코드에 논리 오류가 있고, 프로그램이 예외를 throw하여 수행 중인 작업이 중단되는 경우 어떤 일이 발생하는지 생각해 보세요. 일반적으로 전역 오류 핸들러는 이 예외를 잡아서 개발자에게 오류를 기록하고 보고할 수 있지만, 프로그램은 다른 작업을 계속할 수 있습니다. 그러나 여기서는 작업을 시작한 프로그램이 중단되었음에도 불구하고 somethingUsefulOneAsync가 여전히 백그라운드에서 실행되고 있습니다. 이러한 문제는 아래 섹션에서 보여지는 것처럼 구조화된 동시성(structured concurrency)에서는 발생하지 않습니다.

async를 사용한 구조화된 동시성

[async`를 사용한 동시 실행](#concurrent-using-async) 예제를 `doSomethingUsefulOne`과 `doSomethingUsefulTwo`를 동시에 실행하고 그 결합된 결과를 반환하는 함수로 리팩토링해 봅시다. asyncCoroutineScope 확장 함수이므로, 필요한 스코프를 제공하기 위해 coroutineScope 함수를 사용할 것입니다.

kotlin
suspend fun concurrentSum(): Int = coroutineScope {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    one.await() + two.await()
}

이러면 concurrentSum 함수 코드 내부에서 문제가 발생하여 예외가 throw되는 경우, 해당 스코프 내에서 시작된 모든 코루틴이 취소됩니다.

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

fun main() = runBlocking<Unit> {
    val time = measureTimeMillis {
        println("The answer is ${concurrentSum()}")
    }
    println("Completed in $time ms")
}

suspend fun concurrentSum(): Int = coroutineScope {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    one.await() + two.await()
}

suspend fun doSomethingUsefulOne(): Int {
    delay(1000L) // pretend we are doing something useful here
    return 13
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1000L) // pretend we are doing something useful here, too
    return 29
}

전체 코드는 여기에서 확인할 수 있습니다.

main 함수의 출력에서 볼 수 있듯이, 두 작업은 여전히 동시에 실행됩니다.

text
The answer is 42
Completed in 1017 ms

취소는 항상 코루틴 계층 구조를 통해 전파됩니다.

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    try {
        failedConcurrentSum()
    } catch(e: ArithmeticException) {
        println("Computation failed with ArithmeticException")
    }
}

suspend fun failedConcurrentSum(): Int = coroutineScope {
    val one = async<Int> { 
        try {
            delay(Long.MAX_VALUE) // Emulates very long computation
            42
        } finally {
            println("First child was cancelled")
        }
    }
    val two = async<Int> { 
        println("Second child throws an exception")
        throw ArithmeticException()
    }
    one.await() + two.await()
}

전체 코드는 여기에서 확인할 수 있습니다.

첫 번째 async와 대기 중인 부모 모두 자식 중 하나(two)의 실패 시 어떻게 취소되는지 주목하세요.

text
Second child throws an exception
First child was cancelled
Computation failed with ArithmeticException