개요
코틀린에서 병행성을 구현하는 방법은 다양합니다. 원래 멀티플랫폼을 목표로 만들어진 언어라서 특정 플랫폼을 겨냥해서 사용하는 경우 원래 그 플랫폼에서 사용하는 방법으로 병행성을 구현하면 되죠.
하지만 멀티플랫폼을 노리고 중립적으로 병행성을 실현하려면 각 플랫폼별로 자연스럽게 번역되는 중립적인 병행성 라이브러리가 필요할 것입니다. 이런 라이브러리 프로젝트가 오픈소스로 없는 것은 아닙니다.
대표적으로 Rx와 유사한 인터페이스로 멀티플랫폼에서 사용할 수 있게 하는 Reaktive 같은 녀석이 있습니다(더 자세한 내용은 여기로)
코틀린 코루틴은 젯브레인이 직접 관리하는 라이브러리로 멀티플랫폼에서 중립적인 병행성을 구현할 수 있게 돕는 라이브러리입니다. 보통 이런 라이브러리는 독립적으로 존재하기 때문에 kotlinx 패키지 하에 있지만 코틀린 코루틴의 경우 코틀린의 문법적 요소 suspend함수를 내포하기 때문에 kotlin언어 자체에도 일부 내용이 들어있습니다.
코틀린 언어 수준에서는 suspend함수와 그 확장함수들, Context, Continuation과 관련된 기능이 들어있고, kotlinx는 CoroutineScope를 기반으로 하는 다양한 기능과 도우미 객체, 함수들이 들어있습니다.
전체적으로 젯브레인에서는 suspend를 직접 다루지 않고 CoroutineScope레벨로 감싼 추상층에서 코루틴을 사용하길 권장하며, 대부분의 문서와 예제도 이 수준을 기반으로 하고 있습니다.
이 시리즈는 suspend함수의 기초 작동과 Context, Continuation의 의미와 관계를 살펴보는 걸로 시작하여 연재됨에 따라 CoroutineScope 수준의 기능과 작동까지 설명합니다.
본문을 읽기 전에 비선점형 멀티태스킹과 Continuation Passing Style(이하 CPS)에 대한 개념학습이 필요합니다(모르셔도 약간 힘들 뿐이긴 합니다만 ^^)
suspend 함수를 직접 실행해보기
아래와 같은 어떤 suspend 함수가 있다고 생각해보죠.
suspend fun helloSuspend(){ println("hello suspend!") }
이 함수는 컴파일 되고 나면 사실 Continuation(컨티뉴에이션)을 받는 함수로 바뀌게 되는데 정확하게는 이를 직접 호출할 수는 없고 startCoroutineUninterceptedOrReturn라는 확장함수를 통해 호출할 수 있게 됩니다. 따라서 다음과 같이 suspend함수를 직접 호출할 수 있습니다.
::helloSuspend.startCoroutineUninterceptedOrReturn(Continuation)
문제는 Continuation을 어떻게 만들어서 넘길까 인데 실제 Continuation은 context속성과 resumeWith만 갖는 매우 간단한 인터페이스로 아무것도 안하는 최소한을 구현하려면 다음과 같이 하면 됩니다.
class Cont(override val context:CoroutineContext = EmptyCoroutineContext): Continuation { override fun resumeWith(result: Result<Unit>){ } } ::helloSuspend.startCoroutineUninterceptedOrReturn(Cont())
이쯤에서 구조가 얼추 파악되셨겠지만
- 컨티뉴에이션이 컨텍스트를 소유하고
- suspend함수에 컨티뉴에이션을 넘겨서 시작하면 실행됨
이런 구조입니다.
헌데 저렇게 호출한다고 hello suspend!를 볼 수는 없습니다.
hello suspend!을 보려면 최초에 한 번은 반드시 Continuation의 resumeWith를 호출해야 합니다. 그러고나면 자동으로 쭉 진행됩니다. 따라서 우리가 hello suspend!를 보기 위한 최종 코드는 다음과 같습니다.
suspend fun helloSuspend(){ println("hello suspend!") } class Cont(override val context:CoroutineContext = EmptyCoroutineContext): Continuation { override fun resumeWith(result: Result<Unit>){ } } ::helloSuspend.startCoroutineUninterceptedOrReturn(Cont()).resumeWith(Result.success(Unit)) // "hello suspend!"
앞으로 이 마법이 일어나기 위해 컴파일러가 어떤 일을 하게 되는지 그리고 위 코드의 실제 의미가 무엇인지 차근차근 살펴보죠.
suspend함수가 컴파일되면
인터셉터의 개념을 이해하려면 suspend에 전달된 컨티뉴에이션이 어떻게 호출되는가에 대해 좀 더 알아볼 필요가 있습니다. 그리고 다시 이것을 이해하려면 suspend 함수가 컴파일 되면서 어떤 식으로 내부 코드가 변하는지 이해해야합니다.
suspend함수가 컴파일되면 어떻게 되는지 자세한 컨셉과 변환 구조는 KEEP/coroutine 공식 문서에서 설명하고 있습니다.
이 문서의 컨셉 따르면 하나의 suspend함수는 Continuation을 상속하고 label이란 속성을 갖는 클래스로 번역됩니다. 지역변수나 인자는 전부 이 클래스의 속성으로 바뀌죠. 그리고 suspend함수 내부의 코드는 전부 resumeWith(result) 형태의 메소드 안으로 들어갑니다. 이를 우리가 인간 컴파일러가 되어 차근차근 진행해보죠.
예를 들어 다음과 같은 suspend함수가 있다고 생각해보죠(KEEP문서에 나오는 예제 ^^)
suspend fun foo(a:Any):Int = 3 suspend fun bar(a:Any, y:Int):Int = 5 suspend fun test(){ val a = a() val y = foo(a) // suspension point #1 b() val z = bar(a, y) // suspension point #2 c(z) }
이 함수에는 두 군데서 suspend를 호출하는 지점이 있습니다. y와 z를 할당하는 곳으로 foo와 bar도 또 다른 suspend함수입니다. 컴파일러는 suspend내부에 다른 suspend를 호출하는 지점을 suspension point(일시중단점)로 인식합니다. 그리고 여기를 기준으로 label을 나눠서 붙여주고 반환합니다. 우선 그렇게 분리해보면 다음과 같은 형태가 될 것입니다.
fun test(){ L0: val a = a() val y = foo(a) // suspension point #1 L1: b() val z = bar(a, y) // suspension point #2 L2: c(z) return }
왜 result를 받는 함수가 되는지는 좀 있다가 살펴보기로 하고 일단 test가 내부에 일시중단점을 기준으로 레이블을 나누었음을 알 수 있습니다.
이는 컴파일 시점에 자동으로 번역되며 이 결과로 suspend test함수로 부터 다음과 같은 클래스가 만들어집니다. 실제로는 코틀린 바이트코드지만 알기쉽게 코틀린 코드로 작성하여 살펴보겠습니다.
class Test:Continuation{ //suspend함수는 Continuation 구상 클래스로 컴파일됨 var label = 0 //label은 클래스의 속성이 되어 유지됨 private set override fun resumeWith(result:Any?){ //suspend함수 내용이 여기로 while(true){ when(label){ 0->{ val a = a() val y = foo(a) } 1->{ b() val z = bar(a, y) } 2->{ c(z) return } } } }
일단 while문이 돌면서 모든 label이 처리되는 것을 알 수 있습니다. 한 번 resumeWith를 호출하면 label상태 0, 1, 2가 다 해소되는 것이죠. 하지만 이건 이후 설명하면서 점점 변경될 것입니다.
이외에도 하나의 suspend함수가 Continuation을 상속한 클래스가 되는 것을 볼 수 있습니다.
suspend함수 → Continuation객체
즉 suspend함수로부터 컴파일러가 생성한 Continuation클래스의 인스턴스를 바로 코틀린에서는 Coroutine(코루틴)이라고 부르고 있습니다.
앞으로 코루틴이라는 단어가 나오면 일반명사가 아니라
“suspend함수로 부터 컴파일러가 만들어낸 Continuation구상 클래스의 인스턴스“
라고 생각하시면 됩니다. 핵심은 저 클래스가 아니라 호출 시마다 생성되는 그 클래스의 인스턴스가 코루틴이라는 것입니다. 그래야만 매 suspend함수 호출 시마다 인스턴스별로 고유의 상태를 기억할 수 있기 때문이죠. 위의 코드에는
- Continuation인터페이스의 필수적인 context 속성이 생략되어있고
- 또한 생성시 반드시 받아야하는 다른 Continuation객체의 존재도 생략되어있습니다.
위 코드를 점진적으로 발전시켜가면서 코틀린 컴파일러가 suspend함수를 어떻게 Continuation객체로 번역하는지 심층탐구해보죠.
suspend함수의 컴파일 번역 상세
일단 suspend test함수가 Continuation을 구상한 Test클래스가 되었다면 내부에 존재하는 foo나 bar도 그러한 변화가 적용될 것입니다. 근데 foo에 넘겨진 인자 a나 bar에 넘겨진 인자 a, y는 어찌될까요?
무난하게는 생성자의 인자로 받을 수 있습니다.
//suspend foo함수로부터 생성됨 //생성자 인자로 함수의 인자를 받음 class Foo(private val a:Any):Continuation{ ... } //suspend bar함수로부터 생성됨 //생성자 인자로 함수의 인자를 받음 class Bar(private val a:Any, private val y:Any):Continuation{ ... } class Test:Continuation{ var label = 0 private set override fun resumeWith(result:Any?){ while(true){ when(label){ 0->{ val a = a() //Foo의 인스턴스 생성 및 실행 val y = Foo(a).resumeWith(null) } 1->{ b() //Bar의 인스턴스 생성 및 실행 val z = Bar(a, y).resumeWith(null) } 2->{ c(z) return } } } }
위 구현을 보면 test내부에서도 foo, bar함수 대신 번역된 Foo, Bar클래스의 인스턴스를 생성한 뒤 resumeWith를 호출하여 실행하는 것을 볼 수 있습니다.
헌데 현재는 label값이 0인 상태에서 변하지 않으므로 무한루프돌면서 0번 케이스를 처리합니다. 각 단계는 전진되어야하므로 label값을 바꿔야합니다. 그 시점은 내부 suspend함수를 호출하기 직전에 이뤄집니다.
class Foo(private val a:Any):Continuation{ ... } class Bar(private val a:Any, private val y:Any):Continuation{ ... } class Test:Continuation{ var label = 0 private set override fun resumeWith(result:Any?){ while(true){ when(label){ 0->{ val a = a() label = 1 val y = Foo(a).resumeWith(null) } 1->{ b() label = 2 val z = Bar(a, y).resumeWith(null) } 2->{ c(z) label = -1 return } } } }
이제 resumeWith를 실행하면 무사히 0, 1, 2인 경우가 실행되긴 하지만 문제가 더 있습니다.
a는 label이 0일때 만들어진 지역변수로 1일때는 참조할 수 없는 스코프를 갖습니다.
이렇듯 label 값이 다른 데도 참조되는 변수에 대해 컴파일러는 일괄 속성을 변경하여 컴파일 합니다.
class Foo(private val a:Any):Continuation{ ... } class Bar(private val a:Any, private val y:Any):Continuation{ ... } class Test:Continuation{ var label = 0 private set //label 여러군데서 쓰이는 지역변수를 속성으로 private lateinit var a:Any private lateinit var y:Any private lateinit var z:Any override fun resumeWith(result:Any?){ while(true){ when(label){ 0->{ //a, y를 최초로 여기서 사용했는데 a = a() label = 1 y = Foo(a).resumeWith(null) } 1->{ //여기서도 bar(a, y)가 사용하므로 속성화됨 b() label = 2 z = Bar(a, y).resumeWith(null) } 2->{ //z도 label이 1,2일때 사용되므로 속성화됨 c(z) label = -1 return } } } }
근데 뭔가 코드에 이상한 점이 있습니다.
resumeWith는 반환 값이 없는 함수인데 y = Foo(a).resumeWith(null) 처럼 결과값을 y에 넣으려고 합니다. 이렇게 동작할리가 없죠. continuation은 이 경우
- test의 resumeWith의 실행을 중단시키고
- 제어권을 Foo쪽에 넘기며
- Foo가 실행이 완료되었을 때 다시 test의 resumeWith를 실행하게 합니다.
이렇게 Foo에서 다시 test.resumeWith를 호출하면 label이 1인 상황이 되므로 그 다음 단계를 실행하게되며 Foo는 test.resumeWith(여기) 인자로 본인의 결과값을 넘겨주게 되는 것입니다. 이를 코드로 표현하면 다음과 같습니다.
class Foo(private val a:Any):Continuation{ ... } class Bar(private val a:Any, private val y:Any):Continuation{ ... } class Test:Continuation{ var label = 0 private set private lateinit var a:Any private lateinit var y:Any private lateinit var z:Any override fun resumeWith(result:Any?){ while(true){ when(label){ 0->{ a = a() label = 1 Foo(a).resumeWith(null) return //test의 resumeWith는 여기서 정지 } 1->{ //Foo의 실행이 끝나면 다시 test.resumeWith를 호출해주며 //이 때 Foo의 결과값이 넘어오므로 이를 y에 할당 y = result!! b() label = 2 Bar(a, y).resumeWith(null) return //test의 resumeWith는 여기서 정지 } 2->{ //y와 마찬가지로 Bar도 result로 넘겨주어 z에 할당 z = result!! c(z) label = -1 return } } } }
바로 위의 코드가 핵심적인 동작입니다.
- 최초 test함수는 test.resumeWith로 시작했지만
- 중간에 Foo.resumeWith가 등장하면 test.resumeWith는 중지되고
- Foo내부가 실행완료된 뒤 다시 Foo가 test.resumeWith를 호출하면서 인자에 결과를 반환해주면
- Bar는 Foo.resumeWith를 호출하기 전에 label을 변경했으므로
- 그 다음 label 상태에서 인자로 넘어온 result값을 원래 받으려했던 변수에 넣어주는 것입니다.
위의 동작으로부터 Foo나 Bar가 자신이 동작이 완료된 후 다시 test를 호출하려면 생성할 때부터 되돌아갈 코루틴을 알고 있다는 점을 유추할 수 있습니다.
//모든 컨티뉴에이션은 생성될 때 returnPoint를 알고 있음 class Foo(private returnPoint:Continuation, private val a:Any):Continuation{ override fun resumeWith(result:Any?){ //마지막 상태에서는 returnPoint의 resumeWith를 호출해줌 returnPoint.resumeWith(3) } } //모든 컨티뉴에이션은 생성될 때 returnPoint를 알고 있음 class Bar(private returnPoint:Continuation, private val a:Any, private val y:Any):Continuation{ override fun resumeWith(result:Any?){ //마지막 상태에서는 returnPoint의 resumeWith를 호출해줌 returnPoint.resumeWith(5) } } //모든 컨티뉴에이션은 생성될 때 returnPoint를 알고 있음 class Test(private returnPoint:Continuation):Continuation{ var label = 0 private set private lateinit var a:Any private lateinit var y:Any private lateinit var z:Any override fun resumeWith(result:Any?){ when(label){ 0->{ a = a() label = 1 //생성시 returnPoint로 test를 넘겨줌 Foo(this, a).resumeWith(null) return } 1->{ y = result!! b() label = 2 //생성시 returnPoint로 test를 넘겨줌 Bar(this, a, y).resumeWith(null) return } 2->{ z = result!! c(z) label = -1 //마지막 상태에서는 returnPoint의 resumeWith를 호출해줌 returnPoint.resumeWith(null) } } } //실제 Test코루틴 발동 Test(NoActionContinuation).resumeWith(null)
이것이 바로 모든 컨티뉴에이션이 생성될 때 다른 컨티뉴에이션을 인자로 요구하는 이유입니다.
returnPoint가 필요하기 때문이죠.
하지만 test는 최초 호출될 때 되돌아갈 returnPoint가 없습니다. 그럼 null을 대신할 아무것도 안할 객체가 필요합니다. 이게 이글 초반에 정의했던 아무것도 안하는 간단한 컨티뉴에이션 객체입니다.
컨티뉴에이션 객체는 컴파일러가 suspend함수를 분석하여 만들기도 하지만 개발자가 직접 Continuation인터페이스를 구상하여 정의할 수도 있습니다. 이것도 코루틴인 거죠.
정의의 핵심은 suspend함수 자체가 Continuation객체로 변환될 것이며 이걸 바로 코루틴이라고 부른다는 것이고 이는 직접 Continuation을 구상한 객체를 만들어도 마찬가지로 코루틴이라 부릅니다.
다시 실제 상황에 대입해보면
여태 설명한 의사코드를 실제 코틀린에서는 어찌 사용하는지 보기 위해 처음 나왔던 코드를 다시 보죠.
그럼 컨티뉴에이션의 resumeWith는 뭘까요?
왜 Test의 resumeWith에 넘기는 인자인
하지만 앞에서 다뤘던 suspend함수의 호출을 직접하는 코드를 살펴보는 것으로 더 깊은 내부 시스템의 이해에 도달할 수 있습니다.
suspend fun helloSuspend(){ // class HelloSuspend:Continuation으로 컴파일러가 번역함 println("hello suspend!") } //아무것도 안하는 Continuation객체의 정의 class Cont(override val context:CoroutineContext = EmptyCoroutineContext): Continuation { override fun resumeWith(result: Result<Unit>){ } } ::helloSuspend.startCoroutineUninterceptedOrReturn(Cont()).resumeWith(Result.success(Unit)) // "hello suspend!"
- 우선 helloSuspend함수는 HelloSuspend:Continuation 클래스로 번역될 것입니다.
- 그렇게 번역될 helloSuspend함수는 startCoroutineUninterceptedOrReturn라는 확장함수를 호출하고 있습니다.
- 이 때 중요한건 Continuation을 요구한다는 점입니다. 위 코드는 이 요구사항에 아무것도 안하는 Continuation객체를 만들어서 넘기고 있습니다.
그럼 startCoroutineUninterceptedOrReturn 확장함수의 내부를 좀 살펴봐야하는데, 그 안에서는 createCoroutineUnintercepted를 호출합니다.
public fun <T> (suspend () -> T).startCoroutineUninterceptedOrReturn( completion: Continuation<T> ) { //여기서 createCoroutine을 호출함 createCoroutineUnintercepted(completion).resumeWith(Result.success(Unit)) } //이 함수는 결국 인자로 다른 Continuation을 받아 Continuation을 반환함 public expect fun <T> (suspend () -> T).createCoroutineUnintercepted( completion: Continuation<T> ): Continuation<Unit>
결국 createCoroutineUnintercepted가 completion을 요구하는 모습은 우리가 의사코드로 만들었던 Foo, Bar나 Test가 Continuation을 요구하는 것과 완전히 일치합니다. 즉 createCoroutineUnintercepted함수는 suspend함수로부터 만들어지는 코루틴클래스에게 returnPoint가 되는 Continuation을 넘겨 코루틴 객체를 만들어내는 함수인 것입니다.
그리고 나서 resumeWith를 호출하여 발동 시킨 것 까지 완전히 동일합니다.
단지 진짜 코루틴에서는 Continuation의 resumeWith가 받는 인자가 그냥 값이 아니라 Result<T>형으로 실패와 성공을 다 보고할 수 있게 한다는 정도의 차이 뿐입니다.
결론
이번 글에서는 suspend함수와 Continuation, Coroutine이 무엇인지 정확하게 정의하고 코틀린 컴파일러와 결합하여 어떻게 동작하는지 자세히 살펴봤습니다.
다음 시간에는 이번 시간에는 생략했던 CoroutineContext를 면밀히 알아봅니다.
CoroutineContext는 원래 Continuation이 소유하는 객체입니다만 이번 글에서는 resumeWith의 동작에 집중하기 위해 일부러 감추고 코드를 진행했습니다.
코루틴컨텍스트의 상세한 내용을 살피다보면 인터셉터와 디스패처, 오류핸들러와 일반적인 데이터의 동시성 공유문제 해결 등 추가적인 주제들을 같이 보게 될 것입니다.
recent comment