아티클 목록으로 가기

마법 뒤의 기계 장치: 코틀린이 suspend를 상태 머신으로 변환하는 과정

skydovesJaewoong Eum (skydoves)||23분 소요

마법 뒤의 기계 장치: 코틀린이 suspend를 상태 머신으로 변환하는 과정

코틀린 코루틴(Kotlin Coroutines)은 JVM에서 비동기 프로그래밍의 표준으로 자리 잡았습니다. 코루틴 덕분에 스레드를 블로킹하지 않으면서도 순차적이고 읽기 쉬운 코드를 작성할 수 있으며, 필요할 때 일시 중단했다가 다시 재개할 수 있습니다. 대부분의 개발자는 launch, async, Flow와 같은 익숙한 API를 통해 코루틴을 사용하면서, suspend 키워드가 알아서 잘 동작하는 것으로 받아들입니다. 하지만 코루틴은 단순히 언어 위에 올려진 라이브러리 기능이 아닙니다. 코루틴은 코틀린 컴파일러의 IR 로우어링(lowering) 파이프라인과 바이트코드 생성을 통해 구현된 컴파일러 수준의 솔루션입니다. 순차적으로 작성된 코드를 재개 가능한 상태 머신(state machine)으로 변환하며, suspend 키워드는 함수의 구조, 시그니처, 제어 흐름을 JVM에 도달하기 전에 완전히 재작성하는 일련의 컴파일러 변환을 촉발합니다.

이 글에서는 코틀린 컴파일러의 코루틴 내부 기계 장치를 깊이 있게 살펴봅니다. suspend 함수를 상태 머신으로 변환하는 6단계 파이프라인을 추적하면서, 컴파일러가 CPS 변환을 통해 숨겨진 Continuation 매개변수를 주입하는 방법, 새로운 호출과 재개를 구분하기 위한 부호 비트(sign bit) 트릭을 활용하여 Continuation 클래스를 생성하는 방법, 바이트코드 수준의 변환기가 일시 중단 지점(suspension point)을 수집하고 TABLESWITCH 디스패치를 삽입하는 방법, 일시 중단 경계를 넘어 지역 변수를 유지하기 위해 Continuation 필드에 "스필링(spilling)"하는 방법, 그리고 모든 일시 중단 지점이 꼬리 호출(tail call)임을 증명할 수 있을 때 컴파일러가 상태 머신 전체를 생략하는 꼬리 호출 최적화까지 하나하나 추적해 보겠습니다.

근본적인 문제: 함수를 어떻게 재개 가능하게 만들 수 있을까?

다음 suspend 함수를 살펴보겠습니다.

suspend fun fetchUserData(): UserData {
    val user = fetchUser()
    val profile = fetchProfile(user.id)
    return UserData(user, profile)
}

평범한 순차 코드처럼 보이지만, fetchUser()fetchProfile() 모두 수백 밀리초가 걸릴 수 있는 네트워크 요청을 수행할 수 있습니다. 이 함수는 각 호출 지점에서 일시 중단하여 스레드를 완전히 해제한 뒤, 나중에 모든 지역 변수를 온전히 유지한 채 정확히 멈춘 지점에서 실행을 재개할 수 있어야 합니다.

JVM은 이를 위한 네이티브 메커니즘을 제공하지 않습니다. JVM 메서드는 스택 프레임(stack frame)이며, 메서드가 반환되면 해당 스택 프레임은 사라집니다. 스택 프레임을 "동결"하여 스레드를 해제한 뒤 나중에 복원하는 방법은 존재하지 않습니다. 스레드를 해제하려면 함수가 반환해야 하지만, 반환하면 지역 상태가 파괴됩니다. 이것이 바로 코루틴 컴파일이 해결해야 하는 근본적인 딜레마입니다.

코틀린 컴파일러는 각 suspend 함수를 상태 머신으로 변환하여 이 문제를 해결합니다. 함수 본문을 일시 중단 지점 사이의 세그먼트로 분할하고, 각 일시 중단 전에 지역 변수를 Continuation 객체의 필드에 저장하며, 재개 후 복원합니다. label 필드가 다음에 실행할 세그먼트를 추적하고, 함수 진입부의 TABLESWITCH가 올바른 세그먼트로 디스패치합니다. 개발자는 선형적인 코드를 작성하고, 컴파일러가 이를 분해한 뒤 필요에 따라 재조립하는 기계 장치를 생성하는 것입니다.

6단계 파이프라인: suspend에서 상태 머신까지

이 변환은 JVM 백엔드(backend)에서 6개의 서로 다른 단계에 걸쳐 수행됩니다. 각 단계가 왜 존재하며 무엇을 담당하는지 이해하려면, 전체 파이프라인을 이해하는 것이 필수적입니다.

  1. SuspendLambdaLowering: suspend 람다 표현식을 익명 Continuation 클래스로 변환
  2. TailCallOptimizationLowering: 꼬리 위치(tail position)에 있는 suspend 호출을 식별하고 IrReturn 래퍼로 표시
  3. AddContinuationLowering: 핵심 IR 로우어링 단계로, Continuation 클래스를 생성하고, $completion 매개변수를 주입하며, 정적(static) suspend 구현체를 생성
  4. 코드 생성(Code generation): IR을 JVM 바이트코드로 로우어링하면서, 각 일시 중단 지점 전후에 BeforeSuspendMarker/AfterSuspendMarker 명령어를 배치
  5. CoroutineTransformerMethodVisitor: 바이트코드 수준의 상태 머신 엔진으로, TABLESWITCH 삽입, 변수 스필링, 재개 경로 생성을 수행
  6. 꼬리 호출 최적화 검사: 모든 일시 중단 지점이 꼬리 호출이면 상태 머신을 전체적으로 생략

각 단계를 순서대로 추적해 보겠습니다.

CPS 변환: 보이지 않는 매개변수

코루틴 컴파일의 토대는 CPS(Continuation-Passing Style) 변환입니다. 모든 suspend 함수는 컴파일 시 숨겨진 추가 매개변수인 Continuation을 전달받습니다. 이 Continuation은 함수가 완료되거나 일시 중단된 후 "다음에 무엇을 해야 하는지"를 나타냅니다.

다음과 같이 작성하면,

suspend fun fetchUser(): User {
    // ...
}

컴파일러는 시그니처를 다음과 같이 변환합니다.

fun fetchUser($completion: Continuation<User>?): Any?

