[코틀린] 그래서 코루틴이 뭔데?
코틀린을 이용해서 안드로이드 앱 개발을 하다 보면 비동기 프로그래밍을 위해서 코루틴을 흔하게 사용하게 됩니다. 또 코틀린으로 스프링 프레임워크를 이용해서 서버를 구축할 때도 코루틴을 자주 사용하게 되지요.
그런데 코루틴에 대해 정말로 자세히 알고 있는 사람은 많지 않습니다.
코루틴은 간단하게 사용할 수 있지만 깊고 자세하게 알고 있는 사람은 잘 없는데, 그만큼 코루틴을 쉽게 사용할 수 있도록 잘 만들었다는 것이겠죠?
아무튼 코루틴에 대해 자세히 알아보겠습니다.
코루틴(Coroutine)
코루틴은 일단 비동기 프로그래밍 및 동시성 작업을 효율적으로 다룰 수 있는 기능을 제공하는 라이브러리와 언어 기능입니다.
사실 코루틴은 코틀린에 종속적인 기술은 아니고 C#, JS, Python, Go 같은 언어에서도 지원한다고 합니다.
코루틴은 Co(함께) + Routine(규칙적인 일의 순서, 작업의 집합) 으로 나누어 볼 수 있습니다. 루틴은 하나의 태스크나 함수 정도로 생각하면 됩니다.
즉, 코루틴은 함께 동작하며 규칙이 있는 작업의 집합. 협력하는 함수들이라고 생각하면 됩니다.
위키 백과에서는 코루틴을 아래처럼 표현하고 있습니다.
실행의 지연, 일시 중단(suspend)과 재개(resume)을 허용함으로써 비선점적, 협력적 멀티태스킹(non-preemptive multitasking)을 위한 서브루틴(subroutine)을 일반화한 컴퓨터 프로그램 구성 요소
코틀린의 코루틴의 가치
코틀린의 코루틴을 사용하면 비동기 처리가 매우 쉽게 이루어 질 수 있습니다. 구글의 대표 샘플 예제 앱의 비동기 처리도 코루틴으로 바꾸어서 깃허브에 올려놓은 것을 확인할 수 있습니다. 즉, 구글도 비동기 처리의 방법으로 코루틴을 추천하고 있는 것이죠.
코루틴의 사용으로 얻을 수 있는 이점은 아래와 같습니다.
- 비동기 코드를 더 간결하고 가독성 좋게 작성할 수 있다.
- 콜백 지옥(callback hell)을 피할 수 있다.
- 스레드 관리에 따른 복잡성을 줄일 수 있다.
- 스레드 내의 컨텍스트 스위칭이 없어 스레드와 메모리 사용이 줄어든다.
위 이유는 글을 읽어 나가면서 하나씩 이해할 수 있을 것입니다.
이제부터 코루틴을 세가지 키워드로 천천히 알아봅시다.
- 루틴
- 비선점형(협력형) 멀티태스킹
- 동시성 프로그래밍 지원
루틴(Routine) vs 코루틴(Coroutine)
위에서 루틴을 하나의 태스크, 함수 정도로 보면 된다고 했습니다.
루틴에는 메인 루틴과 서브 루틴이 존재합니다.
fun main() { // 메인 루틴(Main routine)
...
val someVal = subRouFunc(value)
...
}
fun subRouFunc(value: Int) { // 서브 루틴(Sub routine)
val one = 1
val result = value + one
return result
}
위 `main` 함수가 말 그대로 메인 루틴입니다. 이 메인 루틴은 서브 루틴인 'subRouFunc` 을 호출합니다.
우리가 매일 작성하는 프로그램은 흔히 위처럼 되어 있습니다.
이 서브 루틴은 대략 아래처럼 동작합니다.
위처럼 서브 루틴은 루틴에 진입하는 지점(entry point)과 빠져나오는 지점이 명확합니다.
메인 루틴이 서브 루틴을 호출하면 서브 루틴이 종료가 되어야 서브루틴을 빠져나옵니다.
진입점과 탈출점 사이에 스레드는 블락되어 있습니다.
하지만 코루틴은 다릅니다!
코루틴인 루틴에 진입할 때 진입점도 여러 개일 수 있고, 탈출점도 여러 개일수 있습니다.
즉, 코루틴 함수는 꼭 함수가 완전히 종료되지 않아도 탈출할 수 있고, 또 그 지점으로 진입도 할 수 있는 것입니다.
비선점형 vs 선점형 멀티 태스킹
비선점형과 선점형은 아래와 같은 차이를 가집니다.
- 선점형 멀티태스킹
하나의 프로세스가 다른 프로세스 대신에 CPU(코어) 를 차지할 수 있다. (쓰레드)
보통 OS 가 스레드의 작업을 CPU 에 할당하는데 이를 선점형 멀티태스킹이라고 한다.
스레드의 작업이 스케줄링될 때는 Context Switching 이 일어난다.
- 비선점형 멀티태스킹
하나의 프로세스가 CPU 를 할당받으면 종료되기 전까지는 다른 프로세스가 CPU 를 강제로 차지할 수 없다. (코루틴)
동시성(병행성) vs 병렬성
말이 굉장히 비슷해서 굉장히 헷갈리실 텐데요.
- 동시성(병행성, Concurrency)
논리적으로 병렬로 작업이 실행되는 것. 동시에 실행되는 것처럼 보이는 것!
실제로는 시분할(time-sharing)으로 CPU 를 나누어서 사용한다.
마치 한 손으로 두 종이에 번갈아 가면서 그림을 그려서 두 개의 그림을 완성하는 것.
- 병렬성(Parallelism)
물리적으로 병렬로 작업이 실행되는 것.
두 손으로 두 종이에 실제로 동시에 그림을 그려서 두 개의 그림을 완성하는 것.
코루틴은 동시성(Concurrency)은 제공하지만, 병렬성(Parallelism)은 제공하지 않습니다.
함수(작업)를 중간에 빠져나왔다가 다른 함수에 진입하고 다시 돌아와 멈췄던 부분부터 다시 시작하는 동시성 프로그래밍을 지원합니다.
코루틴은 스레드와 비슷해서 경량 스레드라고도 불립니다. 하지만 스레드인 것이 아닙니다!
스레드는 스레드 간에 Context Switching 에 의해 비용이 많이 듭니다.
그리고 하나의 스레드에서는 여러 코루틴이 존재할 수 있습니다.
하나의 코루틴이 일시 중지(suspend) 되면 기본적으로 현재 스레드에서 재개(resume) 할 다른 코루틴을 찾습니다. 즉, 이 경우에는 스레드 간의 Context Switching 비용이 들지 않는 것이죠.
물론 코루틴이 일시중지 되었을 때 개발자가 명시적으로 스레드를 바꾸도록 만들 수도 있습니다.
그렇다면 이제 예제를 봅시다.
버스 티케팅 예제
버스 티케팅 - 동기
이에 대해서 비동기 처리 예제 중 유명한 버스 티켓팅 예제를 살펴보도록 합시다. (이 예제의 출처)
시나리오는 아래와 같습니다.
- 버스 티케팅을 하기 위해 줄에 서서 기다린다.
- 내 차례가 되면 티켓을 구매한다.
- 버스를 타기 위해 기다린다.
- 버스가 오면 버스에 탑승한다.
fun main() {
linedUp()
ticketing()
takeTheBus()
}
fun linedUp() {
println("lined up")
Thread.sleep(2000)
}
fun ticketing() { println("ticketing") }
fun takeTheBus() {
println("waiting the bus")
Thread.sleep(2000)
println("take the bus")
}
이렇게 각각의 서브 루틴들 사이의 관계는 계층적이고 직렬적인 관계가 됩니다.
버스 티케팅 예제 - 비동기(Thread 사용)
그렇다면 위 예제를 Thread 로 비동기 처리해보겠습니다.
시나리오는 아래와 같습니다.
- 버스 티켓팅을 위해 줄을 선다.
- 내 차례가 되기 전까지는 음악을 듣는다.(기다리는 동시에 음악 듣기)
- 내 차례가 되면 음악을 정지하고 티켓을 구매한다.
- 버스가 올 때까지 기다린다.
- 버스가 올 때까지 음악을 듣는다.(기다리는 동시에 음악 듣기)
- 버스가 오면 음악을 중지하고 버스에 탄다.
fun main() {
asyncLinedUp() {
stopMusic()
ticketing()
asyncTakeTheBus {
stopMusic()
}
asyncPlayMusic()
}
asyncPlayMusic()
}
fun asyncLinedUp(myTurn: () -> Unit) {
Thread {
println("lined up")
Thread.sleep(2000)
myTurn.invoke()
}.start()
}
fun asyncTakeTheBus(onTime: () -> Unit) {
Thread {
println("waiting the bus")
Thread.sleep(2000)
onTime.invoke()
println("take the bus")
}.start()
}
var playingMusic = false
fun asyncPlayMusic() {
Thread {
println("play music")
playingMusic = true
while(playingMusic) {
println("listening..")
Thread.sleep(500)
}
}.start()
}
fun stopMusic() {
playingMusic = false
println("stop music")
}
이렇게 되면 코드가 더 복잡해졌죠? 이렇게 단순한 동작을 수행하는데도 코드만 보고서 동작이 어떻게 되는지 벌써 살짝 헷갈리기 시작합니다.
이렇게 단순 thread 와 callback 을 이용한 비동기 처리는 문제가 있습니다.
1. 코드의 복잡성 문제
각 루틴들은 독립적인 thread 안에서 동작합니다. 그림을 어떻게 그려야 할지도 모르겠네요..
그런데 루틴은 진입점과 탈출점이 있다고 했죠? 각 루틴들이 서로에게 영향을 주려면 thread 사이에 통신이 필요하게 됩니다.
이것은 코드를 복잡하게 만들고 관리하기도 힘들게 합니다.
또 thread 와 callback 구조는 코드 상으로 흐름을 파악하기가 쉽지 않습니다.
2. 비용 문제
이 비용 문제는 위에서 선점형 멀티태스킹에서 다루었습니다. 다시 자세히 설명하겠습니다.
thread 는 보통 OS 에서 할당하고 관리를 합니다.
OS 는 thread 들의 작업이 적절히 분배하기 위해 CPU(코어)에 각각의 태스크들을 적절하게 할당, 회수 작업을 하게 됩니다.
이렇게 OS 에 의해서 작업이 할당되는 것을 선점형(preemptive) 멀티태스킹이라고 합니다.
OS 가 각 thread 의 작업을 스케줄링할 때는 Context Swithing 이 일어납니다. (스레드의 컨텍스트 스위칭은 프로세스의 컨텍스트 스위칭보다는 효율적이지만 이 또한 OS 수준에서 관리되며 컨텍스트 스위칭 비용이 많이 든다.)
스레드를 많이 생성하게 되면 결국 많은 리소스를 소비하게 되어 전체적인 프로그램의 성능이 저하될 수 있습니다.
위 문제에 대한 많은 방법들이 등장했고, 우리의 코루틴 또한 아주 훌륭한 기능입니다
버스 티케팅 예제 - 비동기(코루틴 사용)
코루틴의 정의를 살펴볼 때 비선점형(혐력적) 멀티태스킹을 한다고 했죠?
코루틴에서는 OS 가 thread 들의 작업을 스케줄링하지 않고 서브 루틴간의 상호 작용을 통해 언어적으로 스케줄링할 수 있습니다. 개발자가 작업을 직접 스케줄링할 수 있는 것이죠.
코루틴으로 작성한 코드를 봅시다.
fun main() {
runBlocking {
val lineUp = launch { coroutineLinedUp() }
val playMusicWithLinedUp = launch { coroutinePlayMusic() }
lineUp.join()
playMusicWithLinedUp.cancel()
coroutineTicketing()
val waitingBus = launch { coroutineWaitingTheBus() }
val playMusicWithWaitingBus = launch { coroutinePlayMusic() }
waitingBus.join()
playMusicWithWaitingBus.cancel()
coroutineTakeTheBus()
}
}
suspend fun coroutineLinedUp() {
println("lined up")
delay(2000)
}
fun coroutineTicketing() {
println("ticketing")
}
suspend fun coroutineWaitingTheBus() {
println("waiting the bus")
delay(2000)
}
fun coroutineTakeTheBus() {
println("take the bus")
}
suspend fun coroutinePlayMusic() {
println("play music")
while(true) {
println("listening..")
delay(500)
}
}
여기서는 간단하게 표현하기 위해 `runBlocking`, `launch`, `join` 함수만 사용했습니다. 이 함수들에 대해서 살짝만 설명하겠습니다.
- `runBlocking`
현재 스레드를 block 하는 코루틴을 생성하는 함수.
현재 스레드는 `runBlocking` 내의 작업이 완료되기 전에는 다른 작업을 할 수 없다. 그래서 UI 스레드에서 직접 호출하지 않고 main 함수나 테스트 코드에서 사용됨. 백그라운드에서 사용하는 것이 좋음.
블록 내의 마지막 표현식의 결과가 리턴값임.
- `launch`
현재 스레드에 대해 blocking 없이 실행되는 코루틴을 생성함. 즉, 현재 스레드에 다른 작업을 할당할 수 있음.
- `join`
코루틴이 종료될 때까지 대기함. 즉, 이 코드 아래에 있는 다른 코루틴은 실행되지 않음.
위 코드에서 `runBlocking' 새로운 코루틴을 생성하고 메인 스레드를 잡고 있으며 작업이 완료될 때까지 프로그램이 종료되지 않도록 합니다. 메인 루틴이 되는 것이죠.
메인 루틴은 `lineUp` 과 `playMusicWithLinedUp` 이라는 루틴을 수행합니다. 그리고 이 루틴은 코루틴이죠. 즉, 위 그림처럼 `runBlocking` 이라는 코루틴 안에서 여러 코루틴을 만든 것입니다.
`lineUp.join()` 에 의해 그 아래에 `lineUp` 코루틴이 완료될 때까지 메인 루틴은 더 진행되지 않고 일시정지(suspend)됩니다. `lineUp` 이 완료되면 다시 메인 루틴이 재개되죠.
그 후에는 음악을 중지시키고 티케팅을 한 후에 다시 티케팅을 기다리는 동작과 비슷하게 버스를 기다리며 음악을 듣습니다.
티케팅을 하는 구조만 그림으로 그려보면 아래와 같습니다.
이처럼 코루틴을 이용해서 루틴과 루틴 간의 관계 정의만으로 동시성이 보장되는 비동기 프로그래밍이 완료되었습니다.
코루틴에서는 비동기적으로 루틴을 실행할 수 있으며, 루틴에서 실행되는 작업들을 중간에 일시정지하고 임의의 시점에 재개할 수 있었습니다.
정리하면 `lineUp` 코루틴이 실행되어 동작하는 도중에 `playMusicWithLinedUp` 코루틴을 실행했고, 메인 루틴인 코루틴에서 `lineUp` 작업이 끝날 때까지 작업들을 일시정지하고, `lineUp` 작업이 끝나면 다시 재개한 것입니다.
이를 통해 코드는 더 읽기 쉬워졌으며, 개발자가 정의한 모든 서브 루틴을 같은 context(위 예에서는 메인 스레드) 에서 실행할 수 있게 합니다.
물론 언어적으로 각 루틴들의 스케줄링을 위한 스레드를 할당하여 사용하고 있을 수 있다.
이렇게 코루틴은 루틴과 루틴 간의 관계를 정의하고 정의된 관계에 따라 스케줄링을 언어 레벨에서 해주어 코드를 더 명확히 하고 context switching 비용을 줄일 수 있습니다.
'함수형 코틀린 - 마리오 아리아스' 이라는 아래 구문이 나옵니다.
2,000 개 미만의 스레드에는 1.5GB 이상의 메모리가 필요하다. 100만 개의 코루틴은 700MB 미만의 메모리가 필요하다. 결론은 코루틴은 매우, 매우 가볍다는 것이다.
RxKotlin 은 어떤데?
안드로이드 프로그래밍에서 Reactive 한 동작을 설계할 때도 굉장히 많이 쓰이는 것이 RxKotlin 입니다.
이 또한 사실 굉장히 편하고 기능을 많이 제공합니다. 또 읽기에도 쉽죠.
하지만 코루틴을 사용하면 더 가독성 좋은 코드를 만들 수 있습니다.
그렇다면 이제 다른 예제로 코드를 비교해봅시다.(약간의 각색이 들어감) 이 예제 코드 출처
아침에 침대에서 일어나 학교 도서관에 자리를 발권하기까지의 과정입니다.
- 7시 기상
- 샤워하기
- 옷 입기
- 출발하기
- 도서관 도착하기
- 도서관 좌석 발권하기
RxKotlin 이전에 callback 으로 구현해봅시다.
callback 으로 구현
fun goLibrary(person: Person) {
val 잠자는나 = person
wakeUp(잠자는나) { 비몽사몽한나 ->
takeShower(비몽사몽한나) { 씻은나 ->
putOnShirt(씻은나) { 옷입은나 ->
walkToLib(옷입은나) -> { 출발한나 ->
getSeat(출발한나) -> { 좌석을발권한나
val 공부준비된나 = finish(좌석발권한나)
공부준비된나.doStudy()
}
}
}
}
}
}
콜백으로 비동기 처리를 구현했을 때 흔히 보이는 콜백 헬(callback-hell)입니다.
에러 처리도 전인 코드인데도 코드가 보기 싫네요.
이번에는 RxKotlin, 혹은 RxJava 로 짠 동일한 코드를 봅시다.
RxKotlin 으로 구현
fun goLibrary(person: Person) {
val 잠자는나 = person
Observable
.just(person)
.observeOn(MAIN_Thread)
.subscribeOn(IO_Thread)
.flatMap { 잠자는나 -> wakeUp(잠자는나) }
.flatMap { 비몽사몽나 -> takeShower(비몽사몽나) }
.flatMap { 깨끗한나 -> putOnShirt(깨끗한나) }
.flatMap { 옷입은나 -> walkToLib(옷입은나) }
.flatMap { 출발한나 -> getSeat(출발한나) }
.subscribe( { 공부준비된나 ->
공부준비된나.doStudy()
}, {
실패했을때에러처리()
})
}
`wakeUp`, `takeShower()` 등의 함수들이 동일한 depth 를 유지해서 동기적인 코드처럼 보이기 때문에 훨씬 보기가 편해졌습니다.
또한 Rx 는 유용하고 많은 함수들을 지원합니다. 코루틴에 비해 제공하는 함수가 훨씬 많죠.
하지만 Rx 는 러닝커브가 굉장히 높습니다.
Rx 를 잘 모르는 사람이라면 `Observable`, `just` 등의 코드를 잘 이해하지 못할 것입니다.
마지막으로 코루틴으로 구현한 경우를 봅시다.
코틀린의 코루틴으로 구현
suspend fun goLibrary(person: Person) {
val 잠자는나 = person
try {
val 비몽사몽한나 = wakeUp(잠든나)
val 깨끗한나 = takeShower(비몽사몽한나)
val 옷입은나 = putOnShirt(깨끗한나)
val 출발한나 = walkToLib(옷입은나)
val 좌석을발권한나 = getSeat(출발한나)
좌석을발권한나.doStudy()
} catch (e: Exception) {
실패했을때예외처리()
}
}
비동기를 처리하는 코드라고는 생각이 듣지 않을 정도로 코드가 깔끔해보입니다.
각 함수들은 분명히 비동기 작업들이지만 결론적인 동작의 순서는 정확히 지켜집니다.
`goLibrary` 함수 내에서의 각 단계는 다른 코루틴으로 일시 중단될 수 있고, 해당 코루틴이 완료되면 다음 단계로 진행됩니다.
여기서는 `goLibrary` 루틴 내의 루틴과 루틴간의 관계 정의에 의해 결론적으로 순서대로 진행되는 것입니다.
그렇다면 Rx 와 코루틴 중 어떤 것을 사용해야 할까요?
당연히 상황에 따라 다릅니다. 이미 Rx 로 대부분의 기능이 구현된 서비스에는 Rx 를 적용해야 할 것이고, 새 서비스를 빠르고 가독성이 좋은 코드로 구현해야 한다면 코루틴을 사용할 수 있겠죠.
경력이 오래된 개발자들이 Rx 의 다양한 함수를 활용해서 개발을 해야하는 서비스라면 Rx 를 적용하고 그렇지 않다면 코루틴을 사용할 수 있습니다.
결국은 두 가지 다 알아야 하겠네요..
정리
- 코루틴은 실행의 suspend, resume 을 허용해서 비선점적 멀티태스킹을 한다.
- 코루틴을 사용하면 각 루틴 간의 관계 정의만으로 비동기 처리를 쉽게 수 있다.
- 코루틴으로 콜백 지옥을 벗어나며 가독성이 좋은 코드를 작성할 수 있다.
- 스레드 간의 Context Switching 이 일어나지 않아서 스레드, 메모리 자원을 적게 사용하고 성능이 좋다.
- 코루틴은 스레드에 비해 굉장히 가볍다.
- Rx 가 코루틴보다 안 좋은 것은 아니다. 각자 장단점이 있다.
참고 링크
https://dev.gmarket.com/82 지마켓 코루틴에 대하여
https://eocoding.tistory.com/88 동시성 프로그래밍 지원
https://wooooooak.github.io/kotlin/2019/08/25/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B0%9C%EB%85%90-%EC%9D%B5%ED%9E%88%EA%B8%B0/ 기상해서 회사 가는 예제 코드
https://tech.wonderwall.kr/articles/CoroutineDeepDive/ 원더월 티케팅 예제
https://velog.io/@cksgodl/%EC%BD%94%ED%8B%80%EB%A6%B0-%EB%82%B4%EB%B6%80%EC%86%8C%EC%8A%A4-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0 코루틴 부모-자식의 트리 형태