← 이전 화면 돌아가기

Backend

2023-11-08

코틀린 컴파일러는 코루틴을 어떻게 처리할까?

Coroutine의 개념과 Kotlin Compiler의 동작 원리

코틀린 컴파일러는 코루틴을 어떻게 처리할까?

안녕하세요, ONDA에서 호텔 운영관리 솔루션을 개발하고 있는 백엔드 개발자 Gunner(거너, 정재훈)입니다.

오늘은 Kotlin(코틀린) 환경에서 Coroutine(코루틴)이 어떻게 동작하는지에 대해 알아보려고 합니다.

먼저 코루틴은 callback을 사용하여 작성된 코드를 순차적 코드로 작성할 수 있게 해줍니다. 예제 코드를 보면 다음과 같습니다.

코루틴을 이용하여 callback 지옥을 피할 수 있으며, 코드를 읽기 쉬워집니다. 코루틴은 어떻게 이런 일을 할 수 있을까요?

1. Coroutines과 Suspension Point

위키피디아에서는 ‘Coroutines’을 다음과 같이 정의하고 있습니다. 

“Coroutines are computer program components that allow execution to be suspended and resumed, …” - 위키피디아

정의에 따르면 코루틴은 연산을 일시정지시키거나 재개할 수 있게 하는 프로그램의 구성 요소로 볼 수 있습니다. 

코틀린의 코루틴에서도 연산을 일시정지시키고 재개할 수 있는데요. 코틀린에서 연산을 일시정지시키는 지점을 ‘suspension point’라고 합니다. IntelliJ IDE(인텔리제이 개발 환경)를 사용할 경우, 하단 이미지와 같이 suspension point를 표시해줍니다.

suspension point는 선언시 suspend modifier가 붙은 함수를 실행할 때 표시됩니다. 위 그림에서 requestToken(), createPost(), processPost() 함수는 suspend로 선언된 함수인 걸 알 수 있습니다.

코드가 실행되다가 suspension point를 만나게 되면, 원래 연산의 흐름을 일시정지시키고, 해당 함수를 실행한 후, 원래 연산을 다시 실행하게 되는데요. 내부적으로 어떻게 일시중지 시키는지 예제 코드로 살펴보겠습니다.

suspendCoroutine 함수를 호출하면서 main() 함수의 연산은 일시정지됩니다. suspendCoroutine에 넘긴 람다에서 continuation.resumeWith()을 호출함으로서 원래 함수(위 예제 코드에선 main)의 실행을 재개할 수 있습니다. 이렇게 suspension point에서 실행을 일시정지하고, suspend 함수를 실행후, 원래 연산에서 일시정지된 시점을 재개할 수 있습니다.

2. Continuation == Callback

코루틴 동작을 설명하는 글에는 항상 ‘Continuation’이라는 개념이 나옵니다. 보통 ‘코틀린 코루틴은 CPS(Continuation Passing Style)를 이용하여 코루틴을 일시정지하고 재개한다’는 설명이 자주 보이는데요.

위의 예제에서 suspendCoroutine 함수에 넘긴 람다가 받는 파라미터명이 ‘continuation’이었습니다. continuation은 예전부터 있던 개념으로, scheme 언어에선 ‘call/cc’라는 함수로 continuation을 사용합니다. call/cc는 call-with-current-continuation이라는 뜻입니다.

continuation을 이용해서 원래 함수의 연산을 일시정지시키고, suspend 함수가 완료되었을 때 원래 함수의 연산을 재개할 수 있습니다. continuation에는 무엇이 들어있어 이런 동작이 가능할까요? 

우리가 어떤 일을 하다가 일시정지하고 다른 일을 하게 된다면, 그리고 추후 다른 일을 완료한 후, 원래 일시정지한 일을 재개하려 한다면, 몇가지 기억할(혹은 어딘가에 적어놓을) 것들이 있습니다.

  • 일을 어디까지 진행했는지(어디서부터 일을 재개해야 할지)에 대한 표시
  • 원래 작업을 중지했을 때와 같은 환경(엑셀 작업을 하고 있었다면, 해당 엑셀 파일과 작업시 참고하던 다른 자료같은 것들이 되겠습니다)

