Theme:

"suspend 함수를 호출하면 스레드가 블로킹되지 않는다고 하는데, 도대체 어떻게 멈췄다가 다시 이어서 실행할 수 있는 걸까요?"

코루틴의 suspend는 마법이 아닙니다. 컴파일러가 CPS(Continuation Passing Style) 변환과 상태 머신(State Machine)을 통해 일반적인 콜백 코드로 바꿔주는 것입니다. 이 글에서는 그 변환 과정을 디컴파일 코드와 함께 살펴보겠습니다.

Continuation — "다음에 할 일"을 담는 객체

코루틴의 핵심 인터페이스는 Continuation입니다.

KOTLIN
public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}
  • context: 코루틴이 실행될 컨텍스트(디스패처 등)
  • resumeWith: 중단된 코루틴을 재개하는 메서드

Continuation은 "이 값을 받으면 다음에 이 코드를 실행해"라는 콜백이라고 생각하면 됩니다.

CPS 변환 — suspend 함수의 시그니처가 바뀌는 과정

코틀린에서 작성한 suspend 함수는 컴파일 시 시그니처가 변합니다.

KOTLIN
// 우리가 작성한 코드
suspend fun fetchUser(id: Long): User { /* ... */ }

// 컴파일 후 (개념적)
fun fetchUser(id: Long, continuation: Continuation<User>): Any? { /* ... */ }

변화 포인트를 정리하면 다음과 같습니다.

  1. 파라미터 추가: Continuation이 마지막 파라미터로 추가됩니다
  2. 반환 타입 변경: UserAny?로 바뀝니다
  3. 반환값의 의미: 실제 결과(User)를 바로 반환하거나, COROUTINE_SUSPENDED를 반환합니다

COROUTINE_SUSPENDED가 반환되면 "지금은 결과가 없으니, 나중에 Continuation.resumeWith()로 알려줄게"라는 뜻입니다.

상태 머신 — 하나의 함수를 여러 단계로 분리

suspend 함수 안에 여러 중단점이 있으면, 컴파일러가 상태 머신으로 변환합니다.

KOTLIN
// 원본 코드
suspend fun loadUserData(userId: Long): UserData {
    val user = fetchUser(userId)       // 중단점 1
    val posts = fetchPosts(user.id)     // 중단점 2
    val friends = fetchFriends(user.id) // 중단점 3
    return UserData(user, posts, friends)
}

이 코드를 컴파일러가 변환하면 개념적으로 다음과 같습니다.

KOTLIN
fun loadUserData(userId: Long, cont: Continuation<UserData>): Any? {
    // 상태 머신을 담는 Continuation 구현체
    val sm = cont as? LoadUserDataContinuation
        ?: LoadUserDataContinuation(cont)

    when (sm.label) {
        0 -> {
            sm.label = 1
            val result = fetchUser(userId, sm)
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            sm.user = result as User
        }
        1 -> {
            sm.user = sm.result as User
            sm.label = 2
            val result = fetchPosts(sm.user.id, sm)
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            sm.posts = result as List<Post>
        }
        2 -> {
            sm.posts = sm.result as List<Post>
            sm.label = 3
            val result = fetchFriends(sm.user.id, sm)
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            sm.friends = result as List<User>
        }
        3 -> {
            sm.friends = sm.result as List<User>
            return UserData(sm.user, sm.posts, sm.friends)
        }
    }
    // 다음 상태로 진행
    return loadUserData(userId, sm) // 재귀적으로 다음 상태 실행
}

핵심 포인트를 정리합니다.

  • label: 현재 상태(몇 번째 중단점까지 실행했는지)
  • 상태 머신 객체가 중간 결과(user, posts, friends)를 필드로 저장
  • 각 중단점에서 COROUTINE_SUSPENDED가 반환되면 함수를 빠져나옴
  • 나중에 resumeWith()가 호출되면 저장된 label에 따라 이어서 실행

직접 디컴파일해서 확인하기

IntelliJ에서 직접 확인할 수 있습니다.

  1. suspend 함수가 있는 .kt 파일을 엽니다
  2. Tools → Kotlin → Show Kotlin Bytecode를 선택합니다
  3. Decompile 버튼을 클릭합니다

간단한 예제로 확인해보겠습니다.

KOTLIN
suspend fun simpleSuspend(): String {
    val a = suspendFunction1()
    val b = suspendFunction2(a)
    return b
}

디컴파일하면 대략 이런 코드가 나옵니다.

JAVA
public static final Object simpleSuspend(Continuation<? super String> $completion) {
    // Continuation 구현체 생성 또는 재사용
    SimpleSuspendContinuation cont;
    if ($completion instanceof SimpleSuspendContinuation) {
        cont = (SimpleSuspendContinuation) $completion;
        // 재진입 확인
    } else {
        cont = new SimpleSuspendContinuation($completion);
    }

    Object result = cont.result;
    Object SUSPENDED = IntrinsicsKt.getCOROUTINE_SUSPENDED();

    switch (cont.label) {
        case 0:
            cont.label = 1;
            Object a = suspendFunction1(cont);
            if (a == SUSPENDED) return SUSPENDED;
            // fall-through
        case 1:
            String a = (String) result;
            cont.label = 2;
            Object b = suspendFunction2(a, cont);
            if (b == SUSPENDED) return SUSPENDED;
            // fall-through
        case 2:
            return (String) result;
        default:
            throw new IllegalStateException("call to 'resume' before 'invoke'");
    }
}

