[kotlin] coroutine 동작 과정 - CPS

조갱·2023년 5월 5일
1

Coroutine

목록 보기
7/9

Coroutine

일반적으로 코루틴을 사용해본 사람이라면, 보통은 Kotlin 과 함께 사용했을 것이다.
그런데, 코루틴은 Co+routine 의 조합으로 Kotlin만의 기능이 아니다.
실제로 코루틴은 아래 사진과 같이, 여러 언어에서 지원하고 있다.

보통 Kotlin에서 Coroutine을 사용할 때는 suspend 키워드를 사용하고, 람다를 통해 마법같이 뚝딱! 만들어내지만, 99.9% 호환되는 Java에서도 suspend와 같은 키워드는 지원되지 않는다.

Coroutine은 어떻게 동작되길래 Java로 변환된 코드에서도 정상적으로 동작되는지 그 원리를 확인해보자.

CPS (Continuation Passing Style)

Coroutine의 동작 원리에 대해 살펴보기 위해, Coroutine을 구현하기 위한 기반 지식인 CPS (Continuation Passing Style)에 대해 알아보자.

CPS는 말 그대로 Continuation 을 전달하는 스타일이다. 그렇다면 Continuation은 무엇일까?

Continuation

위키피디아의 설명에 따르면, Continuation은 '컴퓨터 프로그램의 제어 상태를 추상적으로 표현한 것', '프로세스 실행의 주어진 지점에서 계산 해야 할 프로세스를 나타내는 데이터 구조' 라고 한다.

쉽게 얘기하면, 다음에 수행해야 할 작업을 구조적으로 나타낸 것 이라고 얘기할 수 있다.

<예시 코드>

fun postItem(item: Item){
    val token = requestToken()
    val post = createPost(token, item)
    processPost(post)
}

현재 프로그램이 2번 라인을 실행중이라면, 다음에 실행할 구문은
val post = createPost(token, item)이다.
3번 라인은 2번 라인에 의존적이며, 2번 라인 이후에 수행해야 하는 작업이다.

이것을 CPS로 구현하게 된다면, 위 설명에서
프로세스 실행의 주어진 지점에서 -> 2번 라인에서
계산 해야 할 프로세스를 -> 다음에 수행해야 하는 것이 3번라인라는 것을
나타내는 데이터 구조 -> CPS를 통해 구조적으로 나타낸다.
와 같은 의미로 해석할 수 있다.

CPS에 대해서는 아래에서 다시 알아보자.

kotlin에서는 Continuation 객체를 통해 다음에 수행할 중단/재개 시점을 Context에 저장하여 관리한다.
즉, Continuation 객체는 Callback Interface를 일반화한 객체라고도 볼 수 있다.

@SinceKotlin("1.3")
public interface Continuation<in T> {
    // 이 Continuation 에 해당하는 CoroutineContext
    public val context: CoroutineContext

    // 마지막 중단점의 반환값으로 성공 또는 실패한 결과를 전달하는 해당 코루틴을 재시작한다.
    public fun resumeWith(result: Result<T>)
}
@SinceKotlin("1.3")
@InlineOnly
public inline fun <T> Continuation(
    context: CoroutineContext,
    crossinline resumeWith: (Result<T>) -> Unit
): Continuation<T> =
    object : Continuation<T> {
        override val context: CoroutineContext
            get() = context

        override fun resumeWith(result: Result<T>) =
            resumeWith(result)
    }

이렇게 Continuation은 프로세스의 실행에서 다음에 수행할 위치를 구조화할 수 있기 때문에, Exception, Generator, Coroutine 등의 프로그래밍 언어에서 제어 메커니즘을 인코딩하는데 효율적이다.

First-class Continuation

Continuation은 First-class Continuation 라고도 할 수 있는데, 이것은 어느 지점에서나 실행 상태를 저장하고, 이후에 몇번이고 이 위치로 되돌아올 수 있다는 것을 의미한다.

말로는 어려울 수 있으니, 예시를 통해 확인해보자.
<예시 코드>