이 두가지가 준비되어야 우리는 일시정지했던 작업을 재개할 수 있습니다.

함수를 일시중지·재개하는 것도 동일합니다. 함수를 재개하기 위해서는 다음 두가지가 필요합니다.

  • 연산을 어디까지 실행했는지
  • 중지될 당시의 context 변수 값(예를 들어, varA = 10, varB = “This is string” 이었다는 context를 기록)

이 정보들을 continuation에 담아두고 일시정지한다면, 다른 일을 마친 후 continuation에 담긴 정보를 이용하여 원래 함수가 하던 일을 재개할 수 있습니다. 보통 continuation을 쓸 일이 없어 잘 와닿지는 않더라도, 뭔가 비슷한 컨셉이 떠오르지 않으시나요? 

바로 ‘callback’입니다. 보통 callback은 특정 시점에 실행하고 싶은 람다를 넘겨 이를 실행되도록 합니다. continuation도 마찬가지로 특정 시점에 실행하고 싶은 람다를 넘기고 이를 실행하도록 하죠.

다만 continuation에선 람다를 실행할 특정 시점은 바로 당장이고, 원래 연산을 재개하는 시점은 람다가 끝난 후가 되겠습니다. callback의 경우, 람다를 실행할 특정 시점은 callback 함수 내부의 어딘가에서 호출될 때이고, 원래 연산을 재개하는 시점은 당장으로 볼 수 있습니다. 실행하는 시점이 다를 뿐 크게 봤을때 callback과 continuation은 유사한 개념이란 것을 알 수 있습니다.

3. 코틀린 컴파일러가 알아서 변환해줍니다

앞서 suspension point와 continuation에 대해 알아봤습니다. 내부적으로는 저렇게 작동하는데 누가 저런 처리를 해주는 걸까요? 

바로 코틀린 컴파일러가 해줍니다. 코틀린 컴파일러는 ‘suspend modifier’가 붙은 함수를 보면, 다음과 같이 내부적으로 코드를 변환합니다.

Continuation은 인터페이스로서 다음과 같습니다.

resumeWith 호출로 결과값을 전달해 원래 함수를 재개시킨다는 것은 확인하실 수 있을 텐데요. 하지만 suspension point를 만날 때마다 일시정지 및 재개를 하게 되는데 어디까지 실행했다는 건 어떻게 알 수 있을까요?

코틀린은 이를 코드 변환시 ‘label’을 두어 표시합니다. 코드로 예를 들어 보겠습니다.

각 suspension point마다 주석으로 label 표시를 해놓았습니다. 이렇게 포인트마다 라벨을 붙이고. 어느 라벨인지 기억하고 있다면 다음에 흐름을 재개할 때 원하는 라벨 지점에서 재개할 수 있습니다.

어느 단계까지 실행했다는 label 정보는 어디에 있을까요? 앞서 continuation이 가지고 있다고 말씀드렸는데요. continuation에 들어있는 label 정보로 분기를 하면 일시정지되었던 코드부터 실행을 재개할 수 있습니다.

어느 정도 틀이 잡혀갑니다. 코드를 보면, 처음 실행시 continuation이 없으면 만들어 주고, 이후 suspension point에서 호출할 때마다 이를 재사용합니다.

근데, 뭔가 빠진 거 같네요. label을 저장하는 코드가 없고, label이 1일 경우 createPost를 호출할 때 필요한 item 값은 어디서 가져오나요? 저 라인이 실행될 때는 resumeWith로 연산이 재개될 때라 원래 함수 진입시 받았던 item 값은 없을 거 같은데요?

맞습니다. 그래서, 위에 언급한 대로 state도 continuation에 저장한 뒤, 나중에  꺼내서 사용하는 코드를 추가해줍니다.