두 가지 변화가 일어납니다. 첫째, Continuation 타입의 $completion 매개변수가 추가됩니다. 둘째, 반환 타입이 Any?로 변경됩니다. 함수가 이제 실제 결과를 반환하거나, 함수가 일시 중단되어 나중에 Continuation을 통해 결과를 전달하겠다는 의미의 특수 센티널(sentinel) 값 COROUTINE_SUSPENDED를 반환할 수 있기 때문입니다.

AddContinuationLowering이 이 주입을 수행하는 방식을 살펴보겠습니다.

val continuationParameter = buildValueParameter(function) {
    kind = IrParameterKind.Regular
    name = Name.identifier(SUSPEND_FUNCTION_COMPLETION_PARAMETER_NAME) // "$completion"
    type = continuationType(context).substitute(substitutionMap)        // Continuation<RetType>?
    origin = JvmLoweredDeclarationOrigin.CONTINUATION_CLASS
}

이 매개변수는 기본 인자(default argument) 마스크 앞에, 그리고 모든 일반 매개변수 뒤에 삽입됩니다. 소스 코드에서는 보이지 않지만 바이트코드에는 항상 존재합니다. suspend 함수의 모든 호출 지점도 마찬가지로 현재 Continuation을 이 추가 인자로 전달하도록 재작성됩니다.

Continuation 클래스: 상태가 저장되는 곳

코루틴 컴파일의 핵심 산출물(artifact)은 Continuation 클래스입니다. 각 이름이 있는 suspend 함수에 대해, 컴파일러는 ContinuationImpl을 상속하는 내부 클래스를 생성하며, 이 클래스는 일시 중단과 재개에 필요한 모든 상태를 보유합니다.

AddContinuationLowering.ktgenerateContinuationClassForNamedFunction을 살펴보겠습니다.

context.irFactory.buildClass {
    name = Name.special("<Continuation>")
    origin = JvmLoweredDeclarationOrigin.CONTINUATION_CLASS
}.apply {
    superTypes += context.symbols.continuationImplClass.owner.defaultType

    val resultField = addField(CONTINUATION_RESULT_FIELD_NAME, ...)   // "result"
    val labelField = addField(COROUTINE_LABEL_FIELD_NAME, ...)        // "label"
    val capturedThisField = ...  // 인스턴스 메서드를 위해 외부 `this`를 캡처

    addConstructorForNamedFunction(capturedThisField, ...)
    addInvokeSuspendForNamedFunction(irFunction, resultField, labelField, ...)
}

생성된 클래스는 세 가지 핵심 필드를 갖습니다.

  • label: Int: 상태 머신 인덱스로, 함수 본문에서 다음에 실행할 세그먼트를 추적
  • result: Any?: 코루틴이 재개될 때 resumeWith에 전달된 값을 보유
  • this$0 (선택 사항): 인스턴스 메서드의 디스패치 수신자(dispatch receiver)를 캡처

추가로, 스필링된 지역 변수 필드(L$0, L$1, I$0 등)는 바이트코드 변환 중에 나중에 추가됩니다. 이 필드들은 일시 중단 지점을 넘어 유지해야 하는 지역 변수를 저장합니다.

invokeSuspend 메서드: 재진입 지점

Continuation 클래스는 invokeSuspend를 오버라이드합니다. 코루틴 런타임은 일시 중단된 코루틴을 재개할 때 이 메서드를 호출합니다. 이 메서드는 재개 값을 저장하고, label에 부호 비트를 설정한 뒤, 원래 함수를 다시 호출합니다.

override fun invokeSuspend(result: Result<Any?>): Any? {
    this.result = result
    this.label = this.label or (1 shl 31)  // 부호 비트 설정
    return foo(this)  // 함수에 `this`를 Continuation으로 전달하여 재진입
}

부호 비트 트릭은 자세히 살펴볼 가치가 있습니다.

부호 비트 트릭: 새로운 호출과 재개를 구분하는 방법

suspend 함수가 $completion으로 Continuation을 전달받으면, 중요한 질문에 답해야 합니다. "이것이 새로운 호출인가, 아니면 이전 일시 중단에서 재개되는 것인가?" 이 답에 따라 처음부터 시작할지, 저장된 상태로 점프할지가 결정됩니다.

세 가지 시나리오가 존재합니다.

  1. 다른 suspend 함수에서의 직접 호출: 호출자가 제공한 Continuation을 가진 새로운 호출
  2. resumeWith를 통한 재개: 런타임이 invokeSuspend를 호출하고, invokeSuspendContinuation 객체 자체를 $completion으로 전달하여 함수에 재진입
  3. 재귀 호출: 함수가 자기 자신을 호출하면서 동일한 타입의 Continuation을 전달

시나리오 1과 시나리오 2, 3을 구분하기 위해, 컴파일러는 INSTANCEOF 검사를 사용합니다. $completion이 함수 고유의 Continuation 클래스의 인스턴스라면, 재개이거나 재귀 호출일 수 있습니다. 시나리오 2와 시나리오 3을 구분하기 위해서는 label 필드의 부호 비트를 사용합니다.

val signBit = 1 shl 31   // 0x80000000
+irSetField(
    irGet(function.dispatchReceiverParameter!!), labelField,
    irCallOp(
        context.irBuiltIns.intClass.functions.single {
            it.owner.name == OperatorNameConventions.OR
        },
        context.irBuiltIns.intType,
        irGetField(irGet(function.dispatchReceiverParameter!!), labelField),
        irInt(signBit)  // label = label | 0x80000000
    )
)

invokeSuspend가 호출되면(재개 경로), label0x80000000을 OR 연산합니다. 함수 진입부의 프리앰블(prelude)은 이 비트를 검사하여, 비트가 설정되어 있으면 진짜 재개로 판단합니다. 비트가 설정되어 있지 않으면, INSTANCEOF 검사를 통과하더라도 재귀 호출이므로 새로운 호출로 처리합니다.

바이트코드 변환기의 prepareMethodNodePreludeForNamedFunction에서 생성하는 진입부 프리앰블은 이 로직을 세 단계로 구현합니다.

먼저, 들어온 $completion이 이 함수 고유의 Continuation 클래스 인스턴스인지 검사합니다.

ALOAD $completion
INSTANCEOF Foo$1 // 우리의 Continuation 클래스인가?
IFEQ createNewContinuation // 아니라면 -> 새로운 호출

INSTANCEOF 검사를 통과하면, 프리앰블은 이를 캐스팅하고 label의 부호 비트를 검사합니다. 바로 이 부분에서 재개와 재귀 호출의 구분이 이루어집니다.

ALOAD $completion
CHECKCAST Foo$1
ASTORE $continuation

ALOAD $continuation
GETFIELD label
ICONST 0x80000000
IAND // label & 0x80000000
IFEQ createNewContinuation // 부호 비트 미설정 -> 재귀 호출이므로 새로운 호출로 처리

