[Coroutine] 코루틴 내부동작 분석해보기!
최근 코루틴의 내부동작에 대해 질문을 받았을 때 하나도 대답하지 못한 경험이 있어...
관련 부분에 대해 좀 더 공부해보고 해당 내용을 정리해보고자 합니다.
참고한 자료는 KotlinConf2017의 Deep Dive into Coroutines on JVM이라는 영상인데
ㅎㅎ 영어자막도 제공해주지 않아서 열심히 영어듣기 평가를 하며 내용을 정리해보았습니다.
독학으로 코틀린과 안드로이드를 공부해보고 있는 초보자여서,🤗
틀린 부분이 있다면 꼭 댓글로 말씀 부탁드립니다!
1. Direct Style과 Continuation Passing Style(CPS)
Direct Style은 현재 동작과 이후에 진행할 동작이 순차적으로 작성된 코드를 말합니다.
fun postItem(item: Item) {
val token = requestToken() // action
// continuation
val post = createPost(token, item)
processPost(post)
}
이와는 달리 Continuation Passing Style(CPS)는 일종의 Callback이라고 생각할 수 있습니다.
fun postItem(item: Item) {
requestToken { items ->
// continuation
val post = createPost(token, item)
processPost(post)
}
}
와 같이 parameter로 다음에 진행할 동작 코드를 전달(Passing)하는 방법이죠. 🤗
하지만 알다싶이 Callback을 이용한 구현방식은 Callback Hell에 빠지기 쉽습니다.

코루틴에서는 이러한 Callback의 문제점을 해결해주면서 비동기 작업을 수행하기 위해 suspend 키워드를 제공해주고 있습니다.
suspend fun postItem(item: Item) {
val token = requestToken()
val post = createPost(token, item)
processPost(post)
}
🌹 suspend 키워드를 통해 개발자가 비동기 작업을 위해 CPS Style 대신 Direct Style로 코드를 작성해도 내부적으로는 CPS style로 컴파일되게 되며, 코드 자체는 Direct Style로 작성한 덕분에 보다 이해하기 쉬운 코드를 구현할 수 있게 되었습니다.
그럼 다음으로는 suspend로 작성한 함수가 내부적으로 어떻게 컴파일이 되는지 알아보도록 하겠습니다.🔥
2. suspend 함수의 내부
이전에 내부적으로 CPS style로 컴파일된다고 잠깐 언급하였었는데,
fun postItem(item: Item) {
requestToken { token ->
createPost(token, item) { post ->
processPost(post)
}
}
}
아쉽게도 이런식으로 컴파일링되지는 않습니다. 😭
개발자가 작성한 코드가 아래👇🏻와 같을 때,
suspend fun postItem(item: Item) {
val token = requestToken()
val post = createPost(token, item)
processPost(post)
}
🌹 여기서 requestToken과 createPost는 suspend function이며, processPost는 일반 메서드입니다.
처음으로는 Suspend function들이 호출되는 위치를 기준으로 Labeling 해줍니다. 😌
suspend fun postItem(item: Item) {
// LABEL 0
val token = requestToken()
// LABEL 1
val post = createPost(token, item)
// LABEL 2
processPost(post)
}
이후 각 Label들을 조건으로 하는 switch 문을 만들어줍니다.
suspend fun postItem(item: Item) {
switch(label) {
case 0: val token = requestToken()
case 1: val post = createPost(token, item)
case 2: processPost(post)
}
}
위 코드를 보면 label에 따라 다른 suspend function을 호출해주는 것을 알 수 있습니다.
그 다음으로는 suspend function으로 전달되는 parameter에 Continuation 타입의 변수를 추가해줍니다. 👍🏻
fun postItem(item: Item, cont: Continuation) {
// ...
}
여기서 Continuation이란 이전에 봤던 CPS(Continuation Passing Style)의 Continuation과 동일하며,
Callback 역할을 담당하는 인터페이스입니다. 🪴
interface Continuation<in T> {
val context: CoroutineContext // 현재 코루틴 환경 정보
fun resume(value: T) // 성공했을 때 호출할 callback method
fun resumeWithException(exception: Throwable) // 실패했을 때 호출할 callback method
}
즉, 작업이 성공적으로 완료되었다면 resume 메서드를, 실패하였다면 resumeWithException 메서드를 callback 함수로서 호출하게 된다는 의미입니다. 🤗
그렇다면 postItem 내부에서 호출되는 suspend function(requestToken과 createPost)에도 역시
Continuation를 전달해줄 필요가 있습니다.
fun postItem(item: Item, cont: Continuation) {
// label 정보를 저장할 state machine
val sm = object : Continuation { ... }
switch(label) {
case 0:
requestToken(sm)
case 1: createPost(token, item, sm)
case 2: processPost(post)
}
}
위는 새로운 Continuation을 생성하고 이를 requestToken과 createPost에 전달하는 코드입니다.
이제 Continuation이 Callback의 역할을 담당하는 것도 알게 되었고 작업이 완료되었을 때 호출할 수 있는 함수도 준비되었습니다.🔥
🌹 requestToken에서 작업이 완료되었을 떄, 전달된 sm, 즉 Continuation의 resume 메서드를 호출하게 될 것입니다.
컴파일러는 과연 resume 메서드를 어떻게 구현하고 있을까요?
fun postItem(item: Item, cont: Continuation) {
// label 정보를 저장할 state machine
val sm = object : Continuation {
fun resume(...) {
postItem(null, this)
}
}
switch(label) {
case 0:
requestToken(sm)
case 1: createPost(token, item, sm)
case 2: processPost(post)
}
}
다시 postItem을 재호출하도록 resume 메서드를 구현하고 있습니다.
이 때 호출되는 postItem의 Continuation은 requestToken에 전달된 sm이죠. 🤔

