Coroutine의 중단과 재개

JeongYong Park·2024년 5월 5일
1
post-custom-banner

회사에서 일을 하면서 Kotlin을 사용하게 되었습니다. 이전까지 Java를 학습해오던 저에게 Kotlin은 꽤 매력적인 언어로 느껴졌습니다. Java의 불필요한 코드(boilerplate)들이 최소화되고, 좀 더 안전하게 코딩을 할 수 있게 된 것 같습니다. 입사 5개월차인 지금, kotlin에 어느정도 익숙해졌습니다.

하지만 여전히 알듯말듯한 코루틴(coroutine) 이라는 존재가 꽤 재밌으면서 어렵습니다. 지금까지 코틀린 코루틴을 사용하면서 알게 된 지식들을 정리하고자 합니다.

코루틴(Coroutine)?

사실 코루틴(coroutine)이라는 개념은 코틀린에만 존재하는 개념은 아닙니다. 위키피디아에서는 coroutine을 아래와 같이 정의하고 있습니다.

Coroutines are computer program components that allow execution to be suspended and resumed, generalizing subroutines for cooperative multitasking.

코루틴을 정의해보면, 중단되었다가 다시 실행할 수 있는 프로그램 컴포넌트라고 할 수 있습니다.
이 말만으로는 이해하기가 어려워 좀 더 자세히 알아보겠습니다.

co + routine

coroutine은 함께 / 협력하는(co) 함수(routine) 입니다. (여기서 루틴(routine)을 함수로 보면 좋을 것 같습니다.) 그런데 코루틴은 일반적인 함수와는 다르게 중간에 일시중단(suspend)하고 함수 블럭을 벗어나 다른 작업을 수행할 수 있습니다.

위에서도 언급했듯이 코틀린 코루틴이 도입한 핵심 기능은 코루틴을 특정 지점에서 중단하고 이후에 다시 재개할 수 있다는 것입니다. 코루틴을 중단시켰을 때 스레드는 블로킹(blocking)되지 않으며 다른 코루틴을 실행시키는 작업이 가능합니다. (아래에서 다루겠습니다.)

이제 예를 들어, 세 개의 API를 호출하고 결과를 이용해 사용자에게 보여줘야하는 경우 코틀린 코루틴을 통해 아래와 같이 간단하게 작성할 수 있습니다.

suspend fun showSomeInfo() {
	val config = async { getConfigFromApi() }
    val news = async { getNewsFromApi(config.await()) }
    val user = async { getUserFromApi() }
    
    view.showNews(user.await(), news.await())
}

문법을 몰라도 쉽게 읽히지 않나요? 이렇게 코틀린 코루틴은 쉽게 적용이 가능하다는게 장점입니다.

코루틴의 중단

코루틴에서 중단은 함수의 실행을 중간에 멈춘다는 것을 의미합니다. 우리는 게임을 하다가도 부모님이 부르면 중간에 잠깐 게임을 잠시 저장해 두었다가 집안일을 하고 저장한 지점부터 다시 게임을 시작할 수 있습니다ㅎㅎ.. 코루틴도 이와 비슷하게 함수의 실행을 중간에 멈추고 저장해두었다가 다른 함수를 실행하다가 다시 돌아와 원래 작업을 이어하게 됩니다.

여기서 저장한다는게 코루틴이 스레드와는 다르다는 것을 의미할 수 있는데, 스레드는 저장이 불가능하고 멈추는 것만 가능합니다. (마치 체크포인트 없는 게임..) 또한 코루틴은 중단되었을 때 어떠한 자원도 사용하지 않는 것이 특징입니다. 이는 코루틴이 중단되었을 때 Continuation 라는 객체를 반환해서 현재까지의 진행정보를 저장하기 때문입니다.

아직 어떤 의미인지 크게 와닿지는 않고 이게 어떻게 가능한지 잘 이해가 되지 않습니다. 우선 코루틴은 스레드와 다르게 중단이 가능하다 정도만 이해하고 다음으로 넘어가도 좋을 것 같습니다.

코루틴의 재개

중단이 있다면 작업을 다시 재개할 수도 있어야겠죠. 코루틴을 만들어서 중간에 코루틴을 중단해보도록 하겠습니다. 코루틴을 만드는 방법은 여러가지가 있지만 여기서는 실행하기 쉽게 중단가능한 main 함수를 만들겠습니다.

suspend fun main() {
    println("BEFORE SUSPEND")

    suspendCoroutine<Unit> {  }

    println("AFTER SUSPEND")
}

위 코드를 실행하게 되면 BEFORE SUSPEND를 출력하고 프로그램은 실행된 상태로 유지됩니다. 바로 suspendCoroutine을 통해 BEFORE와 AFTER 사이를 중단했기 때문입니다. 이 프로그램은 중단된채로 재개되지 않는데, 어떻게 다시 실행시킬 수 있을까요?

앞서 언급했던 Continuation을 기억하시나요? 이 Continuation 객체를 통해 함수를 재개할 수 있습니다. suspendCoroutine 함수를 보면 Continuation을 인자로 받는 것을 확인할 수 있습니다.

public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T {
	//...
}