부호 비트가 설정되어 있다면 진짜 재개입니다. 프리앰블은 비트를 지워서 원래의 label 값을 복원하고, 상태 머신 디스패치로 점프합니다.

ALOAD $continuation
DUP
GETFIELD label
ICONST 0x80000000
ISUB // label - 0x80000000 (부호 비트 제거)
PUTFIELD label
GOTO afterCreate

두 검사 중 하나라도 실패하면(클래스가 다르거나 부호 비트가 없는 경우), 프리앰블은 새로운 Continuation을 할당하고 재개 값을 로드합니다.

createNewContinuation:
  NEW Foo$1
  DUP
  ALOAD this
  ALOAD $completion
  INVOKESPECIAL Foo$1.<init>
  ASTORE $continuation

afterCreate:
  ALOAD $continuation
  GETFIELD result
  ASTORE $result // 재개 값을 $result 지역 변수에 로드

이 설계는 정말 인상적입니다. label 정수의 단일 비트 하나가, 추가 필드 없이도 비트 AND 연산 하나의 런타임 비용만으로 세 가지 서로 다른 호출 시나리오를 완벽하게 구분하는 플래그 역할을 합니다.

정적(static) suspend 구현체: 가상 디스패치 회피

오버라이드 가능한 suspend 함수(final이 아니고 private이 아닌 함수)에 대해, 컴파일러는 정적 구현체 메서드를 생성하고 원래 함수는 이에 위임하도록 재작성합니다.

private fun createStaticSuspendImpl(irFunction: IrSimpleFunction): IrSimpleFunction {
    val static = createStaticFunctionWithReceivers(
        irFunction.parent,
        irFunction.name.toSuspendImplementationName(),  // "foo$suspendImpl"
        irFunction,
        origin = JvmLoweredDeclarationOrigin.SUSPEND_IMPL_STATIC_FUNCTION,
    )
    static.body = irFunction.moveBodyTo(static)

    // 원래 메서드는 단순 포워더가 됩니다:
    irFunction.body = irBuilder.irBlockBody {
        +irReturn(irCall(static).also {
            it.arguments.assignFrom(irFunction.parameters, ::irGet)
        })
    }
    return static
}

상태 머신은 정적 메서드 foo$suspendImpl에 존재합니다. 원래의 가상(virtual) 메서드는 단순히 위임만 합니다. 이렇게 하는 데는 중요한 이유가 있습니다. 서브클래스가 foo를 오버라이드하면, 재개된 Continuation은 서브클래스의 오버라이드가 아닌 원래 구현체의 상태 머신을 호출해야 하기 때문입니다. 정적 디스패치가 이를 보장합니다.

suspend 람다 변환: 익명 Continuation 클래스

suspend 람다는 SuspendLambdaLowering을 통해 다른 경로로 처리됩니다. 각 suspend 람다는 SuspendLambda를 상속하는 익명 클래스로 변환됩니다.

val suspendLambda =
    if (reference.isRestrictedSuspension)
        context.symbols.restrictedSuspendLambdaClass.owner
    else
        context.symbols.suspendLambdaClass.owner

// 이 클래스는 SuspendLambda와 FunctionN+1을 모두 상속합니다
superTypes = listOf(suspendLambda.defaultType, functionNType)

람다 매개변수는 JVM 타입 디스크립터(descriptor)에 기반한 명명 규칙을 사용하여 필드로 저장됩니다. 참조 타입은 L$0, L$1, 정수는 I$0, long 타입은 J$0 등입니다. 이 명명 규칙은 바이트코드 변환기가 충돌 없이 스필 필드를 할당하는 데 활용되므로 중요합니다.

인자 수(arity)가 0 또는 1인 람다에 대해, 컴파일러는 create(completion) 팩토리 메서드를 생성합니다. 생성자는 처음에 completion 매개변수에 null을 전달합니다.

+irCall(continuation.constructors.single().symbol).apply {
    arguments[0] = irNull()  // completion = 처음에는 null
}

실제 completion은 나중에 create(completion) 또는 invoke 오버라이드를 통해 제공됩니다. 이러한 분리 덕분에 동일한 람다 클래스를 한 번 인스턴스화한 뒤, 서로 다른 completion으로 여러 번 호출할 수 있습니다.

바이트코드 변환기: 상태 머신이 탄생하는 곳

IR 로우어링과 코드 생성이 완료된 후에도, 각 suspend 함수의 바이트코드는 여전히 대부분 선형적인 형태입니다. 각 일시 중단 지점 전후에 합성(synthetic) BeforeSuspendMarker/AfterSuspendMarker 명령어가 배치되어 있을 뿐입니다. CoroutineTransformerMethodVisitor가 바로 이 마커를 소비하고 실제 상태 머신을 조립하는 지점입니다.

코루틴 컴파일에서 가장 복잡한 부분이기도 합니다. ASM MethodNode 트리를 대상으로 동작하며, 정해진 순서에 따라 변환을 수행합니다.

변환 파이프라인

performTransformations 메인 드라이버 메서드를 살펴보면, 파이프라인은 IR 코드 생성에서 남은 바이트코드를 정규화하는 정리(cleanup) 단계부터 시작합니다.

override fun performTransformations(methodNode: MethodNode) {
    removeFakeContinuationConstructorCall(methodNode)            // 1. IR 플레이스홀더 제거
    replaceReturnsUnitMarkersWithPushingUnitOnStack(methodNode)  // 2. 실제 Unit 푸시 삽입
    replaceFakeContinuationsWithRealOnes(methodNode)             // 3. ACONST_NULL을 실제 로드로 대체
    FixStackMethodTransformer().transform(...)                   // 4. 인라이닝으로 인한 스택 형태 수정

다음으로, 일시 중단 지점을 식별하고 최적화 단계를 수행합니다.

    val suspensionPoints = collectSuspensionPoints(methodNode)                    // 5. 모든 마커 쌍 찾기
    RedundantLocalsEliminationMethodTransformer(suspensionPoints).transform(...)  // 6. 데드 코드 제거
    ChangeBoxingMethodTransformer.transform(...)                                  // 6. 박싱 정리
    checkForSuspensionPointInsideMonitor(methodNode, suspensionPoints)            // 7. 불법 suspend 검사

이 시점에서 변환기는 전체 상태 머신을 아예 생략할 수 있는지 검사합니다. 모든 일시 중단 지점이 꼬리 호출이면, 빠른 경로(fast path)를 선택합니다.

    if (isForNamedFunction &&
        methodNode.allSuspensionPointsAreTailCalls(suspensionPoints, ...)) {
        methodNode.addCoroutineSuspendedChecks(suspensionPoints)
        dropSuspensionMarkers(methodNode)
        return  // 상태 머신이 필요 없음
    }

빠른 경로가 적용되지 않으면, 변환기는 전체 상태 머신을 구축합니다. 나머지 단계들은 각각 이전 단계에 의존하여 순서대로 실행됩니다.

    prepareMethodNodePreludeForNamedFunction(methodNode)                       // 8. 진입부 프리앰블
    for (point in suspensionPoints) {
        splitTryCatchBlocksContainingSuspensionPoint(methodNode, point)        // 9. try-catch 분할
    }
    spillVariables(suspensionPoints, methodNode)                               // 10. 변수 스필링
    val stateLabels = suspensionPoints.withIndex().map {
        transformCallAndReturnStateLabel(it.index + 1, it.value, methodNode, ...)  // 11. 지점별 로직
    }
    generateStateMachinesTableswitch(methodNode, ..., suspensionPoints, stateLabels) // 12. TABLESWITCH
    dropSuspensionMarkers(methodNode)                                          // 13. 정리
}

