본문 바로가기

안드로이드/Kotlin

[Kotlin] 코틀린 공식 문서 - Composing suspending functions

개요

해당 게시글은 Composing suspending functions | Kotlin (kotlinlang.org)을 번역한 을 번역한 게시글 입니다.

 

Composing suspending functions

이번 섹션에서는 suspending 함수 구성에 대한 다양한 접근법을 다룬다.

 

Sequential by default

API를 호출하거나 연산하는 것과 같은 용도로 사용되는 두 개의 suspend 함수가 있다고 가정해보자. 단순히 유용할 것이라 생각했지만 이 예제에서는 각 함수는 1초씩 지연된다.

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
}

만약 이를 순서대로 실행시키거나 이 둘을 반환 값을 더하는 연산을 할 경우 어떻게 해야될까? 실제로 첫번째 함수의 결과를 사용하여 두번째 함수의 호출 여부를 결정해야 될 경우도 있을 것이다.

 

아래 코드처럼 코루틴의 코드는 일반 코드와 같이 순차적이므로 일반적인 순차적인 호출을 해도 문제는 없다. 다음 예에서는 두 가지 suspend 함수를 실행하는데 걸리는 총 시간을 측정하여 출력한다.

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

 

Concurrent using async

doSomethingUsefulOne과 doSomethingUsefulTwo 호출에 대한 종속성이 없을 경우 둘을 동시에 수행햐여 가능한 빨리 결과를 얻는게 좋다. 이는 async를 통해 가능하다.

 

개념적으로 async는 launch와 유사하다. 다른 모든 코루틴과 동시에 작동하는 경량 스레드인 새 코루틴을 시작한다. launch와의 차이점은 launch는 Job을 반환하지만 async는 Diferred를 반환한다. Deferred는 나중에 결과를 제공하겠다는 약속(promise)을 타나내는 경량 넌블러킹 객체이다. .await()을 통해 실제 결과를 받을 수 있지만 Deferred도 결국 Job이기 때문에 필요한 경우 취소도 가능하다.

 

val time = measureTimeMillis {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms")
The answer is 42
Completed in 1017 ms

두 개의 작업은 연관성이 없기에 동시에 수행되어 앞선 결과보다 더 빨리 수행되었다.

 

Lazily started async

async는 start 파라미터로 CoroutineStart.LAZY를 통해 나중에 명시적으로 실행시킬 수도 있다. 이 모드에서는 Job의 .start() 함수가 호출되거나 await()이 호출된 경우에만 코루틴을 시작한다.

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")
The answer is 42
Completed in 1017 ms

여기서 두 코루틴은 정의는 되었지만 이전 예제처럼 실행되지는 않지만 제어권이 프로그래머로 넘어가서 직접 start()를 호출하여 실행시킬 수 있다. 첫 번째로 one을 시작하고 다음 two를 시작하고 그 결과를 각 코루틴이 끝날 때 까지 기다린다.

 

각 코루틴에 start 없이 그냥 await만 println 안에서 호출할 경우 순차적인 방식으로 동작한다는 것을 유의해야 한다. await은 코루틴을 시작하고 종료를 기다리기 때문에 laziness를 위한 용도는 아니다.  async(start=CoroutineStart.LAZY)의 용도는 값 연산 과정에서 suspend 함수가 포함된 경우 표준의 lazy 함수를 대체 한다.

 

Async-style functions

 

구조화된 동시성 개념이 없는 GlobalScope의 async 코루틴 빌더를 사용하여 doSomethingUsefulOne과 doSomethingUsefulTwo 비동기적으로 호출하는 async-style 함수를 정의할 수 있다. 이런 함수는 보통 "...Async"라는 접미사를 붙여 연산이 비동기적으로 처리됨을 명시하고 결과를 얻으려면 Deferred를 사용해야 한다.

 

GlobalScope는 역효과를 낼 수 있는 민감한 API이기 때문에 @OptIn..과 함께 사용한다는 것을 명시해야 한다.

// 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()
}

"...Async" 함수는 suspend 함수가 아님에 유의해라. 어디든 사용이 가능하다. 하지만, 사용은 비동기적으로(동시에) 수행한다는 것을 암시한다.

 

아래 예제는 코루틴 밖에서 위 함수를 어떻게 사용하는지 확인할 수 있는 예제이다.

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")
}

val one = somethingUsefulOneAsync() 라인과 one.await() 사이에서 약간의 논리적인 오류가 있고 프로그램에서 예외를 발생시키고 프로그램에서 수행하던 작업이 중단되면 무슨 일이 일어나는지 생각해보자. 일반적으로 global error-handler는 이 예외를 확인하여 로그로 남기고 개발자에게 에러를 알리지만 프로그램은 다른 연산 작업을 계속할 것이다. 그러나 시작이 중단되었음에도 불구하고 somethingUsefulOneAsync는 백그라운드에서 게속 동작중이다. 이 문제는 다음 섹션에서 보듯 구조화된 동시성에서는 발생하지 않는다. 

 

참고로 위와 같은 프로그래밍 스타일은 다른 프로그래밍 언어에서 인기 있는 스타일이기 때문에 소개하지만 코틀린 코루틴과 함께 이 스타일을 사용하는 것은 위의 이유로 매우 권장하지 않는다.

 

Structured concurrency with async

Concurrenct using async 섹션에서 사용한 동시에 수행하고 해당 결과의 합계를 반환하는 함수를 추출해보겠다. async 코루틴 빌더는 CoroutineScope의 확장 함수로 정의되어있기 때문에 코루틴 스코프 함수가 제공하는 scope 내에 있어야 한다.

 

Concurrenct using async 버전

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 concurrentSum(): Int = coroutineScope {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    one.await() + two.await()
}

이 방식의 경우 GlobalScope 버전과 다르게 concurrentSum 함수 내부에서 문제가 발생할 경우 에외를 발생시키고 스코프 내에 실행된 모든 코루틴이 취소된다.

 

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

위에서 확인할 수 있는 것 처럼 두 연산은 동시에 수행되었다.

 

위에서 말한 코루틴의 취소는 코루틴 계층을 따라 전파된다.

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()
}
Second child throws an exception
First child was cancelled
Computation failed with ArithmeticException

 

첫 번째 async와 기다리고 있는 부모 코루틴이 자식 코루틴 중 하나(two)가 실패될 경우 어떻게 취소되는지 주목해라. 두번째 자식이 예외를 던지고 첫번째 자식이 같은 계층이라 취소되고 최종적으로 부모계층이 예외를 아리며 취소된다.