fun goWork() {
    var label = 1
    val doStep = Continuation<Unit>(context = Dispatchers.Default) {
        when (label) {
            1 -> println("집에서 준비하기")
            2 -> println("나가서 이동하기")
            3 -> println("출근해서 일하기")
        }
    }

    val rollbackStep = Continuation<Unit>(context = Dispatchers.Default) {
        label--
    }
    
    val procStep = Continuation<Unit>(context = Dispatchers.Default) {
        label++
    }
    
    doStep.resume(Unit) // 집에서 준비하기
    procStep.resume(Unit) // 준비 끝났으니 나가자!
    
    doStep.resume(Unit) // 회사로 이동하는중~
    rollbackStep.resume(Unit) // 아맞다 사원증! 집으로 빠꾸
    
    doStep.resume(Unit) // 다시 집에서 사원증 챙겨나오기
    procStep.resume(Unit) // 사원증 챙겼으니 다시 나가자!
    
    doStep.resume(Unit) // 회사로 이동~
    procStep.resume(Unit) // 출근 완료!
    
    doStep.resume(Unit) // 출근해서 일하기
}
집에서 준비하기
나가서 이동하기
집에서 준비하기
나가서 이동하기
출근해서 일하기

위 코드는 일반적으로 출근하는 과정을 (굉장히 간략하게) 담고있다. 위 설명에서,

  • 어느 지점에서나 실행 상태를 저장
    -> label 객체를 통해 실행 상태를 저장하며, procStep.resume(), rollbackStep.resume()을 통해 상태를 관리한다.
  • 이후에 몇번이고 이 위치로 되돌아올 수 있다
    -> 회사로 이동중에 사원증을 놓고와서 rollbackStep.resume()을 통해 집으로 돌아왔다.

First-class Continuation 은 명령의 실행 순서를 완벽하게 제어할 수 있다.
현재 함수를 실행시킨 함수로도 이동할 수 있고, 또는 이전에 이미 종료된 함수로도 이동할 수 있다.

이렇게 보면, First-class continuation은 프로그램의 실행 상태를 저장한다고 생각할 수 있다.
First-class continuation은 프로그램의 데이터를 process image처럼 저장하는 것이 아니라, 단지 실행 컨텍스트만을 저장한다. (그래서 빠르다!)

Call-with-current-continuation

Call-with-current-continuation (call/cc 로 줄여 쓴다) 은 말 그대로, '현재 Continuation 에서 새로운 Continuation을 호출하는 것'을 의미한다.

예외 처리나 (코루틴과 같이) 중단된 작업을 처리하는 상황에서 사용되며, 함수 자체를 인자로 받는다.

예시를 통해 확인해보자.

fun main(){
	printWithHello {
    	println("World!")
    }
}

fun printWithHello(block: () -> Unit) {
	println("Hello")
    block() // call/cc
}
Hello
World!

다시 CPS

CPS에 대해 이해하기 위해 Continuation를 알아봤다.

Direct Style vs CPS

우선, 코드를 작성할 때 대부분은 아래와 같은 Direct Style로 코드를 작성했을 것이다.

Direct Style

무언가 결과를 얻기 위해 잠시 기다리고, 후행 작업을 수행

fun postItem(item: Item){
    val token = requestToken() // Wait
    val post = createPost(token, item) // 후행 작업 (Continuation)
    processPost(post) // 후행 작업 (Continuation)
}

위 예시에서는
processPost(post)를 수행하기 위해 createPost(token, item) 를 기다리고,
createPost(token, item)을 수행하기 위해 requestToken() 을 기다린다.

CPS (Continuation-Passing-Style)

후행 작업 (Continuation)을 전달하는 기법

fun postItem(item: Item){
    requestToken { token ->
        val post = createPost(token, item) // Continuation
        processPost(post) // Continuation
    }
}

위 예시에서는, createPost(), processPost() 둘 다 token을 필요로 하기 때문에
requestToken 의 후행 작업으로 넘기도록 수정했다.

물론, processPost()는 직접적으로 token을 필요로 하지 않고 post를 필요로 하지만, post 또한 createPost()에서 token을 필요로 하기 때문에 하나로 묶었다.

이 코드는 한번 더 CPS 로 변환이 가능하다.

fun postItem(item: Item){
    requestToken { token ->
        createPost(token, item) { post ->
            processPost(post)
        }
    }
}

CPS vs Callback

위 예제를 보면, CPS와 Callback은 동일한 것처럼 보인다.
CPS는 Callback의 일부분이며, 아래와 같은 특징을 가진다.

  • CPS
    다음에 수행할 작업을 의미하기 때문에, 함수의 맨 마지막에 1회만 사용한다.
  • Callback
    추가로 수행할 작업을 의미하기 때문에, 함수 내 어느곳에나, 몇번이나 사용될 수 있다.