그렇다면 다음 2가지 코드가 더 추가되어야 합니다.
- 전달된 cont가 있을 때는 해당 cont를 sm으로 사용하기 & 없을 때는 새로 생성하기
- 다시 postItem을 호출할 때는 다음 suspend function을 호출할 수 있도록 label의 값 변경하기
위 2가지를 코드를 추가하면 아래와 같습니다. 😆👍🏻
fun postItem(item: Item, cont: Continuation) {
// label 정보를 저장할 state machine
val sm = object : Continuation {
fun resume(...) {
postItem(null, this)
}
}
switch(label) {
case 0:
// 다음에 필요한 정보를 state machine에 저장해둔다.
sm.item = item
sm.label = 1 // 다음 동작을 수행할 label로 저장
requestToken(sm)
case 1: createPost(token, item, sm)
case 2: processPost(post)
}
}
🌹 이 때, 다음 suspend function인 createPost에 필요한 item 정보도 sm에 저장하는 것에 유의하세요.
그 결과 다음에 호출된 postItem의 Continuation은
- label은 1
- item에는 이전 postItem 호출 시 전달된 item 참조
- result에는 이전 suspend function인 requestToken의 결과
가 저정되어 있습니다.📌
위 3가지 정보는 다음 suspend function인 createPost를 호출하는데 사용되게 됩니다.
fun postItem(item: Item, cont: Continuation) {
// label 정보를 저장할 state machine
val sm = cont ?: ThisSM ?: object : Continuation {
fun resume(...) {
postItem(null, this)
}
}
switch(label) {
case 0:
// 다음에 필요한 정보를 state machine에 저장해둔다.
sm.item = item
sm.label = 1 // 다음 동작을 수행할 label로 저장
requestToken(sm)
case 1:
val item = sm.item
val token = sm.result as Token
sm.label = 2 // 다음 Continuation label을 지정
createPost(token, item, sm)
case 2: processPost(post)
}
}
3. 결과
지금까지의 과정을 통해 원래는 👇🏻이렇게 Direct Style로 작성된 postItem 메서드가
suspend fun postItem(item: Item) {
val token = requestToken()
val post = createPost(token, item)
processPost(post)
}
👇🏻 요렇게 CPS Style로 동작하도록 바뀌게 되었습니다.
fun postItem(item: Item, cont: Continuation) {
// label 정보를 저장할 state machine
val sm = cont ?: ThisSM ?: object : Continuation {
fun resume(...) {
postItem(null, this)
}
}
switch(label) {
case 0:
// 다음에 필요한 정보를 state machine에 저장해둔다.
sm.item = item
sm.label = 1 // 다음 동작을 수행할 label로 저장
requestToken(sm)
case 1:
val item = sm.item
val token = sm.result as Token
sm.label = 2 // 다음 Continuation label을 지정
createPost(token, item, sm)
case 2: processPost(post)
}
}
👏🏻👏🏻👏🏻
실질적으로 내부 동작도 새로운 개념없이 기존개념들을 개발자가 사용하기 쉽도록 제공했다는 점을 알게되었습니다. 😭
다음 Post에서는 Dispatcher를 통해 각 Thread에 코루틴이 할당되는 과정에 대해 학습하고
정리해보도록 하겠습니다.
감사합니다. 😌