Deep dive into Coroutines on JVM - 2017 KotlinConf

김병수·2025년 8월 26일
post-thumbnail

들어가며

안녕하세요,
최근 진행하고 있는 프로젝트에서 안드로이드 기반의 새로운 내비게이션 컨트롤러를 개발하면서, Kotlin을 주로 사용하고 있습니다.
Kotlin을 사용하여 내비게이션 컨트롤러를 개발하다 보니, 비동기 처리를 자연스레 Coroutine으로 구현하게 되었고,
이 과정에서 Coroutine의 동작 원리가 궁금해 졌습니다.

도대체 Kotlin은 어떻게 Coroutine을 중단(suspend)했다가 다시 재개(resume)할 수 있을까요?

이 질문에 답을 하기 위해, 이번 포스팅에서는 2017 KotlinConf에서 진행된 Deep Dive into Coroutines on JVM by Roman Elizarov 세션의 내용을 바탕으로 Coroutine에 대하여 이야기해 보려고 합니다.

⚠️ 본 포스팅에 작성된 코드는 내용 설명을 돕기 위해 작성된 의사 코드이기 때문에, 문법적으로 100% 올바른 코드가 아님을 미리 공지합니다. ⚠️

Continuation

fun postItem(item: Item) {
    val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}

postItem 함수는 크게 2가지 동작을 수행합니다.

  1. requestToken 함수를 호출하여 token을 얻어옵니다.
  2. 반환된 token을 사용하여 어떤 동작을 수행하고 있는데요.

여기서 화자는 1번을 Action, 2번을 Continuation이라고 정의합니다.
Action에 따른 결과물을 바탕으로 바로 또다른 Action(Continuation)을 수행하는 이러한 코드를 Direct Style이라고 하는데요.

Direct Style이 있다면 Undirect Style도 있겠죠??
이를 Continuation Passing Style이라고 합니다.

Continuation Passing Style (CPS)

fun postItem(item: Item) {
    requestToken { token->
        val post = createPost(token, item)
        processPost(post)
    }
}

위의 코드를 보면, postItem 함수는 requestToken 함수에게 파라미터로 람다 함수를 전달합니다.
따라서 postItem은 직접적으로 createPost 함수와 processPost 함수를 호출하지 않는데요.
이와 같이 어떤 Action의 결과를 바탕으로 수행할 Action을 람다 함수로 단순 전달하는 코드를 Continuation Passing Style(이하 CPS)이라고 합니다.

사실 일반적인 Callback과 동일하지만, CPS라고 부르면 좀 더 fancy해서 이렇게 부른다고 하네요🤣

위의 코드는 100% CPS가 아니기 때문에, 이를 100% CPS로 변경하면 아래와 같이 되는데요.

fun postItem(item: Item) {
    requestToken { token->
        createPost(token, item) { post->
            processPost(post)
        }
    }
}

이러한 코드는 가독성이 좋지 않고 개발자에게 별도의 중괄호 작성을 요구합니다.
따라서 Kotlin Collaborator는 Kotlin 개발자들이 코드를 편하게 작성할 수 있도록 Direct Style로 작성된 코드를 CPS 방식으로 변경해 주는 기능을 만들게 되었고,
이 과정에서 만들어진 것이 바로 suspend function입니다.

suspend function

그렇다면 Kotlin suspend function은 어떻게 Direct Style을 CPS로 바꿔줄까요?
이를 알기 위해서는 먼저 Kotlin 컴파일러가 suspend function을 어떻게 처리하는지 알아야 합니다.
예시 코드를 통해 한 번 살펴봅시다.

suspend fun postItem(item: Item) {
    val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}

suspend fun requestToken(): Token { ... }
suspend fun createPost(token: Token, item: Item): Post { ... }
suspend fun processPost(post: Post) { ... }

Kotlin 컴파일러는 suspend function createPost를 아래와 같은 Java 함수로 변경한다고 합니다.

Object createPost(Token token, Item item, Continuation<Post> cont) { ... }

