이번 포스트에서는 코루틴의 suspend 함수 원리와 CPS(Continuation Passing Style)에 대해 정리하겠습니다.
코루틴의 suspend는 어떻게 동작할까?
코루틴을 이용하면 비동기 작업을 순차적으로 처리하여 동시성 문제를 해결할 수 있습니다. 그러므로, 코루틴을 사용하면 콜백 지옥에서 벗어날 수 있고, 혹은 Reactive streams를 사용하기 위해서 복잡한 함수형 프로그래밍의 체이닝 방식을 사용하지 않을 수 있습니다. 그런데 코루틴이 네트워크 요청과 같은 비동기 작업을 순차적으로 처리할 수 있는 이유는 무엇일까요?
결론부터 말하면, 코루틴의 일시 중지하고 작업을 처리한 후 나중에 해당 지점에서 다시 재개할 수 있는 기능 덕분입니다. 이러한 기능 덕분에 비동기 작업을 처리할 때 코루틴이 일시 중지되어도 스레드를 block 하지 않고 자유롭게 사용할 수 있으므로, 안드로이드 프레임워크에서 사용할 경우 메인 스레드를 차단하지 않고 Main-Safe하게 View를 수정하거나 다른 코루틴을 처리할 수 있습니다.
이에 대해 이러한 궁금증이 떠오를 것 입니다. 코루틴은 어떻게 일시 중지하고 다시 시작할 수 있을까요?
궁금증을 해결하기 위해 예제를 통해 코루틴의 suspend 함수가 내부적으로 어떻게 동작하는지에 대해 알아보겠습니다. 또한, suspend 함수의 동작 원리를 알아보기 위해서 Continuation Passing Style(CPS)
에 대해 알아보겠습니다.
Continuation Passing Style(CPS)
suspend 함수는 Continuation Passing Style(CPS)
라는 개념의 Continuation을 전달하는 프로그래밍 스타일로 변환되어 일시 중지 및 재개합니다. 즉, 간단히 말하면 CPS는 함수 호출이 완료된 후에 다음에 수행할 작업인 콜백 함수를 전달하는 방식이며, 콜백 함수는 Continuation이라는 개념을 사용하는 것입니다.
실제 예제를 통해 알아보겠습니다.
예를 들면, 로그인을 구현한다고 가정해 보겠습니다. 로그인의 로직은 아래의 순서로 로직을 구성할 수 있습니다.
- login() 함수를 호출한다.
- 정상적으로 로그인이 성공했다면 Token을 가져온다.
- 가져온 Token을 데이터 베이스에 저장한다.
- 로그인 성공 여부를 반환한다.
위의 로직을 코틀린 코루틴으로 작성하면 아래 코드와 같이 작성할 수 있습니다.
suspend fun login(userId: String, password: String): Result<Unit> = runCatching {
val loginResult = authRemoteDataSource.login(userId, password)
val token = authRemoteDataSource.fetchToken().getOrThrow()
authLocalDataSource.saveToken(token)
return loginResult
}
분명히 로그인, 토큰 가져오기, 토큰 저장하기 코드는 비동기 코드일 텐데 마치 순차적인 코드인 것처럼 작성되어 있습니다. 그 이유는 코루틴을 일시 중지하고 해당 지점에서 작업을 끝낼 때까지 결과를 return하지 않을 수 있기 때문입니다. 이렇게 동작할 수 있는 이유를 알고 싶다면 suspend modifier가 코틀린 컴파일러에 의해 어떻게 변환되는지를 알아보면 됩니다.
코틀린 컴파일러는 이 suspend 함수를 변환시킬 때 내부적으로 Continuation을 만들어 유한 상태 머신(Finite State Machine)을 이용한 최적화된 콜백 형태로 변환합니다. 즉, 코틀린 컴파일러가 내부적으로 콜백을 작성해 주는 것입니다.
Continuation
suspend 함수는 Continuation
객체를 통해 다른 suspend 함수와 소통합니다. Continuation은 몇 가지의 추가 정보를 가지고 있는 Generic Callback 인터페이스입니다. 즉, Continuation은 상태 머신(State Machine)을 사용하여 일시 중단한 지점의 현재 상태를 유지하며, 다시 시작되었을 때 어떠한 동작을 할 지에 대한 정보를 상태 머신을 통해 전달합니다.
Continuation은 아래와 같이 정의되어 있습니다.
/**
* Interface representing a continuation after a suspension point that returns a value of type `T`.
*/
@SinceKotlin("1.3")
public interface Continuation<in T> {
/**
* The context of the coroutine that corresponds to this continuation.
*/
public val context: CoroutineContext
/**
* Resumes the execution of the corresponding coroutine passing a successful or failed [result] as the
* return value of the last suspension point.
*/
public fun resumeWith(result: Result<T>)
}
context
-> Continuation에서 사용할 CoroutineContext입니다.resumeWith
-> 이 함수를 통해 중단점의 결과 값 혹은 예외를 반환하는 Result 값을 반환하면서 코루틴을 재개합니다.
이제 다시 돌아와서 suspend 함수가 어떻게 변환되는지 보겠습니다.
login() 함수를 Decompile 해보면 아래와 같은 코드로 변경됩니다.
fun login(userId: String, password: String, completion: Continuation<Any?>): Any {
// ...
completion.resume(loginResult)
}
코틀린의 suspend 함수는 컴파일러에 의해 suspend modifier가 사라지고 CPS의 구현을 위해서 마지막 매개변수에 completion이라는 이름의 Continuation을 가지도록 변환됩니다. 또한, 이 Continuation 객체는 resumeWith 메소드를 통해서 자신을 호출한 suspend 함수와 소통할 수 있습니다.
디컴파일 된 함수의 반환 타입을 보면 Any 혹은 Any?를 반환하는데, 그 이유는 suspend 함수가 일시 중지 상태일 수 있으므로 선언된 형식을 반환하지 않을 수 있기 때문입니다. 만약, 이 susepnd 함수가 일시 중지 상태일 경우 COROUTINE_SUSPENDED
를 반환합니다. 그러므로 위 코드에서 login 함수는 Result 혹은 COROUTINE_SUSPENDED 중 하나를 반환할 것이므로 반환 값은 합집합 유형인 Any를 반환하는 것입니다.
State Machine - Suspension Point
fun login(userId: String, password: String, completion: Continuation<Any?>): Any {
// Label 0 -> 첫 번째 실행
val loginResult = authRemoteDataSource.login(userId, password)
if (loginResult.isSuccess) {
// Label 1 -> login() 함수로부터 재개
val token = authRemoteDataSource.fetchToken().getOrThrow()
// Label 2 -> fetchToken() 함수로부터 재개
authLocalDataSource.saveToken(token)
}
completion.resume(loginResult)
}
컴파일러는 함수가 내부적으로 일시 중지 될 수 있는 지점을 식별할 수 있으며, 이 지점을 중단점(Suspension Point)
이라고 부릅니다.
중단점은 IDE에서 코드 부분 옆의 화살표로 확인할 수 있습니다.
fun login(userId: String, password: String, completion: Continuation<Any?>): Any = runCatching {
when (label) {
0 -> { // Label 0 -> 첫 실행
authRemoteDataSource.login(userId, password)
}
1 -> { // Label 1 -> login() 함수로부터 재개
authRemoteDataSource.fetchToken().getOrThrow()
}
2 -> { // Label 2 -> fetchToken() 함수로부터 재개
authLocalDataSource.saveToken(token)
}
else -> throw IllegalStateException(...)
}
}
이 중단점은 유한 상태 기계의 상태로 표현되고 컴파일러에 의해 labeling 됩니다. 또한, 컴파일러는 상태 머신을 더 잘 표현하기 위해 중단점을 when 문을 사용하여 분리합니다. 물론, labeling으로 코드의 순서는 정해졌지만, 아직은 상태들이 정보를 공유할 방법이 보이지 않습니다. 이러한 문제를 해결하기 위해 Kotlin 컴파일러는 Continuation 파라미터를 이용합니다. Continuation의 제네릭 타입이 원래 함수의 반환 타입인 Result<Unit>가 아닌 Any?를 반환하는 이유가 그 때문입니다.
일반적으로는 첫 번째로, 컴파일러는 필요한 데이터를 가지는 익명 클래스를 생성하고, 두 번째로는 login() 함수를 재귀적으로 호출하여 suspend 함수의 실행을 재개합니다. 하지만, 예시를 위해 MyContinuation이라는 객체를 만들겠습니다. (실제로는 디컴파일 될 때 Continuation은 익명 클래스로 생성됩니다.)
fun login(userId: String, password: String, completion: Continuation<Any?>): Any = runCatching {
class LoginResultContinuation(
completion: Continuation<Any?>
): CoroutineImpl(completion) {
var loginResult: Result? = null
var token: Token? = null
var result: Any? = null
var label: Int = 0
override fun invokeSuspend(result: Any?) {
this.result = result
login(userId, password, this)
}
}
}
먼저, LoginResultContinuation 클래스를 생성하고 Continuation을 구현하고 있는 CoroutineImpl을 구현해 줍니다. 그리고, 매개 변수로 함수의 결과를 호출한 함수에 다시 전달하는 데 사용하는 Continuation 타입의 completion 객체를 추가해 줍니다. 이 completion은 상태 머신의 마지막 상태 이전에 호출한 상태와 같은 Continuation 객체입니다.
클래스 내부에서는 상태를 저장하기 위해 LoginResultContinuation 내부에는 suspend 함수의 내부에 선언된 변수인 loginResult, token을 변수로 추가해줍니다. 그리고, result, label과 같은 공통적으로 사용하는 변수도 추가해줍니다. 여기서 result은 이전 상태의 결과이고, label은 상태 머신의 진행 상황을 나타냅니다.
또한, invokeSuspend 함수를 오버라이딩하고 재귀적으로 실행될 수 있도록 login 함수를 호출하여 상태 머신을 재개할 수 있도록 해줍니다.
fun login(userId: String, password: String, completion: Continuation<Any?>): Any = runCatching {
class LoginResultContinuation(
completion: Continuation<Any?>
): CoroutineImpl(completion) {
var loginResult: Result? = null
var token: Token? = null
var result: Any? = null
var label: Int = 0
override fun invokeSuspend(result: Any?) {
this.result = result
login(userId, password, this)
}
}
val continuation = completion as? LoginResultStateMachine ?: LoginResultStateMachine(completion)
when (continuation.label) {
0 -> { // Label 0 -> 첫 실행
throwOnFailure(continuation.result)
continuation.label = 1
authRemoteDataSource.login(userId, password, continuation)
}
1 -> { // Label 1 -> login() 함수로부터 재개
throwOnFailure(continuation.result)
continuation.loginResult = continuation.result as Result<Unit>
continuation.label = 2
authRemoteDataSource.fetchToken(continuation.loginResult).getOrThrow()
}
2 -> { // Label 2 -> fetchToken() 함수로부터 재개
throwOnFailure(continuation.result)
continuation.token = continuation.result as Token
continuation.label = 3
authLocalDataSource.saveToken(continuation.token)
}
else -> throw IllegalStateException(...)
}
}
최종적으로, 컴파일러는 LoginResultStateMachine 클래스 타입의 continuation 객체를 생성하고 when 절에서 continuation의 label 값에 따라 분기해 줍니다. 각각의 분기에서는 해당 label 위치에서 resume 될 함수를 실행하기 전에, 다음에 실행될 함수의 위치를 저장하기 위해 상태 머신의 label 값을 증가시켜서 다음 재개될 함수를 실행되도록 설계된 것을 확인할 수 있습니다. 또한, label 1, 2의 continuation.loginResult, continuation.token 코드에서 Continuation 변수를 사용하여 상태 머신의 이전 상태 값을 읽어 오는 것을 확인할 수 있습니다.
이번 포스트의 내용을 정리하면 아래와 같이 정리할 수 있습니다.
- 코루틴을 사용하면 콜백이나 함수형 프로그래밍의 복잡한 체이닝 방식을 사용하지 않고도 순차적으로 처리할 수 있으며, 이유는 코루틴의 일시 중지 기능 덕분입니다.
- suspend 함수가 코루틴을 일시 중지할 수 있는 이유는 Kotlin 컴파일러에 의해 내부적으로 유한 상태 머신을 구현하기 위해 Continuation을 전달하는 프로그래밍 방식인 CPS로 변환되기 때문입니다.
- 이러한 방식은 스레드를 차단시키는 것이 아니라 코루틴을 일시 중지시키는 것이므로 훨씬 효율적이며 안드로이드 프레임워크에서 사용할 경우 Main-Safe하게 프로그래밍할 수 있습니다.
References
Marcin Moskala, "Kotlin Coroutines: Deep Dive" (2022)
https://kotlinlang.org/docs/coroutines-overview.html