코루틴 내부 동작 — CPS 변환과 상태 머신이 suspend를 가능하게 하는 원리
"suspend 함수를 호출하면 스레드가 블로킹되지 않는다고 하는데, 도대체 어떻게 멈췄다가 다시 이어서 실행할 수 있는 걸까요?"
코루틴의 suspend는 마법이 아닙니다. 컴파일러가 CPS(Continuation Passing Style) 변환과 상태 머신(State Machine)을 통해 일반적인 콜백 코드로 바꿔주는 것입니다. 이 글에서는 그 변환 과정을 디컴파일 코드와 함께 살펴보겠습니다.
Continuation — "다음에 할 일"을 담는 객체
코루틴의 핵심 인터페이스는 Continuation입니다.
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
context: 코루틴이 실행될 컨텍스트(디스패처 등)resumeWith: 중단된 코루틴을 재개하는 메서드
Continuation은 "이 값을 받으면 다음에 이 코드를 실행해"라는 콜백이라고 생각하면 됩니다.
CPS 변환 — suspend 함수의 시그니처가 바뀌는 과정
코틀린에서 작성한 suspend 함수는 컴파일 시 시그니처가 변합니다.
// 우리가 작성한 코드
suspend fun fetchUser(id: Long): User { /* ... */ }
// 컴파일 후 (개념적)
fun fetchUser(id: Long, continuation: Continuation<User>): Any? { /* ... */ }
변화 포인트를 정리하면 다음과 같습니다.
- 파라미터 추가:
Continuation이 마지막 파라미터로 추가됩니다 - 반환 타입 변경:
User→Any?로 바뀝니다 - 반환값의 의미: 실제 결과(
User)를 바로 반환하거나,COROUTINE_SUSPENDED를 반환합니다
COROUTINE_SUSPENDED가 반환되면 "지금은 결과가 없으니, 나중에 Continuation.resumeWith()로 알려줄게"라는 뜻입니다.
상태 머신 — 하나의 함수를 여러 단계로 분리
suspend 함수 안에 여러 중단점이 있으면, 컴파일러가 상태 머신으로 변환합니다.
// 원본 코드
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)
}
이 코드를 컴파일러가 변환하면 개념적으로 다음과 같습니다.
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에서 직접 확인할 수 있습니다.
- suspend 함수가 있는
.kt파일을 엽니다 Tools → Kotlin → Show Kotlin Bytecode를 선택합니다Decompile버튼을 클릭합니다
간단한 예제로 확인해보겠습니다.
suspend fun simpleSuspend(): String {
val a = suspendFunction1()
val b = suspendFunction2(a)
return b
}
디컴파일하면 대략 이런 코드가 나옵니다.
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을 사용합니다.
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의 동작 순서는 다음과 같습니다.
- 현재 코루틴의
Continuation을 콜백 블록에 전달합니다 - 함수는
COROUTINE_SUSPENDED를 반환하여 코루틴을 중단합니다 - 비동기 작업이 완료되면
continuation.resume()또는resumeWithException()을 호출합니다 - 코루틴이 재개되어 다음 상태로 진행합니다
중단이 일어나지 않는 경우
모든 suspend 함수가 실제로 중단되는 것은 아닙니다.
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가 결정합니다.
// Dispatchers.IO의 개념적 동작
class IoDispatcher : ContinuationInterceptor {
override fun <T> interceptContinuation(
continuation: Continuation<T>
): Continuation<T> {
return DispatchedContinuation(this, continuation)
}
}
withContext(Dispatchers.IO)를 호출하면 다음이 일어납니다.
- 현재 코루틴을 중단합니다
- Continuation을 IO 디스패처의 스레드 풀에 제출합니다
- IO 스레드에서 블록 실행 후, 원래 디스패처로 돌아옵니다
상태 머신이 가지는 이점
콜백 방식과 비교했을 때 상태 머신의 장점은 다음과 같습니다.
- 하나의 객체: 각 중단점마다 새로운 콜백을 생성하지 않고, 하나의 상태 머신 객체를 재사용합니다
- 스택 프레임 절약: 실제 콜 스택을 소비하지 않고 힙에 상태를 저장합니다
- 디버깅 지원: 상태 머신의
label과 저장된 필드로 코루틴의 현재 상태를 추적할 수 있습니다
// 콜백 방식 — 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 기능으로 직접 확인해보면, 코루틴이 "마법"이 아니라 잘 설계된 컴파일러 변환이라는 것을 실감할 수 있습니다.