각 단계는 명확한 목적을 가지고 있습니다. 가장 중요한 단계들을 자세히 살펴보겠습니다.

try-catch 분할: 일시 중단을 넘나드는 예외 처리

9단계에서는 일시 중단 지점을 둘러싼 try-catch 블록을 분할합니다. 문제는 소스 코드의 단일 try-catch 블록이 여러 일시 중단 지점에 걸쳐 있을 수 있다는 점입니다.

suspend fun riskyOperation(): String {
    try {
        val a = fetchA()    // 일시 중단 지점 1
        val b = fetchB(a)   // 일시 중단 지점 2
        return process(a, b)
    } catch (e: Exception) {
        return "fallback"
    }
}

JVM 바이트코드에서 try-catch 블록은 시작 레이블, 끝 레이블, 핸들러 레이블로 정의됩니다. 하지만 함수가 일시 중단되어 COROUTINE_SUSPENDED를 반환하면, 실행이 try-catch 범위를 완전히 벗어나게 됩니다. 이후 상태 레이블에서 재개될 때는 원래 try-catch 범위 바깥에 있는 TABLESWITCH를 통해 메서드에 재진입합니다.

변환기는 일시 중단 지점을 포함하는 각 try-catch 블록을 여러 블록으로 분할하여 이 문제를 해결합니다. 일시 중단 전 코드를 위한 블록과 일시 중단 후 재개 경로를 위한 블록으로 나누며, 각 재개 레이블은 동일한 핸들러를 가리키는 자체 try-catch 항목을 갖게 됩니다. 이를 통해 재개 중 발생하는 예외(예를 들어, resumeWith가 실패 결과를 전달하는 경우)도 원래 핸들러에서 여전히 잡을 수 있습니다.

7단계의 checkForSuspensionPointInsideMonitor는 관련이 있지만 다른 역할을 수행합니다. synchronized 블록 내부의 suspend 호출을 감지하여 오류를 보고합니다. 모니터를 보유한 채 일시 중단하면 락을 잡은 상태로 스레드를 해제하게 되어 데드락이 발생할 수 있습니다. 컴파일러는 런타임에서 조용히 실패하는 대신 컴파일 타임에 이를 포착합니다.

일시 중단 지점 수집: 경계 찾기

상태 머신을 구축하기 전에, 변환기는 일시 중단 지점의 위치를 식별해야 합니다. 코드 생성 시, 각 suspend 함수 호출은 합성 마커 명령어로 둘러싸입니다.

ICONST_0
INVOKESTATIC InlineMarker.mark()    // BeforeSuspendMarker
... 실제 suspend 호출 ...
ICONST_1
INVOKESTATIC InlineMarker.mark()    // AfterSuspendMarker

collectSuspensionPoints 메서드는 바이트코드를 순회하면서 각 BeforeSuspendMarker/AfterSuspendMarker 쌍을 식별하고, SuspensionPoint 객체를 구성합니다.

private fun collectSuspensionPoints(methodNode: MethodNode): List<SuspensionPoint> {
    val cfg = ControlFlowGraph.build(methodNode, followExceptions = false)

    return methodNode.instructions.filter { isBeforeSuspendMarker(it) }
        .mapNotNull { start ->
            val ends = mutableSetOf<AbstractInsnNode>()
            collectSuspensionPointEnds(start, mutableSetOf(), ends)
            val end = ends.find { isAfterSuspendMarker(it) } ?: return@mapNotNull null
            SuspensionPoint(start.previous, end)
        }.toList()
}

SuspensionPointstateLabel을 가지며, 이는 해당 지점에서 재개할 때 TABLESWITCH가 점프할 LabelNode입니다.

변수 스필링: 일시 중단을 넘어 지역 변수 저장하기

함수가 일시 중단되면, JVM 스택 프레임은 파괴됩니다(함수가 COROUTINE_SUSPENDED를 반환하기 때문). 재개 후 필요한 지역 변수는 영속적인 어딘가에 저장해야 합니다. 컴파일러는 이 변수들을 Continuation 객체의 필드에 저장하는데, 이 과정을 "스필링(spilling)"이라고 합니다.

spillVariables 메서드는 생존 분석(liveness analysis)을 수행하여 각 일시 중단 지점에서 어떤 변수가 살아 있는지 결정한 뒤, 저장 및 복원 바이트코드를 생성합니다.

private fun spillVariables(suspensionPoints, methodNode) {
    val frames = performSpilledVariableFieldTypesAnalysis(...)
    val livenessFrames = analyzeLiveness(methodNode)

    for (suspension in suspensionPoints) {
        val variablesToSpill = calculateVariablesToSpill(...)

        // 참조 타입과 원시 타입 분리: 참조는 스필 후 null 처리하여 GC 누수 방지
        val (references, primitives) = variablesToSpill.partition {
            it.normalizedType == OBJECT_TYPE
        }

        for (variable in references + primitives) {
            generateSpillAndUnspill(methodNode, suspension, variable, ...)
        }
    }
}

살아 있는 각 변수에 대해, 변환기는 다음을 삽입합니다.

일시 중단 지점 이전(스필)

ALOAD $continuation
ALOAD localVar         // 또는 ILOAD, LLOAD 등
PUTFIELD Foo$1.L$0     // Continuation 필드에 저장

재개 레이블 이후(언스필)

ALOAD $continuation
GETFIELD Foo$1.L$0     // Continuation 필드에서 복원
ASTORE localVar

