Suspend 함수 컴파일과 코루틴 상태 머신
Suspend 함수 컴파일과 코루틴 상태 머신
코틀린의 suspend 함수는 소스 코드 상에서는 순차적인 코드처럼 보이지만, 컴파일러가 내부적으로 CPS(Continuation-Passing Style) 기반의 상태 머신(state machine)으로 변환합니다. 이 변환이야말로 코루틴 시스템 전체의 근간이 되는 핵심 메커니즘으로, 스레드를 블로킹하지 않으면서도 콜백 없이 일시 중단과 재개를 수행할 수 있는 원리입니다. 컴파일러가 생성하는 코드의 구조, Continuation 인터페이스의 역할, 레이블 기반 상태 전이, 그리고 일시 중단과 재개 사이의 원자적(atomic) 조율 방식을 이해하면, 코루틴의 동작을 진단하고 성능을 분석하는 데 큰 도움이 됩니다. 면접에서도 이러한 내부 구현에 대한 질문이 자주 등장하므로, 깊이 있게 학습해 두시는 것을 권장합니다.
이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.
- 코틀린 컴파일러가 suspend 함수에 CPS 변환을 적용하는 방식
Continuation인터페이스와 생성되는ContinuationImpl서브클래스의 역할- 레이블 기반 상태 머신의 실행 흐름 추적 및 일시 중단 지점에서 지역 변수가 유지되는 원리
COROUTINE_SUSPENDED가 실제 일시 중단 여부와 fast path를 제어하는 메커니즘invokeSuspend,intercepted(),resumeCancellableWith를 통한 코루틴 시작 및 디스패치 과정
CPS 변환과 Continuation 인터페이스
모든 suspend 함수는 컴파일 타임에 CPS(Continuation-Passing Style) 변환을 거칩니다. 컴파일러는 함수 시그니처에 숨겨진 Continuation 매개변수를 추가하고, 반환 타입을 Any?로 변경합니다. 예를 들어, 아래와 같이 작성된 suspend 함수가 있다고 가정해 보겠습니다.
suspend fun fetchUser(): User {
val response = api.getUser()
val processed = processData(response)
return processed
}
이 함수는 바이트코드 수준에서 개념적으로 다음과 같은 형태로 변환됩니다.
fun fetchUser(continuation: Continuation<User>): Any? {
// 상태 머신 본문
}
Continuation 인터페이스 자체는 매우 간결한 구조를 가지고 있습니다.
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
Continuation은 CoroutineContext를 보유하며, 일시 중단된 코루틴에 결과를 전달하는 resumeWith 콜백을 제공합니다. 모든 suspend 함수는 호출자의 Continuation을 마지막 매개변수로 전달받는 구조로 되어 있습니다. 함수 실행이 완료되면 해당 Continuation의 resumeWith를 호출하여 결과를 상위로 전달하고, 일시 중단이 필요한 경우에는 Continuation을 저장해 두었다가 나중에 비동기 콜백이 resumeWith를 호출할 수 있도록 합니다.
반환 타입이 Any?로 변경되는 이유는 함수가 두 가지 중 하나를 반환할 수 있기 때문입니다. 함수가 실제로 일시 중단되면 센티널(sentinel) 값인 COROUTINE_SUSPENDED를 반환하고, 일시 중단 없이 동기적으로 완료(fast path)되면 실제 결과 값을 직접 반환합니다. 호출자는 반환 값을 검사하여 실행을 계속할지, 아니면 제어권을 넘길지 결정하게 됩니다. 이 설계 덕분에 불필요한 일시 중단 오버헤드 없이 효율적인 실행이 가능합니다.
생성된 상태 머신과 레이블 디스패치
컴파일러는 suspend 함수의 본문을 상태 머신으로 변환하며, 각 일시 중단 지점(suspension point)에 레이블을 부여합니다. 두 개의 일시 중단 지점을 가진 함수를 살펴보겠습니다.
suspend fun loadData(): String {
val token = fetchToken() // 일시 중단 지점 0
val data = fetchData(token) // 일시 중단 지점 1
return data
}
컴파일러는 이 함수의 가변 상태를 보관하기 위한 ContinuationImpl 서브클래스를 생성합니다. 변환된 코드는 개념적으로 다음과 같은 형태입니다.
fun loadData(completion: Continuation<String>): Any? {
class LoadDataSM(
completion: Continuation<String>
) : ContinuationImpl(completion) {
var result: Any? = null
var label: Int = 0
var token: String? = null // 일시 중단 지점 이후에도 유지해야 하는 지역 변수
override fun invokeSuspend(result: Any?): Any? {
this.result = result
return loadData(this) // 함수 재진입
}
}
val sm = completion as? LoadDataSM ?: LoadDataSM(completion)
// 아래에서 계속...
레이블에 대한 디스패치가 상태 전이를 주도합니다. 외부 루프는 호출한 suspend 함수가 일시 중단 없이 동기적으로 결과를 반환하는 경우(fast path), 별도의 스케줄링 없이 곧바로 다음 레이블로 진행할 수 있도록 설계되어 있습니다.