변경된 Java 함수를 보면, 파라미터로 Continuation이 추가되었다는 점 외에는 Kotlin 함수와 매우 유사한 것을 알 수 있습니다.
여기서 Continuation은 앞서 언급했던 것과 같이, 각각의 함수가 반환했던 값을 가지고 수행해야 하는 Action을 실행할 수 있는 callback interface 입니다.
실제로 Continuation 코드를 확인해보면, 아래와 같이 callback을 위한 단순한 interface임을 확인할 수 있습니다.

interface Continuation<in T> {
    val context: CoroutineContext
    fun resume(value: T)
    fun resumeWithException(exception: Exception)
}

여기까지 이해했다면, 위에서 봤던 suspend function postItem은 이미 CPS로 변경된 것이나 다름 없는데요.
지금부터는 suspend function postItem을 CPS로 변경하는 과정을 하나씩 살펴보겠습니다.

Convert suspend function to CPS

suspend fun **postItem**(item: Item) {
    val token = requestToken()val post = createPost(token, item)         ⎟ Continuation #1
    processPost(post)}

suspend function을 CPS로 변경하려면, suspend function을 N개의 Continuation으로 쪼개야 합니다.
첫 번째로는 postItem 함수 자제를 하나의 Continuation으로 볼 수 있습니다.

suspend fun postItem(item: Item) {
    val token = **requestToken**()
    val post = createPost(token, item)         ⎤ Continuation #2
    processPost(post)}

그 다음에는 requestToken 함수를 action으로 보고, 이후의 코드 2줄을 Continuation이라 볼 수 있습니다.

suspend fun postItem(item: Item) {
    ~~val token = requestToken()~~
    val post = **createPost**(token, item)
    processPost(post)                          ⎬ Continuation #3
}

마지막으로 createPost 함수를 action이라 보면, processPost 함수를 Continuation이라 볼 수 있습니다.
이렇게 suspend function을 Continuation으로 쪼갠 후에, 각 Continuation에 Label을 붙입니다.

suspend fun postItem(item: Item) {
    // LABEL 1
    val token = requestToken()
    // LABEL 2
    val post = createPost(token, item)
    // LABEL 3
    processPost(post)
}

Label을 붙인 후에는 Label을 통해 어떤 Continuation을 실행할 지 결정할 수 있어야 하기 때문에, 아래와 같은 형태로 코드가 변경됩니다.

suspend fun postItem(item: Item) {
    switch (label) {
        case 0:
            val token = requestToken()
        case 1:
            val post = createPost(token, item)
        case 2:
            processPost(post)
    }
}

여기까지 왔다면 label 1에서 필요한 token과 2에서 필요한 post 데이터를 어딘가에 저장해야 한다는 것을 알 수 있는데요.
이를 위해 상태를 저장할 수 있는 StateMachine을 사용합니다.

fun postItem(item: Item, cont: Continuation) {
    val sm = cont as ThisSM ?: object : ThisSM {
        fun resume(...) {
            postItem(null, this)
        }
    }
    switch (sm.label) {
        case 0:
            sm.item = item
            sm.label = 1
            requestToken(sm)
        case 1:
            val item = sm.item
            val token = sm.result as Token
            sm.label = 2
            val post = createPost(token, item, sm)
        case 2:
            val post = sm.result as Post
            processPost(post, sm)
    }
}

코드를 보면 알 수 있듯이, StateMachine은 Continuation의 구현체이기 때문에 상태를 저장함과 동시에 Continuation을 재개(resume)할 수도 있습니다.
이러한 방식으로 Kotlin 컴파일러는 suspend function을 CPS로 변경합니다.

사실 위와 같은 방식 외에도 callback 형태의 CPS를 사용할 수 있지만,
CPS 방식은 코드 뎁스가 깊어진다는 점과 반복문과 같은 기능을 구현하는데 어려움이 있다는 문제점 때문에 State Machine 형태를 사용한다고 합니다.

Coroutine Context

Context라는 용어는 Context Switching 개념을 알고 계신 분들에게는 매우 익숙한 용어입니다.
멀티 프로세스 환경에서 CPU가 실행 중인 프로세스를 멈추고, 다른 프로세스를 실행하기 위해 실행 중인 프로세스의 상태를 저장한 후, 새로 실행할 프로세스의 상태를 다시 레지스터에 적재하는 과정을 Context Switching이라고 하는데요.