필드는 타입과 인덱스로 명명됩니다. 객체 참조는 L$0, L$1, 정수는 I$0, long은 J$0, double은 D$0입니다. 컴파일러는 일시 중단 지점을 넘어 살아 있는 변수만 필드로 승격시킵니다. 단일 상태 내에서만 사용되는 변수는 일반 스택 할당 지역 변수로 유지됩니다.

여기서 중요한 점이 있습니다. 참조 타입 변수는 복원된 후 Continuation에서 null로 처리됩니다. 함수가 이미 사용을 마친 객체에 대해 Continuation이 강한 참조(strong reference)를 유지하는 것을 방지하기 위해서입니다. 그렇지 않으면 코루틴이 오래 일시 중단되어 있는 동안 메모리 누수를 유발할 수 있습니다.

TABLESWITCH: 상태 머신 디스패치

상태 머신의 마지막 조각은 함수 진입부의 디스패치 메커니즘입니다. generateStateMachinesTableswitch 메서드가 label 필드를 읽고 올바른 재개 지점으로 점프하는 TABLESWITCH 명령어를 삽입합니다.

먼저, COROUTINE_SUSPENDED 센티널을 캐시하고 현재 label을 로드합니다.

methodNode.instructions.insertBefore(actualCoroutineStart, insnListOf(
    *withInstructionAdapter { loadCoroutineSuspendedMarker() }.toArray(),
    VarInsnNode(ASTORE, suspendMarkerVarIndex),   // 센티널을 지역 변수에 캐시
    VarInsnNode(ALOAD, continuationIndex),
    *withInstructionAdapter { getLabel() }.toArray(),  // GETFIELD label

그런 다음 상태별 케이스를 가진 TABLESWITCH를 삽입합니다.

    TableSwitchInsnNode(
        0,                          // min = 0 (초기 호출)
        suspensionPoints.size,      // max = N
        defaultLabel,               // default: IllegalStateException 발생
        firstStateLabel,            // case 0: 초기 진입
        *stateLabels.toTypedArray() // case 1..N: 재개 지점
    ),
    firstStateLabel
))

기본(default) 케이스는 잘못된 상태를 잡습니다. 예를 들어, Continuation이 두 번 이상 재개되는 경우입니다.

methodNode.instructions.insert(last, withInstructionAdapter {
    AsmUtil.genThrow(
        this,
        "java/lang/IllegalStateException",
        ILLEGAL_STATE_ERROR_MESSAGE  // "call to 'resume' before 'invoke' with coroutine"
    )
})

상태 0은 초기 진입점입니다(함수가 처음 호출될 때). 상태 1부터 N까지는 각 일시 중단 이후의 재개 지점에 해당합니다.

COROUTINE_SUSPENDED 센티널은 메서드 최상단에서 한 번 로드되어 지역 변수($suspendMarker)에 저장됩니다. 각 일시 중단 지점 검사에서 getCOROUTINE_SUSPENDED()를 반복적으로 호출하는 것을 피하기 위함입니다.

각 일시 중단 지점의 처리: label 설정, 검사, 반환

각 일시 중단 지점에 대해, transformCallAndReturnStateLabel은 세 가지 로직을 삽입합니다.

첫째, suspend 호출 전에 일시 중단 지점의 ID를 label 필드에 기록하여 현재 상태를 저장합니다.

insertBefore(suspension.suspensionCallBegin, withInstructionAdapter {
    visitVarInsn(ALOAD, continuationIndex)
    iconst(id)
    setLabel()  // PUTFIELD label = id
})

suspend 호출이 반환된 후에는, 함수가 실제로 일시 중단되었는지 검사합니다. 반환값이 COROUTINE_SUSPENDED이면 센티널을 호출 스택 위로 전파합니다. 재개 레이블(TABLESWITCH가 재진입 시 점프할 위치)은 바로 뒤에 배치됩니다.

insert(suspension.tryCatchBlockEndLabelAfterSuspensionCall, withInstructionAdapter {
    dup()
    load(suspendMarkerVarIndex, OBJECT_TYPE)  // COROUTINE_SUSPENDED 로드
    ifacmpne(continuationLabel)               // 일시 중단되지 않았으면? 건너뜀

    load(suspendMarkerVarIndex, OBJECT_TYPE)
    areturn(OBJECT_TYPE)                      // COROUTINE_SUSPENDED를 호출자에게 반환

    visitLabel(suspension.stateLabel.label)   // 재개 레이블 (TABLESWITCH 타겟)
})

재개 레이블에서 변환기는 예외 검사를 수행하고(resumeWith가 실패로 호출된 경우를 대비), $result를 스택에 로드하여 마치 suspend 호출이 정상적으로 반환한 것처럼 처리합니다.

insert(possibleTryCatchBlockStart, withInstructionAdapter {
    generateResumeWithExceptionCheck(dataIndex)  // ResultKt.throwOnFailure($result)
    load(dataIndex, OBJECT_TYPE)                 // $result를 "반환값"으로 푸시
})

모든 일시 중단 지점에서 이 패턴이 일관되게 적용됩니다.

  1. label = id를 설정하여 TABLESWITCH가 재개 시 어디로 점프할지 기록
  2. 실제 suspend 호출 수행(Continuation을 전달)
  3. 반환값이 COROUTINE_SUSPENDED인지 검사하고, 맞으면 상위로 전파
  4. 호출이 동기적으로 완료되면(빠른 경로), 다음 명령어로 계속 진행
  5. 재개 레이블에서 throwOnFailure를 호출하여 resumeWith의 예외를 전파한 뒤, $result를 스택에 로드하여 마치 suspend 호출이 정상 반환한 것처럼 처리

4단계의 빠른 경로는 매우 중요합니다. suspend 함수가 실제로 일시 중단하지 않고 완료되면(예를 들어, 캐시된 값을 반환하는 경우), 어떤 일시 중단 기계 장치의 오버헤드도 없이 실행이 계속됩니다. 상태 저장도, 스레드 전환도, 디스패치도 필요하지 않습니다. 동기적 완료라는 일반적인 경우가 극도로 저렴하게 처리되는 것입니다.

꼬리 호출 최적화: 상태 머신 전체 생략

모든 suspend 함수에 전체 상태 머신이 필요한 것은 아닙니다. 함수 내의 모든 일시 중단 지점이 꼬리 호출(suspend 호출의 반환값이 즉시 반환되는 경우)이면, 컴파일러는 상태 머신 전체를 건너뛰고 훨씬 단순한 형태를 생성할 수 있습니다.

이 최적화는 두 가지 수준에서 이루어집니다. 먼저, IR 수준에서 TailCallOptimizationLowering이 꼬리 위치의 suspend 호출을 식별합니다.

