본문 바로가기
Android/Coroutine

[Android] 코루틴(Coroutine) 내부적으로 어떻게 동작할까?

by 태크민 2025. 1. 24.

코루틴을 사용해보면 약간 궁금한 게 있었다. 어떻게 함수가 중단되고 재개되고 가능한지 의문이 생긴다. 그래서 한번 코루틴 내부를 한번 파헤쳐 보도록 하자.

 

아래 포스팅한 내용은 KotlinConf 2017 - Deep Dive into Coroutines on JVM by Roman Elizarov 영상의 내용들이다.

결론적으로 말하면, 코루틴은 디컴파일되면 일반 코드일 뿐이다. Continuation Passing Style(CPS, 연속 전달 방식) 이라는 형태의 코드로 전환한다.

 

아래에서 자세히 알아보자.

 

Continuation Passing Style(CPS)

코루틴은 컴파일러에서 CPS 로 변환이 된다. CPS 는 일종의 연속된 콜백의 형태라고 한다.

 

그래서 어떻게 동작하는지 알아보자.

 

suspend 함수가 하나 있다. 이 함수는 코루틴에서 일시중단되었다가 재개가 된다.

 

 

내부적으로는 JVM에 들어갈 때 바이트 코드로 컴파일되고 CPS로 바뀌기 위해 Continuation 이라는게 생긴다. 작성한 코루틴이 컴파일될 때 CPS 스타일로 변환이 된다.

변환은 호출한 함수에 끝에 파라미터가 하나 추가되면서 Continuation이라는 객체를 넘겨주게 된다.

‘변환이 이루어진다’ 정도로 이해하면 될 것 같다.

 

Labels

Labels?🤔

먼저 Labael이라는 작업을 하게 되는데 코루틴에서 순차적으로 작성했던 코드들이 suspend 함수가 되면 컴파일할 때 Label이 찍히게 된다.

 

이 함수가 재개되어야 하는데, 재개될 때 필요한 Suspention Point(중단 지점과 재개 지점)가 요구된다. 그래서 이 지점들을 Label로 찍어놓는 것이다. 이런 작업을 코틀린 컴파일러가 내부적으로 하게 된다.

 

switch case구현

대략적으로 위와 같은 형태가 되는데, 작성했던 함수가 내부적으론 switch-case문처럼 바뀌어 case문이 3개가 생성되고 세 번을 실행하는 것을 알 수 있다. 함수를 실행할 때 0번이든, 1번이든, 2번이든 함수를 재개할 수 있는 지점이 생긴 것이다. 그리고 이 함수를 호출한 지점은 중단점이 될 수도 있는 것이다.

 

Label들이 다 완성되고 나면 CPS (Continuatuon Passing style)로 변환하게 된다.

바로 아래에서 살펴보자.

 

Continuation

Continuation 객체가 생성

 

Continuation객체가 매번 함수가 호출될 때마다 Continuation객체를 넘겨준다.

continuation은 Callback 인터페이스 같은 것으로, 재개를 해주는 인터페이스를 가진 객체인 것이다.

 

위의 코드에서 sm이라고 하는 것은 state machine을 의미하는데, 각각의 함수가 호출될 때 상태(지금까지 했던 연산의 결과)를 같이 넘겨줘야 한다. 이 state machine의 정체는 결국 Continuation이고, Continuation이 어떠한 정보값을 가진 형태로 Passing이 되면서 코루틴이 내부적으로 동작하게 되는 것이다.

 

 

각각의 suspend 함수가 Continuation(위 코드에선 sm)을 마지막 파라미터로 갖게 된다.

 

예를 들어 requestToken이 완료되면 Continuation에 resume 을 호출하게 된다. 그럼 resume은 뭐냐? resume은 결국 자기 자신을 불러주는 호출한다. suspend 함수가 끝날 때 resume을 통해 다시 자신을 불러오게 되는데 label 값을 하나 올려서 다른 케이스가 불리게 되는 것이다. 마치 케이스가 끝날 때마다 자기 자신을 호출하고 다음 케이스를 실행하는 형태를 띈다.

 

Decomplie된 코드 살펴보기

이제 실제 코드를 한번 뜯어보자.

 

위 코루틴은 서버에서 데이터를 가져오면 데이터를 캐시하고 업데이트 해주는 코드이다.

위 코드를 코틀린 바이트 코드로 만든 다음에 디컴파일해서 자바코드바꿔보면 어떻게 되는지 확인해 보자.

 