<예시 코드>

fun main() {
    val mysqlLogger = MySqlLogger(...)
    
    order(
        orderRequest = orderRequest,
        log = mySqlLogger::write,
        sendPush = pushService::send,
    )
}

private fun order(
    orderRequest: OrderRequest,
    log: (request: LogRequest) -> Unit,
    sendPush: (request: PushRequest) -> Unit,
) {
    val product = getProduct(...).also { log(it.toLogRequest()) } // callBack
    val coupons = getCoupons(...).also { log(it.toLogRequest()) } // callBack
    ...
    sendPush(pushRequest) // CPS
}

class ElasticsearchLogger(...) : Logger() {
    ...
}

class MySqlLogger(...) : Logger(...) {
    ...
}

위 예시는 상품을 주문하는 과정을 굉장히 축약해놓은 코드이다.
(물론, 실제 주문 로직은 이렇지 않다. 단순히 예제로만 참고하길,,,)

주문할 때는 요청이 조작될 수도 있기 때문에, 반드시 로직단에서 다시 한 번 상품, 쿠폰 등의 정보를 읽어서 검증 절차를 가진다.

위 로직은 Callback과 CPS를 모두 담고있다.
1. 상품, 쿠폰 등의 정보를 불러올 때마다 로깅을 남긴다. (CallBack)
2. 주문이 완료되면 구매자에게 푸시알림을 보낸다. (CPS)

CPS의 장점

CPS는 지금까지 위에 작성한 예시처럼 callback style로 작성할 수도 있지만,
switch (kotlin: when)문으로 작성하고, sm (state machine)을 통해 실행 순서를 관리할 수 있다. (이게 kotlin coroutine의 진짜 동작 과정이기도 하다.)

switch문으로 작성하는 방법은 다음 포스팅에서 다루도록 한다.

  • 재귀함수를 제거할 수 있다
    -> 스택 프레임을 늘리지 않을 수 있다.
  • CPS는 복잡한 콜백을 switch문으로 단순하게 표현할 수 있다.
    -> CPS는 콜백을 많이 사용하는 비동기 프로그래밍에서 유용하게 사용된다.

지금까지 살펴본 callback style CPS에서는 이해할 수 없는 장점들이지만,
다음 포스팅에서 소개할 switch문을 통한 CPS를 보면 이해가 갈 것이다.

CPS의 단점

위 설명을 보면, First-class Continuation은 실행 상태를 관리하기에 유용해보이지만, 단점도 분명하다.

프로그래밍을 해본 사람이라면, 누구나 (가급적 금기의) GOTO문을 알고 있을것이다.
Continuation은 GOTO문을 함수적으로 표현한 것이기 때문에, 사용할 때는 주의가 필요하다.

웹 프로그래밍과 같은 일부 특수한 경우에는 Continuation이 합리적인 옵션이지만, Continuation을 사용하면 디버깅이나, 코드 가독성 면에서 추적하기 어려워질 수 있다.

웹 프로그래밍에서 Continuation이 합리적인 이유

웹 어플리케이션에서는 많은 I/O 작업(DB, 외부 API 통신, File 등) 이 필요하다.
이러한 작업들은 네트워크 또는 디스크 I/O에 의해 블로킹될 수 있으며, 다른 클라이언트 요청의 처리를 지연시킬 수 있다.

이를 해결하기 위해 많은 웹 프로그래밍 언어와 프레임워크는 비동기식 프로그래밍 패턴을 지원한다. 그러나 비동기식 프로그래밍 패턴은 복잡하며, 콜백 지옥(callback hell)과 같은 문제를 야기할 수 있다.

이 때 Continuation을 사용하면 비동기식 프로그래밍을 동기식 코드처럼 작성하여 간소화하고 가독성을 높일 수 있다.

Reference
https://en.wikipedia.org/wiki/Continuation
https://en.wikipedia.org/wiki/Continuation-passing_style
https://jisungbin.medium.com/continuation-passing-style-863608b37c18
https://www.cs.purdue.edu/homes/suresh/590s-Fall2002/lectures/lecture-4.html
https://www.youtube.com/watch?v=YrrUCSi72E8

profile
A fast learner.

0개의 댓글