override fun visitCall(expression: IrCall, data: TailCallOptimizationData?): IrExpression {
    val transformed = super.visitCall(expression, data) as IrExpression
    return if (data == null || expression !in data.tailCalls) transformed
    else IrReturnImpl(
        data.function.endOffset, data.function.endOffset,
        context.irBuiltIns.nothingType,
        data.function.symbol,
        if (data.returnsUnit) transformed.coerceToUnit() else transformed
    )
}

그런 다음, 바이트코드 수준에서 TailCallOptimization.ktallSuspensionPointsAreTailCalls가 제어 흐름 분석(control flow analysis)을 수행하여 최적화가 안전한지 검증합니다.

fun MethodNode.allSuspensionPointsAreTailCalls(suspensionPoints, ...): Boolean {
    val frames = MethodTransformer.analyze("fake", this, TcoInterpreter(suspensionPoints))

    return suspensionPoints.all { suspensionPoint ->
        // try-catch 블록 내부에 있으면 안 됨
        tryCatchBlocks.all { index < it.start || it.end <= index } &&
        // 호출 후 ARETURN(또는 POP + Unit + ARETURN)만 허용
        suspensionPoint.suspensionCallEnd.transitiveSuccessorsAreSafeOrReturns(...)
    }
}

검사를 통과하면, TABLESWITCH, 스필링, Continuation 클래스 인스턴스화를 포함한 전체 상태 머신을 구축하는 대신, 변환기는 각 호출 후 단순히 COROUTINE_SUSPENDED 검사만 삽입합니다.

fun MethodNode.addCoroutineSuspendedChecks(suspensionPoints) {
    for (suspensionPoint in suspensionPoints) {
        if (suspensionPoint.suspensionCallEnd.nextMeaningful?.opcode == ARETURN) continue
        instructions.insert(suspensionPoint.suspensionCallEnd, withInstructionAdapter {
            dup()
            loadCoroutineSuspendedMarker()
            ifacmpne(label)
            areturn(OBJECT_TYPE)    // COROUTINE_SUSPENDED 전파
            mark(label)
        })
    }
}

상당히 큰 최적화 효과를 가져옵니다. 꼬리 호출 최적화가 적용된 suspend 함수는 Continuation 클래스 할당도, 필드 스필링도, TABLESWITCH도 없습니다. 일시 중단 지점당 하나의 참조 비교만 추가되는 것이므로, 일반 함수 호출과 거의 같은 비용으로 실행됩니다.

IR 코드 생성과 바이트코드 변환의 연결 고리

IR 코드 생성과 바이트코드 수준 변환기 사이의 연결은 CoroutineCodegen.kt에서 이루어집니다. acceptWithStateMachine 확장 함수가 생성된 MethodNodeCoroutineTransformerMethodVisitor로 감쌉니다.

internal fun MethodNode.acceptWithStateMachine(
    irFunction: IrFunction,
    classCodegen: ClassCodegen,
    methodVisitor: MethodVisitor,
    varsCountByType: Map<Type, Int>,
    obtainContinuationClassBuilder: () -> ClassBuilder,
) {
    val visitor = CoroutineTransformerMethodVisitor(
        methodVisitor, access, name, desc,
        containingClassInternalName = classCodegen.type.internalName,
        obtainClassBuilderForCoroutineState = obtainContinuationClassBuilder,
        isForNamedFunction = irFunction.isSuspend,
        needDispatchReceiver = irFunction.isSuspend &&
            (irFunction.dispatchReceiverParameter != null || ...),
        initialVarsCountByType = varsCountByType,
    )
    accept(visitor)
}

JvmIrCoroutineUtils.kthasContinuation() 판별자(predicate)가 어떤 함수가 이 경로를 거치는지 결정합니다.

fun IrFunction.hasContinuation(): Boolean =
    isInvokeSuspendOfLambda() ||
    isSuspend && shouldContainSuspendMarkers() &&
    !isEffectivelyInlineOnly() &&
    origin != IrDeclarationOrigin.INLINE_LAMBDA &&
    origin != JvmLoweredDeclarationOrigin.FOR_INLINE_STATE_MACHINE_TEMPLATE

실질적으로 인라인(inline)인 함수나, 인라이너를 위한 템플릿 역할을 하는 함수는 상태 머신을 건너뜁니다. 해당 함수의 코드가 호출자의 상태 머신에 이식(transplant)되기 때문입니다.

전체 그림: suspend 함수 변환 과정 추적

모든 조각이 함께 작동하는 모습을 보기 위해, 구체적인 함수의 변환 과정을 처음부터 끝까지 추적해 보겠습니다.

suspend fun loadData(id: Int): String {
    val token = authenticate()       // 일시 중단 지점 1
    val data = fetch(id, token)      // 일시 중단 지점 2
    return process(data)             // 일시 중단 지점 3 (꼬리 호출)
}

AddContinuationLowering 이후 (IR 수준)

함수 시그니처가 다음과 같이 변환됩니다.

fun loadData(id: Int, $completion: Continuation<String>?): Any?

Continuation 클래스가 생성됩니다.

class LoadData$1(
    var I$0: Int,       // `id`를 위한 스필 필드
    var result: Any?,
    var label: Int,
    completion: Continuation<*>?
) : ContinuationImpl(completion) {
    override fun invokeSuspend(result: Result<Any?>): Any? {
        this.result = result
        this.label = this.label or 0x80000000  // 부호 비트 설정
        return loadData(0, this)               // 재진입
    }
}

바이트코드 변환 이후

최종 바이트코드 변환은 여러 단계에 걸쳐 이루어집니다. 각 단계를 순서대로 살펴보겠습니다.

메서드는 프리앰블로 시작합니다. 부호 비트 트릭 섹션에서 설명한 것과 정확히 동일하게, INSTANCEOF 검사, 부호 비트 검사, Continuation 생성 또는 재사용이 수행됩니다. 프리앰블 이후에는 COROUTINE_SUSPENDED 센티널이 한 번 로드되어 캐시되고, label에 기반하여 TABLESWITCH가 디스패치합니다.

INVOKESTATIC getCOROUTINE_SUSPENDED; ASTORE $suspended
ALOAD $cont; GETFIELD label
TABLESWITCH 0..3:
    0 -> state_0
    1 -> state_1
    2 -> state_2
    3 -> state_3
    default -> throw IllegalStateException

상태 0은 초기 진입입니다. 컴파일러는 idContinuation에 스필하고(첫 번째 일시 중단 이후에도 필요하므로), label = 1을 설정한 뒤, authenticate를 호출합니다. 호출이 COROUTINE_SUSPENDED를 반환하면, 함수는 즉시 반환하여 스레드를 해제합니다.