suspendCoroutine — 코루틴과 콜백의 다리

기존 콜백 기반 API를 코루틴으로 감싸려면 suspendCoroutine이나 suspendCancellableCoroutine을 사용합니다.

KOTLIN
suspend fun fetchFromNetwork(url: String): Response =
    suspendCancellableCoroutine { continuation ->
        val call = httpClient.newCall(Request.Builder().url(url).build())

        call.enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) {
                continuation.resume(response) // 성공 시 재개
            }

            override fun onFailure(call: Call, e: IOException) {
                continuation.resumeWithException(e) // 실패 시 예외와 함께 재개
            }
        })

        // 코루틴 취소 시 네트워크 요청도 취소
        continuation.invokeOnCancellation {
            call.cancel()
        }
    }

suspendCancellableCoroutine의 동작 순서는 다음과 같습니다.

  1. 현재 코루틴의 Continuation을 콜백 블록에 전달합니다
  2. 함수는 COROUTINE_SUSPENDED를 반환하여 코루틴을 중단합니다
  3. 비동기 작업이 완료되면 continuation.resume() 또는 resumeWithException()을 호출합니다
  4. 코루틴이 재개되어 다음 상태로 진행합니다

중단이 일어나지 않는 경우

모든 suspend 함수가 실제로 중단되는 것은 아닙니다.

KOTLIN
suspend fun cachedFetch(id: Long): User {
    val cached = cache[id]
    if (cached != null) return cached // 중단 없이 바로 반환

    val user = fetchFromDb(id) // 여기서만 중단 가능
    cache[id] = user
    return user
}

캐시에 값이 있으면 COROUTINE_SUSPENDED가 반환되지 않고 바로 결과가 반환됩니다. 이 경우 상태 머신 전환 없이 일반 함수처럼 동작하므로 오버헤드가 거의 없습니다.

Continuation Interceptor — 스레드 전환의 원리

코루틴이 재개될 때 어떤 스레드에서 실행될지는 ContinuationInterceptor가 결정합니다.

KOTLIN
// Dispatchers.IO의 개념적 동작
class IoDispatcher : ContinuationInterceptor {
    override fun <T> interceptContinuation(
        continuation: Continuation<T>
    ): Continuation<T> {
        return DispatchedContinuation(this, continuation)
    }
}

withContext(Dispatchers.IO)를 호출하면 다음이 일어납니다.

  1. 현재 코루틴을 중단합니다
  2. Continuation을 IO 디스패처의 스레드 풀에 제출합니다
  3. IO 스레드에서 블록 실행 후, 원래 디스패처로 돌아옵니다

상태 머신이 가지는 이점

콜백 방식과 비교했을 때 상태 머신의 장점은 다음과 같습니다.

  • 하나의 객체: 각 중단점마다 새로운 콜백을 생성하지 않고, 하나의 상태 머신 객체를 재사용합니다
  • 스택 프레임 절약: 실제 콜 스택을 소비하지 않고 힙에 상태를 저장합니다
  • 디버깅 지원: 상태 머신의 label과 저장된 필드로 코루틴의 현재 상태를 추적할 수 있습니다
KOTLIN
// 콜백 방식 — 3개의 콜백 객체 생성
fetchUser(id) { user ->
    fetchPosts(user.id) { posts ->
        fetchFriends(user.id) { friends ->
            callback(UserData(user, posts, friends))
        }
    }
}

// 코루틴 — 1개의 상태 머신 객체
suspend fun loadUserData(id: Long): UserData {
    val user = fetchUser(id)
    val posts = fetchPosts(user.id)
    val friends = fetchFriends(user.id)
    return UserData(user, posts, friends)
}

정리

  • CPS 변환: suspend 함수는 컴파일 시 Continuation 파라미터가 추가되고, 반환 타입이 Any?로 바뀝니다
  • 상태 머신: 여러 중단점이 있는 함수는 label 기반의 when/switch 문으로 변환됩니다
  • COROUTINE_SUSPENDED: 실제 중단이 필요할 때만 반환되며, 캐시 히트 같은 경우에는 바로 결과를 반환합니다
  • Continuation: "다음에 실행할 코드"를 담는 콜백 객체로, resumeWith()로 코루틴을 재개합니다
  • 하나의 상태 머신 객체가 여러 콜백을 대체하므로 메모리 효율이 좋습니다

IntelliJ의 Show Kotlin Bytecode → Decompile 기능으로 직접 확인해보면, 코루틴이 "마법"이 아니라 잘 설계된 컴파일러 변환이라는 것을 실감할 수 있습니다.

댓글 로딩 중...