Skip to content

코루틴

프로그래밍 언어에는 루틴이라는 개념이 있다.

fun main() {
...
val addedValue = plusOne(value)
}
fun plusOne(value: Int) {
return value + 1
}

위와 같은 코드가 있다고 했을떄, main 함수가 메인루틴이고 함수를 호출해서 이동하는 것은 서브루틴이라고 부른다.

서브루틴은 진입하고, 빠져나오는 지점이 명확하다. 메인 루틴이 서브루틴을 호출하면, 서브루틴의 맨 처음 부분에 진입하여 return문을 만나거나 서브루틴의 닫는 괄호를 만나면 해당 서브루틴을 빠져나오게 된다. 그리고 진입점과 탈출점 사이에 쓰레드는 블락되어있다.

우리가 보통 짜는 코드는 이렇게 동작한다. 그러나 코루틴(Coroutine)은 조금 다르다.

코루틴 함수는 꼭 return문이나 마지막 닫는 괄호를 만나지 않더라도 언제든지 중간에 나갈 수 있고, 언제든지 다시 나갔던 그 지점으로 들어올 수 있다.

image

예시

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
}

https://kotlinlang.org/docs/composing-suspending-functions.html

두 함수가 있다. 이 두 함수는 transaction을 수행하는 등 뭔가 유용한 작업을 한 뒤 Int를 반환하는 함수인데, 동작하는데 1초가 걸린다.

이 함수를 그냥 순서대로 실행하면 당연히 2초가 걸릴 것이다.

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

하지만 코루틴을 통해 이걸 비동기적으로 처리하면 두 함수를 1초만에 처리할 수 있게 된다. (두 함수간의 종속성이 없기 때문에 가능하다.)

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

async로 둘러싼 블록이 있으면, 다른 코루틴과 동시에 작동할 수 있는 별도의 코루틴이 생성되기 때문에 위 코드에서의 두 함수도 동시에 실행할 수 있다.

위 코드에서는 async를 사용했는데 개념적으로는 launch를 사용하는 것과 동일하다. 하지만 차이점이 있다면 launch는 return값을 반환하지 않는데 비해, acyns는 Deferred라는 경량 반환값을 반환한다는 것이다. (이 반환값은, 비동기 처리가 끝난 미래에 실제 값이 들어오는 것으로 약속되어있는 가상의 값이다.)

Deferred.await()을 사용하면 최종 결과를 얻을 수 있지만, Deferred도 job의 일종이기 때문에 필요한경우 취소하는 것도 가능하다.

원하면 async를 Lazy하게 설정할 수 있다. 이렇게 설정 해놓으면 await에 의해 반환값이 요구되거나 job의 시작 기능이 호출될때만 코루틴을 시작한다.

fun main() = runBlocking {
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") // about 1000ms
}

suspend 없이 일반 함수로 비동기 구현해보기

추천되는 방식은 아니지만, 우리는 GlobalScopeasync를 활용하여, 일반 함수도 비동기적으로 동작할 수 있게 할 수 있다.

// 나름 async이기 때문에 Deferred를 반환한다
@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulOneAsync() = GlobalScope.async {
doSomethingUsefulOne()
}
@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulTwoAsync() = GlobalScope.async {
doSomethingUsefulTwo()
}

이렇게 정의한 함수는 Suspending Function이 아니다. 코루틴 스코프가 아니여도 어디서든 쓰일 수 있다. 하지만, 이들은 항상 비동기적으로 (동시성을 띄며) 동작하도록 한다.

// main()에 runBlocking이 없어도 된다.
fun main() {
val time = measureTimeMillis {
// 이런 식으로, 코루틴 스코프 밖에서 Deferred 객체 생성이 가능하다.
val one = somethingUsefulOneAsync()
val two = somethingUsefulTwoAsync()
// 하지만, 결과를 받아보는 `await()` 등의 동작은 무조건 코루틴 스코프 내에서 이루어져야 한다.
// 아래 runBlocking 을 통해 13 + 29의 결과인 42가 나올 때 까지 메인 쓰레드를 블로킹하여 시간을 잰다.
runBlocking {
println("13 + 29 는 ${one.await() + two.await()} 입니다")
}
}
println("Completed in $time ms")
}

이러한 비동기 스타일의 일반 함수를 사용하는 예제는, 공식 문서에도 나와있듯이 ‘다른 프로그래밍 언어에서 많이 사용되는 스타일이기 때문에 보여주기 식으로 제공’되는 것이다. 코틀린에서는 이러한 스타일을 절대 사용하지 말 것을 권고한다. 이유는 다음과 같다.

image

만약 Async 스타일 함수를 호출하는 부분과 해당 함수의 Deferred 객체의 await()를 호출하는 부분 사이에서 어떤 에러가 발생하여 프로그램이 Exception을 throwing하고 프로그램이 중단되는 경우를 생각해보자.

오류 핸들러가 이 Exception을 감지해서 개발자에게 로그를 보여주는 식의 동작을 할 수도 있고, 아니면 그냥 다른 동작을 시작할 수도 있다.

하지만, 우리가 호출한 Async 함수는 이를 호출한 쪽에 에러가 떠러 중단되더라도 백그라운드상으로 계속 실행되어 있는 문제가 발생한다. 이 문제를 해결하기 위해서는 아래에서 설명하는 구조적 동시성 프로그래밍을 사용해야한다.

구조적 동시성 프로그래밍

조금 위에서 사용했던 동시성 계산 코드를 Suspending Function으로 분리해보자.

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

이렇게 하면 concurrentSum() 내부의 자식 코루틴 스코프 둘 중 하나에게 어떠한 에러가 발생했을떄, 상위 코루틴 스코프 coroutineScope의 실행이 중단되어 모든 자식 코루틴이 종료된다,

fun main() = runBlocking {
val time = measureTimeMillis {
println("13 + 29 는 ${concurrentSum()} 입니다")
}
println("Completed in $time ms") // about 100ms
}

아래의 예시를 보자. failedConcurrentSum() 내부에는 두 코루틴 객체가 각각 있고 두 번째 녀석은 ArithmeticException을 발생하는 녀석이다. 이 함수 자체를 try-catch 로 감쌌을 때, 어떤 결과가 나오는지 보자.

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

one 객체에 대한 동작을 수행한 다음 two를 수행했다면 긴 delay가 걸려 Exception이 던져지는 것을 볼 수 없었을 테지만, 코루틴을 통해 동시에 동작하기 때문에 two의 Exception으로 인해 catch문에 바로 걸리는 것을 볼 수 있다.

이를 통해 coroutineScope() 안에서 오류가 발생하면 해당 코루틴 자체가 중단되어 다른 자식 코루틴도 모두 중단되고, 결국 최상위 계층까지 오류가 전파되는 사실을 알 수 있다. 따라서, 백그라운드 상으로 코루틴이 남아있는 문제는 발생하지 않는다.