suspension point를 기억하고, state를 관리 하는 일을 suspendCoroutine()과 continuation이 해주는데요. 코틀린 컴파일러가 suspend 제한자가 붙은 코드를 처리할 때, 이렇게 자동으로 코드를 변형해줍니다. 

다만, 실제 변환 코드가 저렇게 작성되진 않습니다. 실제 컴파일된 바이트 코드를 디컴파일해 보면 복잡한 Java 코드를 볼 수 있지만, 독자분들의 이해를 돕기 위해 코틀린 코드로 예를 들었는데요. 큰 흐름은 동일하니 이렇게 이해하셔도 무리가 없을 것입니다.

4. Coroutine에서 future-like에 대해 제공하는 extension

위 예시들로 callback 스타일 코드를 direct 스타일로 쓸 수 있게 된다는 것은 이해하셨을 거라 생각합니다. 하지만 이렇게 쓴다고 해서 특별한 성능 향상이 있을 것 같진 않고, callback을 써도 보기에 좋지 않을 뿐 별다른 문제가 없어 보이는데요. 스타일 차이 외에 또 다른 장점은 없는 걸까요?

Coroutine의 장점은 유휴 쓰레드를 효율적으로 활용할 수 있다는 점입니다. suspension point에서 일시중지시키고 다른 작업을 시작할 때 이를 특정 쓰레드풀에서 실행시킨다면, 원래 작업이 재개될 때까지 일시정지시킨 작업이 실행되던 원래 쓰레드는 유휴 상태라 다른 필요한 곳에서 사용할 수 있습니다.

이런 식으로, 다수의 async 콜을 동시에 보내서 한꺼번에 처리하려 할 때 async 콜들이 suspend로 호출된다면, 효율적인 쓰레드 활용이 가능합니다. 그리고, 다른 장점이 있는데요. 보통 JVM의 여러 async 라이브러리들은 독자의 ‘Future-like’를 제공합니다. 

  • Guava: ListenableFuture
  • RxJava: Observable
  • JDK8: CompletableFuture

클래스는 다르지만, 기본적으로 Future와 동작 원리는 같은 클래스들입니다. 코틀린의 coroutine에서는 이런 future-like 클래스들과 연동되는 확장 코드를 제공합니다. 

이를 통해 해당 future-like 확장 코드가 제공되는 라이브러리에 한해 유니폼한 형태로 값을 가져올 수 있는데요. 예를 들어, 코루틴에서 ‘await()’를 쓰면 대상이 ListenableFuture, CompletableFuture, Observale, Promise 등 무엇이든 같은 방식으로 작동합니다. 이는 코틀린 확장(extension)을 통해 가능하며 결과적으로 코루틴을 통하여 라이브러리와 관계없이 동일한 방식으로 연동 코드를 작성할 수 있습니다.

💡 Kotlin Compiler

1. 코틀린 컴파일러는 suspend modifier가 붙은 함수를 변환한다.

2. 변환된 함수는 suspension point와 state를 관리하는 continuation을 콜백처럼 이용하여 연산을 일시정지/재개할 수 있다.

3. 일시정지된 원래 쓰레드는 재개될 때까지 다른 작업에서 활용할 수 있으므로 효율적이다.

4. 코루틴에서 제공하는 future-like에 대한 확장을 통해, 유니폼한 형태로 다른 future-like 라이브러리를 다룰 수 있다.

지금까지 Coroutine의 개념과 Kotlin Compiler의 동작 원리에 대해 소개해드렸습니다. 또한, 여러 future-like에 대해 제공되는 확장 덕택에 코루틴에선 동일한 형태의 코드로 future-like를 다룰 수 있습니다. 감사합니다.

숙박산업 최신 동향, 숙소 운영 상식 등 숙박업의 유용한 정보들을
이메일로 짧고 간편하게 만나보세요!
위클리온 구독신청
Gunner
Backend Developer

웹서비스, 디지털 광고 분야에서 백엔드 개발 업무를 했습니다. 현재는 온다에서 호텔 관리 솔루션을 개발하고 있습니다.