코틀린의 코루틴은 코틀린에서 동시성 프로그래밍을 지원하기 위한 도구입니다. 자바에도 동시성 프로그래밍을 위한 편리한 라이브러리가 많죠. 코틀린에선 자바 코드를 문제없이 사용할 수 있으니 동시성 코드를 구현함에 있어서 꼭 코루틴을 써야하는 건 아닙니다. 하지만 코틀린의 코루틴은 기존 자바 동시성 도구들에서는 볼 수 없었던 두가지 큰 특징이 있습니다.
- CPS (Continuation Passing Style)
- Structured Concurrency
보통 코틀린 코루틴의 장점에 대해 설명하는 글들을 읽어보면 성능적인 부분을 많이 강조합니다. 이는 1번 특징인 CPS 와 연관이 많기 때문에 CPS 를 설명하는 대목은 어렵지 않게 찾을 수 있습니다. 하지만 2번 특징인 Structured Concurrency 는 표면적인 의미만 짚고 넘어가는게 대부분이고 깊이있게 설명한 글은 잘 찾아볼 수 없었습니다. 이에 대해 답답해하던 와중에 Structured Concurrency 라는 개념을 처음 소개한 블로그 글을 접했는데요. 저는 이 글을 읽고나서야 비로소 Structured Concurrency 라는게 대체 무엇이고 왜 이런 개념이 생겼으며 그 의의가 무엇인지 이해할 수 있었습니다.😭 너무 좋은 내용이 많아서 이번 기회에 위 글을 요약하고 코틀린 코루틴과 연관지어 설명하고자합니다.
무시무시한 goto
이 글을 읽고 계신 분 중 대부분은 goto
라는 명령어에 대해 들어본 적 있을 겁니다. 주로 저수준 프로그래밍 언어에서 사용되고 특정 위치로 실행흐름을 바꾸기위해 사용합니다.
goto label
하지만 goto
는 그 기능보다는 악명으로 유명하죠. 대다수의 교재나 강의에서 goto
에 대해 설명할 때, 그 기능보다는 사용을 피해야 하는 이유에 더 큰 비중을 둡니다.
혹시 goto 의 어떤 특징 때문에 사용을 피하라고 했었는지 기억 나시나요?
위 코드는 COBOL 의 아버지뻘인 FLOW-MATIC 이라는 언어로 작성된 코드입니다. 요즘 언어에서는 흔히 쓰이는 block 형태의 코드가 전혀 없죠. 대신 FLOW-MATIC 은 조건문에서 goto 명령어를 사용해서 로직을 분기시킵니다. 코드의 기본적인 흐름은 위에서 아래로 순차적이지만 goto 를 만나는 순간 그 흐름은 전혀 다른곳으로 흘러버립니다.
코드의 흐름을 바꿔버리는 goto 가 한두개면 그래도 괜찮겠지만 앞선 코드에서 처럼 많이 사용되면 코드의 흐름을 머리로 따라가기가 굉장히 힘들어집니다. 위 코드에서 goto 가 사용되는 곳 마다 그 흐름을 화살표로 그려 볼까요?
화살표만 봐도 머리가 아픈데 실제로 코드를 읽으려면 더 아플 것 같습니다. 그런데 화살표를 가만 보고있으니 곡선 여러개가 배배 꼬인것이 모양이 꼭 스파게티 같지 않나요? 개발자들이 흔히 난잡하고 가독성이 떨어지는 코드를 보고 스파게티 코드라고 부르죠. 이 스파게티 코드라는 단어가 여기서 유래했습니다.
goto 의 말로
goto 가 지금에야 고수준언어에서는 거의 안쓰이다시피 하지만 50년대까지만 해도 그때 당시 고수준언어에서 아주 흔하게 쓰였습니다. 이 주류에 반대하는 흐름은 60년대 중후반 부터 생겼는데 이 반류를 주도한 대표적인 인물이 다들 아시는 다익스트라(Edsger W. Dijkstra)입니다.
다익스트라는 프로그래밍에 있어서 goto 의 사용에 대해 강하게 반대했습니다. 다익스트라는 1968년 Go to statement considered harmful 라는 제목의 유명한 글을 기고했는데 이 글을 보면 그가 goto 의 사용을 정말 강하게 반대했다는 걸 확인할 수 있습니다.
위 글에서 다익스트라는 goto 의 사용에 반대하는 근거로 goto 의 여러가지 위험성과 문제점을 지적합니다. 다익스트라의 글을 간단히 요약하면 아래와 같습니다.
- goto 의 무분별한 사용은 프로그램의 구조를 매우 복잡하게 만든다. 이는 곧 개발자의 실수를 유발하기 쉽고 코드를 이해하는 것 조차 어렵게 만든다.
- 프로그램의 크기는 날이갈수록 커지고 우리의 뇌는 한계가 있다. 다만 아무리 프로그램이 커질지언정 그저 커졌다는 이유만으로 개발자가 방해받아서는 안된다. 이를 위해 우리는 도구가 필요하다.
- 코드가 위에서 아래로 흐르는 순차적인 구조. 프로그램이 아무리 커지더라도 이 구조만 지켜지면 된다.
- goto 를 쓰지않고 시작과 끝이 명확한 순차적이고 구조적인 프로그래밍(structured programming)을 하자.
다익스트라는 코드가 위에서 아래로 순차적으로 흐르는 구조가 지켜져야한다고 말했고 goto 는 이 흐름을 지키지 못하니 goto 를 쓰지 말자고 주장했습니다. 이는 곧 추상화를 의미하기도 하는데요.
조건문이든 반복문이든 코드가 위에서 아래로 순차적으로 흐른다는 구조만 지켜지면 코드의 이해 난이도는 훨씬 낮아집니다. 왜냐면 이런 구조에서는 특정 메소드에서 분기가 아무리 일어나더라도 사용자는 해당 메소드의 내부를 알필요가 전혀 없거든요. 분기된 코드의 흐름은 결국엔 다시 본래 흐름으로 돌아와서 아래로 흐를것이기 때문이죠. 한마디로 추상화가 가능해지는겁니다.
하지만 goto 는 다릅니다. 한번 goto 를 들어가면 그 코드의 흐름은 어디로 갔는지 모릅니다. 알려면 메소드 내부를 봐야만하죠. 그래서 goto 는 추상화를 해치고 결국 코드를 이해하기 어렵게 만듭니다.
RIP goto (해치웠나?)
다익스트라를 비롯하여 goto 의 사용에 반대한 많은 학자들 덕분에 goto 는 현대의 고수준 언어에서 거의 쓰이지 않게 되었습니다. 적어도 표면적으로는 그렇게 보입니다.
지금까지 goto 명령어의 생애에 대해 간략하게 알아봤습니다. Structured Concurrency 설명한다고 해놓고 왜 뜬금없이 goto 에 대해 주저리 설명했냐고 생각하실수도 있습니다. 하지만 Structured Concurrency 가 탄생한 배경에는 위에서 다익스트라가 주장했던 Structured Programming(구조적 프로그래밍)이 있고 이를 이해하기 위해선 goto 에 대한 스토리가 꼭 필요했습니다.
go (to)
이제 goto 는 거의 쓰이지 않습니다. c 나 c++ 를 제외하고 고수준의 언어에서는 거의 없을겁니다. 자바에서도 goto 가 예약어로 지정은 되어있으나 아무런 기능을 하지않죠. 다익스트라의 바램대로 goto 는 정말 없어진걸까요?
혹시 Go 언어에서 비동기 함수를 어떻게 실행하는지 아시나요? 보통 아래와 같은 형태입니다.
go myfunc()
어디서 많이 본 모양이죠? 방금까지 없어졌다고 말했던 goto 와 그 쓰임새가 굉장히 닮았습니다. 위 메소드도 앞서 goto 에서 했듯이 흐름을 한번 그려봅시다.
부모 고루틴의 흐름(초록색)은 위에서 아래로 순차적으로 흐릅니다. 반면 자식 고루틴의 흐름(보라색)은 위에서 시작했지만 myfunc
메소드의 바디로 흘러가버립니다. 보라색에 흐름에 한해서 구조적 프로그래밍이 지켜지지 않은거죠.
조금 극적인 효과를 위해 Go 언어의 코드를 예시로 사용했지만 사실 다른 동시성 프로그래밍 도구들도 마찬가지입니다. 동시성 로직의 흐름은 기존 코드의 구조적 흐름을 지키지 못하고 이는 곧 기존 goto 의 문제점을 그대로 다시 가져오게 되는 꼴입니다. Future, Promise, Callback 등.. 크게보면 위 흐름과 다르지 않습니다. 모두 위처럼 구조적 프로그래밍을 해치죠.
이젠 없어진 줄 알았던 goto 는 go 라는 이름으로(동시성 프로그래밍이라는 이름으로) 버젓이 살아있었습니다.
nursery
하지만 아무리 동시성 코드가 순차적 구조를 해친다고 한들 동시성을 완전히 배제하고 프로그래밍할 순 없습니다. 컴퓨팅 자원을 최대한 활용하고 가능한 비동기적으로 코드를 구현해서 프로그램의 성능을 높여야죠.
다익스트라가 주장했던 순차적이고 구조적인 프로그램 구조를 지키면서 동시성 프로그래밍을 구현할 수는 없는걸까요?
이에 대한 대답으로 파이썬의 비동기 I/O 라이브러리 trio 를 개발한 Nathaniel J. Smith 는 nursery
라는 개념을 제안합니다. nursery
의 핵심을 간추려보면 아래와 같습니다.
nursery
안에서 시작한 동시성 흐름은 반드시 해당nursery
안에서 끝납니다.- 흐름이
nursery
안에서 여러갈래로 나뉘더라도 그 나뉘었던 흐름은nursery
블록이 끝나는 시점에는 반드시 다시 합쳐짐(join up)을 보장합니다. 아래 그림처럼요.
nursery
블록 내부에서 얼마나 많은 비동기 코드가 생성되었든 간에 블록이 끝나는 시점에서 모든 흐름은 다시 제자리로 돌아옵니다. 별거아닌 것 처럼 보여도 이는 기존 비동기 도구들과는 굉장히 큰 차이입니다. 이 블록 덕분에 기존 goto
혹은 비동기 코드의 가장 큰 문제였던 추상화의 어려움이 해결됩니다.
사용자는 굳이 nursery
블록을 들춰볼 필요가 없습니다. 블록안에서 여러갈래로 갈라졌던 코드의 흐름은 결국엔 다시 본래 흐름으로 돌아와서 아래로 흐를것이기 때문이죠. 기존처럼 알 수 없는 어딘가로 흘러버리는 일이 없는겁니다.
파이썬에서 nursery
를 실제로 활용하면 어떤 모습일지 코드로도 한번 확인해봅시다.
nursery
블록을 생성한 후 이 블록 내부에서 nursery
객체의 start_soon
메소드로 비동기 로직을 실행합니다.
위 코드의 흐름을 시각화 해보면 위처럼 표현할 수 있겠네요. 앞서 그렸던 흐름과 거의 동일하죠? nursery 블록은 내부에서 시작한 모든 작업이 끝나기 전까지는 종료되지 않습니다. 자식 task 가 모두 끝날 때 까지 nursery 는 기다립니다.
go 를 비롯한 기존의 비동기 도구들로 위 흐름을 구현할 수 없는건 아닙니다. 여러 비동기 task 를 만들고 이 작업들이 끝날 때 까지 기다리는 코드정도는 충분히 작성할 수 있죠. 하지만 선택적으로 할 수 있게 해놓은 것과 그렇게 할 수 밖에 없도록 강제해놓은 것은 아주아주 큰 차이입니다.
앞서 계속 언급했던 nursery
블록의 특성은 동시성 코드를 작성함에 있어서 큰 변화를 가져왔습니다. 기존의 동시성 프로그래밍 도구를 활용한 코드들을 따라다닌 묘한 불편함(에러 전파, 리소스 정리), 기존 동기적인 코드를 읽을 때처럼 술술 읽히지 않는 비동기 코드 특유의 지저분함과 난잡함. trio 는 이를 모두 없애고자 탄생했고 굉장히 성공적인 시도였습니다.
Structured Concurrency
비로소 우리는 동시성(concurrency) 코드를 구조적으로(structured) 작성할 수 있게 되었습니다. 그리고 이 새로운 동시성 프로그래밍 패러다임을 Structured Concurrency 라고 부릅니다.
Kotlin Coroutine
코틀린은 이 패러다임을 적극적으로 받아들였고 코루틴에 그대로 녹여냈습니다. 코루틴의 주요 컴포넌트 중 trio 의 nursery
와 거의 동일하게 동작하는 컴포넌트가 하나 있죠. 바로 CoroutineScope
입니다. CoroutineScope
또한 자식 scope 가 모두 끝나기 전까지는 부모 scope 가 종료되지않는 중요한 특징이 있죠. 이 특징에 대한 설명은 한 줄의 글보단 예시 코드를 보는게 더 좋겠네요.
fun main() = runBlocking {
doHello()
doWorld()
println("Done")
}
suspend fun doHello() = coroutineScope { // this: CoroutineScope
println("Hello start")
launch {
delay(2000L)
println("hello 1")
}
launch {
delay(1000L)
println("hello 2")
}
println("Hello end")
}
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
println("World start")
launch {
delay(2000L)
println("World 1")
}
launch {
delay(1000L)
println("World 2")
}
println("World end")
}
/*
>>
Hello start
Hello end
hello 2
hello 1
World start
World end
World 2
World 1
Done
*/
제일 바깥 runBlocking 으로 생성된 부모 scope 내부에 두 개의 자식 scope 가 생성되었고 doHello() scope 가 끝나기 전까지는 doWorld() scope 가 실행되지 않을 걸 볼 수 있습니다.
그리고 코틀린 공식 문서에서도 CoroutineScope
를 설명하면서 structured concurrency 를 직접적으로 언급하고 있습니다.
Coroutines follow a principle of structured concurrency which means that new coroutines can only be launched in a specific CoroutineScope which delimits the lifetime of the coroutine.
In a real application, you will be launching a lot of coroutines. Structured concurrency ensures that they are not lost and do not leak.
코틀린의 코루틴은 structured concurrency 라는 비교적 새로운 동시성 패러다임을 수용했습니다. 이는 선조격인 파이썬 trio 라이브러리에서 처음으로 도입한 개념입니다. trio 이전의 동시성 도구들은 동시성만 생각한 나머지 코드를 구조적으로 프로그래밍하기 힘들도록 만들었습니다. 이는 곧 코드를 작성하는것도, 읽는것도 힘든 상황을 가져왔죠.
Structured concurrency 는 구조적 특성을 그대로 살린 동시성 프로그래밍을 가능하도록 만들었습니다. 동시성 로직이 영향을 끼치는 명확한 범위, 그리고 이것간의 부모와 자식 관계를 규정했습니다. 이는 아래와 같은 기존 동시성 도구의 문제점을 해결했습니다.
- 자원 누수 방지
- 오류 처리
- 작업 취소(중단) 처리
- 가독성 향상 & 유지보수 용이
정리
현대 고수준 언어들에서 주로 쓰이는 동시성 도구들은 대부분 옛날옛적에 악명높았던 goto 명령어가 가지고 있던 문제점을 거의 그대로 물려받았습니다. 겉모습이 다를 뿐 속내는 비슷했죠. goto 가 그 문제점 때문에 자연스레 없어지고 조건문과 반복문을 통한 구조적 프로그래밍으로 대체되었던 것 처럼 동시성 도구도 개선이 필요했습니다. structured concurrency 는 이 문제를 해결하고자 고안되었고 코틀린의 코루틴은 이 개념을 도입했습니다(CoroutineScope). 코루틴으로 동시성 코드를 작성할 때 기계적으로 작성하기보다 structured concurrency 라는 무기를 적극적으로 활용해서 기존에는 할 수 없었던 구조적인 동시성 코드를 작성하려고 노력해보면 좋겠습니다.