state_0:
    ALOAD $cont; ILOAD id; PUTFIELD I$0      // id 스필
    ALOAD $cont; ICONST 1; PUTFIELD label     // 다음 상태 설정
    ALOAD $cont; INVOKEVIRTUAL authenticate   // suspend 호출
    DUP; ALOAD $suspended; IF_ACMPNE -> state_0_continue
    ARETURN                                    // 일시 중단됨: 스레드 해제

상태 1authenticate() 완료 후의 재개 지점입니다. 변환기는 먼저 예외를 검사하고(resumeWith가 실패로 호출된 경우), Continuation에서 id를 복원(언스필)하며, 결과를 token으로 저장합니다.

state_1:
    ALOAD $result; INVOKESTATIC throwOnFailure  // 실패이면 예외 발생
    ALOAD $cont; GETFIELD I$0; ISTORE id        // id 언스필
    ALOAD $result; ASTORE token                  // authenticate의 결과

그런 다음 실행은 두 번째 일시 중단을 준비하기 위해 계속됩니다. 여기서는 변수를 스필할 필요가 없습니다. idtokenfetch의 인자로 소비되며, 호출이 반환된 후에는 어디서도 참조되지 않기 때문입니다. 재개 후 필요한 유일한 값은 data이며, $result를 통해 전달됩니다.

state_0_continue:
    ALOAD $cont; ICONST 2; PUTFIELD label         // 다음 상태 설정
    ILOAD id; ALOAD token; ALOAD $cont; INVOKEVIRTUAL fetch
    DUP; ALOAD $suspended; IF_ACMPNE -> state_1_continue
    ARETURN                                        // 일시 중단됨

상태 2fetch() 이후 재개됩니다. 결과가 data가 됩니다.

state_2:
    ALOAD $result; INVOKESTATIC throwOnFailure
    ALOAD $result; ASTORE data

process(data)에 대한 최종 호출은 꼬리 호출입니다. 컴파일러는 여전히 label = 3을 설정하고 COROUTINE_SUSPENDED를 검사하지만, 호출 이후에 아무것도 없으므로 스필링은 필요하지 않습니다.

state_1_continue:
    ALOAD $cont; ICONST 3; PUTFIELD label
    ALOAD data; ALOAD $cont; INVOKEVIRTUAL process
    DUP; ALOAD $suspended; IF_ACMPNE -> state_2_continue
    ARETURN

state_3:
    ALOAD $result; INVOKESTATIC throwOnFailure
    ALOAD $result

state_2_continue:
    ARETURN                                        // 최종 결과 반환

이것이 전체 변환의 완성된 모습입니다. 3줄짜리 순차 함수가 4개의 상태를 가진 상태 머신으로 변환되었으며, 하나의 지역 변수(id, 첫 번째 일시 중단을 넘어 살아 있음)에 대한 필드 스필링, 각 재개 지점에서의 예외 검사, 그리고 진입부의 TABLESWITCH 디스패치를 포함합니다.

JS 및 Wasm 백엔드의 다른 접근 방식

JVM 백엔드는 ASM 트리 조작을 사용하여 바이트코드 수준에서 상태 머신 변환을 수행합니다. JS와 Wasm 백엔드는 근본적으로 다른 접근 방식을 취합니다. 상태 머신을 전적으로 IR 안에서 구축합니다.

AbstractSuspendFunctionsLowering이 공통 프레임워크를 제공합니다.

abstract class AbstractSuspendFunctionsLowering<C : CommonBackendContext>(val context: C) {
    protected abstract val stateMachineMethodName: Name
    protected abstract fun buildStateMachine(
        stateMachineFunction: IrFunction,
        transformingFunction: IrFunction,
        argumentToPropertiesMap: Map<IrValueParameter, IrField>,
    )
}

JS 백엔드의 StateMachineBuilder는 IR 트리 안에 직접 SuspendState 노드를 생성하며, 각 상태는 두 일시 중단 지점 사이의 원자적 코드 블록을 나타냅니다.

class SuspendState(type: IrType) {
    val entryBlock: IrContainerExpression = JsIrBuilder.buildComposite(type)
    val successors = mutableSetOf<SuspendState>()
    var id = -1
}

JS 백엔드는 또한 무엇을 생성할지 결정하기 전에 suspend 함수를 세 가지 카테고리로 분류합니다.

sealed class SuspendFunctionKind {
    object NO_SUSPEND_CALLS : SuspendFunctionKind()
    class DELEGATING(val delegatingCall: IrCall) : SuspendFunctionKind()
    object NEEDS_STATE_MACHINE : SuspendFunctionKind()
}

suspend 호출이 없는 함수는 일반 함수로 처리됩니다. 단일 꼬리 위치 suspend 호출만 있는 함수는 단순 위임으로 처리됩니다. 진정으로 상태 머신이 필요한 함수만 상태 머신을 갖게 됩니다. 이러한 분류 덕분에 생성되는 JavaScript에서 불필요한 오버헤드를 피할 수 있습니다.

인라인 suspend 함수: 상태 머신의 이식

인라인 suspend 함수는 또 다른 경로를 따릅니다. suspend 함수에 inline이 표시되면, 컴파일러는 해당 함수에 대한 상태 머신을 생성하지 않습니다. 대신, 함수의 본문이 인라이너(inliner)에 의해 호출자의 바이트코드에 직접 복사되며, 호출자의 상태 머신이 인라인된 일시 중단 지점을 흡수합니다.

즉, withContextcoroutineScope 같은 인라인 suspend 함수는 자체적인 Continuation 클래스나 TABLESWITCH를 생성하지 않습니다. 해당 함수의 일시 중단 지점은 호출 함수의 상태 머신의 일부가 되며, 호출자의 Continuation이 스필링과 디스패칭을 처리합니다.

이를 지원하기 위해, 컴파일러는 코드 생성 시 모든 인라인 suspend 함수에 대해 두 개의 사본을 생성합니다.

  1. 상태 머신이 있는 일반 버전: 인라인되지 않은 컨텍스트(예를 들어, 함수 참조를 통한 호출)에서 사용
  2. 상태 머신이 없는 foo$$forInline 버전: suspend 마커를 유지하여 인라이너가 소비

SuspendFunctionGenerationStrategy.ktSuspendForInlineCopyingMethodVisitor가 이 복제를 처리합니다.

class SuspendForInlineCopyingMethodVisitor(...) : TransformationMethodVisitor(...) {
    override fun performTransformations(methodNode: MethodNode) {
        methodNode.preprocessSuspendMarkers(forInline = false, keepFakeContinuation = false)
        newMethodNode.preprocessSuspendMarkers(forInline = true, keepFakeContinuation = true)
        newMethodNode.accept(newMethodVisitor)
    }
}