suspend fun인 함수들이 일반 함수로 바뀌었고 마지막에 Contination이 추가되었다.

일반 코드로 작성한 코루틴이 내부적으로 CPS로 디컴파일 되고 Continuation을 가져다 주면서 계속 resume 되는 콜백형태로 디컴파일 되었다고 볼 수 있다. 

 

그리고 위의 suspend 함수를 호출하는 코드는 아래와 같이 labeling 처리가된다.

 

위에 보면  label 을 발견 할 수 있고

 

swtich — case 문도 발견할 수 있다. 그리고 메소드를 호출할 때 Contination 객체가 넘어가는 것을 확인할 수 있다.

 

하지만, 아래와 같이 CPS 스타일로 바뀌었지만 continuation은 실제로 사용하는 것 같지 않아 보인다.

 

suspend라고 선언했다고해서 모두 같은 suspend함수일까 궁금하여 기존 자바코드에 delay를 추가해보았다.

 

다시 디컴파일을 해보겠다.

위 코드는 디컴파일한 fetchUserData 함수 내부로, 단순히 delay()만 추가했음에도 불구하고 많은 코드가 생성되었다.

또한, 이전과 달리 continuation을 실제로 사용하고 있다.

이를 통해 단순히 suspend 키워드를 추가하는 것만으로는 해당 함수가 온전히 중단(suspend) 가능한 함수로 작동하지 않음을 확인할 수 있다.

즉, 코루틴의 suspend는 delay(), withContext, 네트워크 요청과 같은 중단 가능한 함수(suspending function)를 호출해야만 함수가 진정한 의미의 중단 및 재개 기능을 수행한다.

 

Continuation 내부구조

Continuation<T>는 코루틴이 중단(suspend)된 이후의 상태를 관리하고, 중단된 지점부터 다시 실행(resume)하기 위한 인터페이스이다. 

 

Continuation의 내부구조는 이외로 간단하다.

CoroutineContext와 resumeWith 함수 2가지로만 구성이 되어있다.

resumseWith은 코루틴을 실제 재게 하는 역할로 Result타입을 매개변수로 받아 성공적인 결과나 예외를 처리한다.

 

실제로, Continuation을 내부적으로 사용할 때 구현체로 BaseContinuationImpl을 사용하는데, 아래와 같이 코드가 구성되어있다.

 

다른 함수에서 continuation.resumeWith()를 호출하면 continuation객체에 담겨 있는 label 및 상태값을 확인하고 그 이후 로직을 실행한다.

위 코드를 보면 COROUTINE_SUSPENDED가 보이는데, 이는 실제 코루틴 함수가 중단(SUSPEND) 상태인지 확인하는 것이다.

그래서 함수가 진짜 종료될 때는 Result 값이 리턴이 되고, 일시정지 일때는 COROUTINE_SUSPENDED라는 상수 값을 리턴하게 된다.

 

지금까지 디컴파일 과정과 Contination 내부구조를 알아보았다.

결국 화끈하게 새롭거나 마법같은 일은 일어나지 않는다.

디컴파일 해본 결과 완벽하게 어떻게 컴파일 돼서 동작하는지 사실 잘 모르겠다. 보안적인 문제로 완전한 코드를 보여주지는 않는 것 같다.

그러한 궁금증을 조금은 이해시켜주는 설명과 코드를 제공해 주시는분이 있었는데, 참고했던 링크를 올려두겠다.

https://youtu.be/DOXyH1RtMC0

 

CPS Simulation

위 영상에서는 위 코드가 내부적으로 어떻게 돌아가는지 디컴파일 형태로 과정을 표현해주셨다.

import kotlin.coroutines.Continuation
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext

fun main() {
    println("[in] main")
    myCoroutine(MyContinuation())
    println("\n[out] main")
}

fun myCoroutine(cont: MyContinuation) {
    when(cont.label) {
        0 -> {
            println("\nmyCoroutine(), label: ${cont.label}")
            cont.label = 1
            fetchUserData(cont)
        }
        1 -> {
            println("\nmyCoroutine(), label: ${cont.label}")
            val userData = cont.result
            cont.label = 2
            cacheUserData(userData, cont)
        }
        2 -> {
            println("\nmyCoroutine(), label: ${cont.label}")
            val userCache = cont.result
            updateTextView(userCache)
        }
    }
}