이를 바탕으로 Coroutine Context의 의미를 유추해 보면,
실행 중인 Coroutine을 멈추고, 다른 Coroutine을 실행하기 위해 실행 중인 Coroutine의 상태를 저장한 후, 새로 실행할 Coroutine의 상태를 복원하는 과정이지 않을까? 라는 합리적 추론을 할 수 있습니다.

네, 맞습니다.

Coroutine Context는 Continuation이 어떤 Thread에서 재개되어야 하는지, Continuation을 재개할 때 필요한 데이터 등과 같이 Coroutine의 상태를 저장하고 있는 객체입니다.

사실 초기 Coroutine에는 Context라는 개념이 없었는데요.
과연 Context라는 개념은 왜 생겼을까요?

suspend fun postItem(item: Item) {
    val token = requestToken()
    val post = **createPost**(token, item)         ⎤ Continuation #2
    processPost(post)}

이 코드에서 우리는 Continuation #2가 어떤 Thread에서 재개되는지 알 수 있을까요?

정답은 “아니요” 입니다.

Continuation이 어떤 Thread에서 재개되는지는 실제 createPost 함수가 어떻게 구현되어 있는지에 달려있습니다.
따라서 후속 Continaution이 재개될 때, Thread 제약(UI 업데이트를 위해 Main Thread에서 실행되어야 하는 등)이 필요하다면 문제가 발생하게 됩니다.

Coroutine은 이러한 문제를 해결하기 위해 Coroutine Context라는 개념을 도입했습니다.
위에서 봤던 Continuation interface를 다시 한 번 살펴봅시다.

interface Continuation<in T> {
    **val context: CoroutineContext**
    fun resume(value: T)
    fun resumeWithException(exception: Exception)
}

다시 살펴 보니 Continuation이 CoroutineContext를 가지고 있다는 사실이 눈에 띄는데요. (사실 볼드체로 강조해서 그렇습니다.)
Continuation은 재개될 때마다 CoroutineContext를 통해 Thread 전환이 필요한지 확인하고, Thread 전환이 필요하다면 Thread를 전환해서 재개합니다.

그렇다면 Coroutine Context는 어떻게 Thread 전환을 지원할까요?
이 질문에 답을하기 위해서는 Continuation Interceptor와 DispatchedContinuation에 대해 알아야 합니다.

Continuation Interceptor

interface ContinuationInterceptor : CoroutineContext.Element {
    companion object Key : CoroutineContext.Key<ContinuationInterceptor>
    fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T>
}

ContinuationInterceptor의 interceptContinuation 함수는 파라미터로 Continuation을 받아서 Continuation을 반환합니다.
다시 말하면 interceptContinuation 함수는 단순 wrapper 함수이고, 우리는 이 함수를 통해 우리가 원하는 동작을 추가할 수 있다는 것을 의미합니다.
즉, interceptContinuation에서 Thread를 전환하는 동작을 추가할 수 있다는 말이 되고, 이러한 기능을 구현해 놓은 것이 바로 DispatchedContinuation 입니다.

DispatchedContinuation

class DispatchedContinuation<in T>(
    val dispatcher: CoroutineDispatcher,
    val continuation: Continuation<T>
) : Continuation<T> by continuation {
    
    override fun resume(value: T) {
        dispatcher.dispatch(context, DispatchTask(...))
    }
    
    ...
}

DispatchedContinuation은 Continuation interface의 구현체입니다.
resume 함수를 보면, CoroutineDispatcher에게 dispatch하는 것을 볼 수 있는데요.
이 과정에서 우리가 지정한 CoroutineDispatcher에 맞는 Thread로 전환되어 Continuation이 재개된다고 합니다.

마치며

지금까지 Coroutine이 어떤 원리로 동작하는지에 대하여 살짝 알아봤는데요.
이러한 원리를 알고 Coroutine을 사용하는 것과, 모르고 사용하는 것은 분명히 차이가 있다고 생각합니다.
물론 대부분의 개발 업무에서는 몰라도 충분히 개발 가능한 내용이지만, 이러한 원리가 나중에 도움이 될 것이라 생각하면서 이번 포스팅을 마무리 하겠습니다.

참고 자료

profile
주니어 개발자

0개의 댓글