forInline = true 사본은 가짜 Continuation 마커를 그대로 유지하여, 인라이너가 나중에 실제 호출자의 Continuation으로 대체할 수 있게 합니다. forInline = false 사본은 마커를 제거하고 일반적인 상태 머신 변환을 거칩니다.

이러한 이중 사본 접근 방식 덕분에, 인라인 suspend 함수는 인라인될 때 사실상 제로 오버헤드를 가집니다. 해당 함수의 일시 중단 지점이 호출자의 상태 머신에 직접 병합되어, 동일한 Continuation 객체와 스필 필드를 공유하기 때문입니다.

실전 활용: 코드에 미치는 영향

컴파일러의 내부 기계 장치는 코틀린 코드를 작성하고 디버깅하는 방식에 직접적인 영향을 미칩니다.

스택 트레이스와 디버깅

코루틴 스택 트레이스는 원래의 코드 흐름이 아니라 상태 머신 내부 구조를 보여줍니다. MyClass$fetchData$1.invokeSuspend(MyClass.kt:42)와 같은 스택 트레이스가 보일 때, $fetchData$1은 생성된 Continuation 클래스이며, invokeSuspend는 상태 머신의 재진입 지점입니다. 라인 번호는 원래 소스의 일시 중단 지점에 해당합니다. 코루틴이 멈춘 것처럼 보인다면, kotlinx-coroutines-debug나 디버거를 통해 Continuationlabel 필드를 검사하면 정확히 어떤 일시 중단 지점에서 대기하고 있는지 파악할 수 있습니다.

일시 중단을 넘나드는 메모리 보유

Continuation 필드로 승격된 지역 변수는 코루틴의 수명 동안 메모리에 남아 있습니다. 한 상태에서 큰 비트맵을 할당했는데, 이후 상태에서 코루틴이 오래 일시 중단된다면, 해당 비트맵은 코루틴이 완료되거나 변수가 덮어쓰일 때까지 ContinuationL$0 필드에 살아 있게 됩니다. 오래 실행되는 코루틴에서 예상치 못한 메모리 압박의 흔한 원인이 되기도 합니다. 대처 방법은 간단합니다. 더 이상 필요하지 않은 큰 참조를 null로 설정하거나, 큰 객체 할당과 긴 일시 중단이 서로 다른 함수에 위치하도록 코드를 구조화하시면 됩니다.

빠른 경로의 중요성

실제 운영 시스템에서 많은 suspend 함수 호출은 동기적으로 완료됩니다. 버퍼에 공간이 있는 채널의 send(), 경합이 없는 Mutex.lock(), 이미 완료된 계산에 대한 Deferred.await() 등은 모두 일시 중단 없이 직접 결과를 반환합니다. 빠른 경로(result != COROUTINE_SUSPENDED를 검사하고 계속 진행)가 존재하기 때문에, 이러한 호출은 suspend가 아닌 함수 호출에 비해 무시할 수 있을 정도의 오버헤드만 가집니다. 대부분의 경우 API 설계에서 suspend 함수를 적극적으로 사용해도 성능 측면에서 걱정할 필요가 없는 이유가 바로 이 때문입니다.

실전에서의 꼬리 호출

컴파일러가 꼬리 호출 suspend 함수를 최적화할 수 있다는 점을 알면, 위임 패턴을 효율적으로 작성하실 수 있습니다.

suspend fun fetchConditionally(id: Int): Data {
    return if (id > 0) fetchFromNetwork(id) else fetchFromCache(id)
    // 두 분기 모두 꼬리 호출입니다
}

두 suspend 호출 모두 꼬리 위치에 있고 try-catch 블록 내부가 아니므로, 이 함수는 상태 머신이 필요하지 않습니다. 컴파일러는 전체 상태 머신 대신 최소한의 COROUTINE_SUSPENDED 검사만 생성합니다. 다만 꼬리 호출을 try-catch로 감싸면 일시 중단 지점이 예외 핸들러 범위 안에 들어가므로 이 최적화 대상에서 제외됩니다. suspend 호출 뒤에 로깅을 추가하거나 try-catch로 감싸면, 최적화가 사라지고 전체 상태 머신이 생성된다는 점에 유의하셔야 합니다.

결론

이 글에서는 코틀린의 suspend 키워드를 JVM 상태 머신으로 변환하는 전체 컴파일러 파이프라인을 살펴보았습니다. CPS 변환(숨겨진 $completion 매개변수 추가), Continuation 클래스 생성(새로운 호출과 재개를 구분하기 위한 부호 비트 트릭), 일시 중단 지점 수집(마커 명령어를 통한), 변수 스필링(살아 있는 지역 변수를 Continuation 필드에 저장), TABLESWITCH 생성(올바른 재개 지점으로 디스패치), 그리고 꼬리 호출 최적화(가능한 경우 상태 머신 생략)까지 추적해 보았습니다.

이러한 내부 동작을 이해하면 코루틴의 동작 방식을 더 정확하게 추론할 수 있습니다. 부호 비트 트릭은 재귀 suspend 호출이 올바르게 동작하는 이유를 설명합니다. 변수 스필링은 일시 중단 지점을 넘어 참조되는 큰 객체가 메모리 압박을 유발할 수 있는 이유를 설명합니다. 빠른 경로 최적화는 많은 suspend 호출이 무시할 수 있을 정도의 오버헤드만 가지는 이유를 설명합니다. 꼬리 호출 최적화는 단순한 위임 함수가 거의 비용이 들지 않는 이유를 설명합니다. 이것이 바로 운영 시스템에서 코루틴의 성능 특성을 결정하는 핵심 메커니즘입니다.

멈춘 것처럼 보이는 코루틴을 디버깅하거나(label 필드를 확인하여 어떤 일시 중단 지점에서 대기 중인지 파악), 타이트한 루프에서 suspend 함수를 호출하는 핫 경로를 최적화하거나(동기적 완료가 빠른 경로를 타는지 확인), 코루틴 기반 아키텍처를 설계할 때(호출당 할당 비용과 스필 오버헤드 이해), 컴파일러 내부 기계 장치에 대한 이 지식은 올바르고 성능 좋은 코틀린 코드를 작성하기 위한 기반이 될 것입니다. 코루틴은 라이브러리 추상화가 아닙니다. 컴파일러 수준의 솔루션이며, 이 솔루션의 깊이야말로 suspend 키워드가 뛰어나게 동작하는 원동력입니다.

아티클 목록으로 가기