fun fetchUserData(cont: MyContinuation) {
    println("fetchUserData(), called")
    val result = "[서버에서 받은 사용자 정보]"
    println("fetchUserData(), 작업완료: $result")
    cont.resumeWith(Result.success(result))
}

fun cacheUserData(user: String, cont: MyContinuation) {
    println("cacheUserData(), called")
    val result = "[캐쉬함 $user]"
    println("cacheUserData(), 작업완료: $result")
    cont.resumeWith(Result.success(result))
}

fun updateTextView(user: String) {
    println("updateTextView(), called")
    println("updateTextView(), 작업완료: [텍스트 뷰에 출력 $user]")
}

class MyContinuation(override val context: CoroutineContext = EmptyCoroutineContext)
    : Continuation<String> {

    var label = 0
    var result = ""

        override fun resumeWith(result: Result<String>) {
            this.result = result.getOrThrow()
            println("Continuation.resumeWith()")
            myCoroutine(this)
        }
    }

 

kotlin코드라 switch-case 가 아닌 when으로 구현 하셨다

 

결과

 

코드를 따라가면서 결과값을 비교해가며 보면 과정이 어느정도 이해가 된다.

 

Continuation 의 명시적인 사용

public interface Continuation<in T> {
    /**
     * The context of the coroutine that corresponds to this continuation.
     */
    public val context: CoroutineContext

    /**
     * Resumes the execution of the corresponding coroutine passing a successful or failed [result] as the
     * return value of the last suspension point.
     */
    public fun resumeWith(result: Result<T>)
}

일반적으로 Continuation 을 명시적으로 사용할 일이 많지 않을 것이다.
하지만 안드로이드에서 이를 명시적으로 사용하고, Callback 루틴을 감싸 코루틴 내부에서 간편하게 사용할 수 있는 몇 가지 예시가 있다.

View#setOnClickListener

suspend fun syncClick(): String = suspendCoroutine { cont ->
    btnEnter.setOnClickListener {
        cont.resumeWith(Result.success("Clicked"))
        btnEnter.setOnClickListener(null) // 빠르고 반복적인 클릭 작업에 대응
    }
}

lifecycleScope.launch {
    val result = syncClick()
    result // Clicked
}

클릭 리스너를 suspendCoroutine 으로 감싸 코루틴 내부에서 간결하고 순차적인 표현이 가능해진다.

 

suspendCoroutine에 대해 더 알고 싶다면 아래 포스팅을 참고 바란다.

https://jtm0609.tistory.com/227

 

[Android] 콜백을 코루틴으로 바꿔보자(SuspendCoroutine/SuspendCancellableCoroutine)

필자는 서버나, 외부 DB가 필요한 Android 프로젝트에 Firebase를 사용하고 있다. Firebase sdk의 경우 Java 코드로 구현되어 있으며, callback을 통해 비동기를 지원하는 것이 특징이다.필자는 이러한 callback

jtm0609.tistory.com

 

마지막으로 오늘 포스팅에 쓰였던 용어를 정리하며 마무리 해보자.

 

정리

CPS

  • CPS == Callbacks
  • CPS Transformation

Decompile

  • Labels
  • Callback

CPS simulation

  • By debugging

Continuation Using

  • SuspendCoroutine

 


참고자료

https://kimansu.medium.com/18-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EA%B3%B5%EB%B6%80-coroutine-%EC%BD%94%EB%A3%A8%ED%8B%B4-4%ED%8E%B8-c791d59ec639

 

#18 안드로이드 공부 — Coroutine(코루틴) 4편

오늘도 코루틴

kimansu.medium.com

https://june0122.github.io/2021/06/09/coroutines-under-the-hood/

 

[Kotlin] Coroutine - 코루틴의 내부 구현

코루틴은 디컴파일되면 일반 코드일 뿐이다. Continuation Passing Style(CPS, 연속 전달 방식) 이라는 형태로 동작하며, 결과를 호출자에게 직접 반환하는 대신 Continuation으로 결과를 전달한다. Continuation

june0122.github.io

https://ogoons.com/coroutine-continuation

 

[Coroutine] 코루틴의 Continuation 과 동작 원리

코루틴의 Continuation 은 무엇이며 suspend 함수는 내부적으로 어떻게 동작하는 지 알아보자

ogoons.com

https://youtu.be/DOXyH1RtMC0

https://www.youtube.com/watch?v=x0KY6qtuNHg