suspendCoroutine 함수는 중단되기 전 Continuation 객체를 사용할 수 있습니다. suspendCoroutine이 호출된 후에는 Continuation 객체를 사용할 수 없기 때문에 람다 표현식이 함수의 인자로 들어가 중단되기 전 실행이 됩니다. 그러면 다시 함수를 재개시켜보겠습니다.

suspend fun main() {
    println("BEFORE SUSPEND")

    suspendCoroutine<Unit> { continuation ->
        continuation.resume(Unit)
    }

    println("AFTER SUSPEND")
}

이렇게 코루틴은 언제든 중단될 수 있습니다. 이게 어떻게 가능한지 내부동작이 궁금해집니다.

CPS(Continuation Passing Style)

코틀린은 CPS(Continuation Passing Style)를 통해 중단함수를 구현했습니다. 컨티뉴에이션 객체는 함수의 마지막 인자를 통해 들어가게 됩니다.

suspend fun getUser(): User?
suspend fun setUser(user: User)
suspend fun sum(val1: Int, val2: Int): Int
fun getUser(continuation: Continuation<*>): Any?
fun setUser(user: User, continuation: Continuation<*>): Any
fun sum(val1: Int, val2: Int, continuation: Continuation<*>): Any

중단함수(suspend function)을 자세히 보면 원래 선언했던 형태와 반환타입이 달라진 걸 확인할 수 있습니다. 그리고 반환타입은 Any 혹은 Any? 로 바뀐 것을 확인할 수 있는데 이는 중단함수를 실행하는 도중에 중단되면 선언된 타입의 값을 반환하지 않을 수도 있기 때문입니다. 우선 getUser 함수가 User?나 COROUTINE_SUSPEND를 반환할 수 있기 때문에 반환타입이 Any?로 지정되었다는 것만 확인하면 됩니다.

간단한 예제

suspend fun sum(num1: Int, num2: Int): Int {
	println("sum START")
    delay(1000)
    println("sum END")
    return num1 + num2
}

sum 함수의 시그니처를 예측하면 아래와 같습니다.

fun sum(num1: Int, num2: Int, continuation: Continuation<*>): Any

이 함수가 시작되는 지점은 함수가 처음 호출되거나 중단 이후의 재개 지점일 것입니다. 코틀린은 모든 중단 가능한 지점을 찾아 when으로 표현하게 됩니다. 코틀린 컴파일러는 내부적으로 중단 가능한 지점을 식별하고 이 지점들로 코드를 구분하게 됩니다. 이때 구분된 코드들을 분리하기 위해 label이라는 필드를 사용하게 되는데 함수가 처음 시작될 때 이 값은 0으로 지정됩니다. 이후에는 중단되기 전 다음 상태로 설정되어 코루틴이 재개될 시점을 알 수 있게 해줍니다. (이런 방식을 state machine이라고도 부르기도 합니다..)

fun sum(num1: Int, num2: Int, continuation: Continuation<Unit>): Any {
    when (continuation.label) {
    	0 -> { // 함수가 처음 호출될 때
        	println("sum Start")
            continuation.label = 1
            if (delay(1000, continuation) == COROUTINE_SUSPEND) {
                return COROUTINE_SUSPEND
            }
        }
        1 -> { // 중단 이후의 호출 지점
            println("sum END")
            return num1 + num2
        }
    }
}

함수가 delay라는 중단 함수에 의해 중단된 경우 COROUTINE_SUSPEND가 반환되고 sum은 COROUTINE_SUSPEND를 반환합니다. sum을 호출한 함수부터 시작해 콜 스택에 있는 함수도 마찬가지입니다. 따라서 중단이 일어나면 콜 스택에 있는 모든 함수가 종료되고, 중단된 코루틴을 수행하던 스레드를 실행 가능한 코드가 사용할 수 있게 해줍니다.

이제 익명 클래스로 구현된 컴파일된 Continuation객체를 간단하게 나타내보면 아래와 같습니다.

cont = new ContinuationImpl(continuation) {
    Object result;
    int label = 0;
    
    @Nullable
    public final Object invokeSuspend(@NotNull Object $result) {
       this.result = $result;
       return sum(var0, var1, this);
    }
}

result로 코루틴 내의 상태를 관리하고 label 을 통해 어디서부터 재시작할 지를 관리합니다.

정리

현재 회사에서 코루틴을 활용해서 로직을 작성하는 일이 많습니다. 처음 코투틴을 접했을 때는 이게 대체 무슨 소리인지 잘 몰랐고, 궁금한 것들이 많았습니다. 지금은 이건가..? 하면서 계속 배우는 느낌이네요. 아직 배울게 넘쳐나는 것 같습니다.

참고자료

https://ko.wikipedia.org/wiki/%EC%BD%94%EB%A3%A8%ED%8B%B4
https://m.yes24.com/Goods/Detail/123034354
https://myungpyo.medium.com/%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B3%B5%EC%8B%9D-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%9E%90%EC%84%B8%ED%9E%88-%EC%9D%BD%EA%B8%B0-part-1-dive-3-b174c735d4fa

profile
다음 단계를 고민하려고 노력하는 사람입니다
post-custom-banner

0개의 댓글