코루틴이란 코틀린만의 것은 아니다. 이름이 비슷해서 코틀린의 것이라고
생각할 수 있지만 파이썬, C#, Go, Javascript 등 여러 언어에서
지원하고 있는 개념이다. Javascript를 사용하고 있으면서 async await를
사용하고 있다면 이미 코루틴을 사용해본 경험이 있는 것이다.
아무튼 코루틴은 새로운 개념, 새로운 기술이 아니라 프로그래밍이
세상에 나온 초창기 부터 존재하던 개념이다.
앱이든 웹이든 비동기 처리가 핵심인 클라이언트 프로그래밍에서 지금까지
가장 핫한 키워드는 RX(ReactiveX) Programming일 것이다. 그러나
구글이 안드로이드 공식 언어를 자바에서 코틀린으로 변경한 이후, 최근 들어
대표적인 샘플 예제들의 비동기 처리 방식을 coroutine으로 바꾸었다.
이와 더불어 상당히 많은 외국 자료들이 올라오고 있다. 그 이유는
코루틴을 사용하면 비동기 처리가 너무나도 쉽게 이루어 질 수 있기
때문이라고 생각한다. 이런 이유만으로 코루틴을 공부해 볼 가치는 충분하다.
이제 코루틴이란 무엇인지 한번 알아보자. 코루틴을 3가지 키워드 정도로 알아보려고 한다.
가장 중요한 개념은 1번, 협력형 멀티 태스킹이다. 사실 협력형 멀티 태스킹에 대한 내용을 이해한다면 코루틴이란 것을 다 알게되는 것이다. 그러나 코루틴을 내것으로 만들기 위해서는 동시성 프로그래밍과 비동기 처리에 대한 관점에서 이해하는 것도 중요하다.
협력형 멀티태스킹을 프로그래밍 언어로 표현하자면 Co + Routine이다.
Co라는 접두어는 "협력","함께"라는 의미를 지니고 있다. Routine은 하나의
태스크, 함수 정도로 생각하면 된다. 즉, 협력하는 함수이다.
더 진도를 나가기에 앞서 Routine에 대해서 좀 더 알아보자.
Routine에는 우리가 흔히 알고 있는 main routine과 sub routine이 존재한다. 이런 단어들이 생소할 수도 있지만, 우리가 늘 작성하고 있는 코드들이다.
위의 자바 코드를 보면 main 함수가 있고, 서브 함수인 plusOne을 호출한다. 우리가 개발하면서 자주 볼수 있는 흐름이라 어려울 것이 전혀 없다.
그런데 Sub Routine을 살펴보면 한가지 특징이 있다. 아래 그림을 보자.
Sub Routine은 루틴에 진입하는 지점과 루틴을 빠져나오는 지점이 명확하다. 즉, 메인 루틴이 서브루틴을 호출하면, 서브루틴의 맨 처럼 부분에 진입하여 return 문을 만나거나 서브루틴의 닫는 괄호를 만나면 해당 서브루틴을 빠져나오게 된다.
이번엔 코틀린 코드로 다시 살펴보자.
메인 쓰레드가 plusOne 이라는 서브루틴에 진입한다. 당연히 코드는
처음부터 진입이 되어 맨 윗줄부터 실행이 될 것이고, 그 아래 코드들을
쭉쭉 실행해서 return 문을 만나면 서브루틴을 호출했던 부분으로 탈출한다.
그리고 진입점과 탈출점 사이에 쓰레드는 블락되어있다. 우리가 늘 짜는
코드라 이해가 쉽다.
그러나 코루틴(Coroutine)은 조금 다르다.
이때까지 코루틴을 사용하지 않았다면 혼란스러운 그림일 수도 있지만 이해해보자!
코루틴도 routine이기 때문에 하나의 함수로 생각하자. 그런데 이 함수에
진입할 수 있는 진입점도 여러개고, 함수를 빠져나갈 수 있는 탈출점도 여러개다.
즉, 코루틴 함수는 꼭 return 문이나 마지막 닫는 괄호를 만나지 않더라도
언제든지 중간에 나갈 수 있고, 언제든지 다시 나갔던 그 지점으로
들어올 수 있다.
아래 코드를 보자.
drawPerson 이라는 함수가 있다. 이 함수 안에는 startCoroutine이라는 빌더가 있다고 가정해보자.
실제로 startCoroutine이라는 빌더는 존재하지 않는다. 실제 코루틴 라이브러리에는 다른방식으로 코루틴을 만들지만 여기서는 이해를 위해 쉽게 startCoroutine이라고 사용한다.
startCoroutine 이라는 코루틴을 만나게 되면 해당 함수는 코루틴으로 작동할 수 있다.
따라서 언제든 함수 실행 중간에 나갈 수도 있고, 다시 들어올 수도 있는 자격이
부여되는 것이다. 언제 코루틴을 중간에 나갈수 있을까?
suspend로 선언된 함수를 만나면 코루틴 밖으로 잠시 나갈 수 있다.
이제 순서를 따라 가보자.
쓰레드의 Main 함수가 drawPerson()을 호출하면 startCoroutine 블럭을 만나 코루틴이 된다(정확하게는 하나의 코루틴을 만들어 시작한다) 위에서도 말했듯이 이제 drawPerson()은 진입점과 탈출점이 여러개가 되는 자격이 주어진 것이다.
코루틴이 실행이 되었지만, suspend를 만나기 전까지는 그다지 특별한 힘이 없다. suspend로 정의된 함수가 없다면 그냥 마지막 괄호를 만날 때 까지 계속 실행된다. 그러나 drawHead()는 suspend 키워드로 정의되어진 함수다. 따라서 drawHead() 부분에서 더 이상 아래 코드를 실행하지 않고 drawPerson()이라는 코루틴 함수를 잠시 탈출한다.
메인 쓰레드가 해당 코루틴을 탈출했다. 그렇다고 쓰레드가 놀고 있을리는 없다. 우리가 짜 놓은
다른 코드들을 실행할 수도 있고, 안드로이드라면 UI 애니메이션을 처리 할 수도 있다. 그러나 Head는
어디선가 계속 그려지고 있다. drawHead()는 2초가 걸리는 suspend 함수였음을
기억해보자. drawHead() 라는 suspend를 만나 코루틴을 탈출했지만, drawHead() 함수의
기능은 메인 쓰레드에서 동시성 프로그래밍으로 작동하고 있을 수도 있고, 다른 쓰레드에서
돌아가고 있을 수도 있다. 그것은 개발자가 자유롭게 선택할 수 있다.(이해가 되지 않더라도
바로 뒤에서 다시 설명할 예정이다)
그렇게 메인쓰레드가 다른 코드들을 실행하다가도, drawHead()가 제 역할을 다 끝내면 다시 아까 탈출했던 코루틴 drawPerson()으로 돌아온다. 아까 멈추어 놓았던 drawHead() 아래인 drawBody() 부터 재개(resuem)된다.
위의 과정에서 보았듯이 코루틴 함수는 언제든지 나왔다가 다시 들어올 수 있다. 이 대목에서 이미 눈치를 챈 분들도 있을 것 같은데, 코루틴의 이런 성향은 동시성 프로그래밍과 밀접한 관계가 있다.
함수를 중간에 빠져나왔다가, 다른 함수에 진입하고, 다시 원점으로 돌아와
멈추었던 부분부터 다시 시작하는 이 특성은 동시성 프로그래밍을
가능하게 한다.
동시성 프로그래밍의 개념을 잡고가자면, 병렬성 프로그래밍과 완전히 다른 개념이다. 예를 들어 양쪽에 놓여진 두 개의 도화지에 사람 그림을 각자 그린다고 가정해보자.
동시성 프로그래밍이란 오른쪽 손에만 펜을 쥐고서 왼쪽 도화지에 사람 일부를 조금 그리고, 오른쪽 도화지에 가서 잠시 또 사람을 그리고, 다시 왼쪽 도화지에 사람을 찔끔 그리고… 이 행위를 아주 빨리 반복하는 것이다.
사실 내가 쥔 펜은 한 순간에 하나의 도화지에만 닿는다. 그러나
이 행위를 멀리서 본다면 마치 동시에 그림이 그려지고 있는 것처럼 보일 것이다. 이것이
동시성 프로그래밍이다.
병렬성 프로그래밍은 이 것과 다르다. 병렬성은 실제로 양쪽 손에 펜을 하나씩
들고서 왼쪽과 오른쪽에 실제로 동시에 그리는 것이다. 같은 시간동안
두 개의 그림을 그리는 것이다.
코루틴은 개념자체로만 보면 병렬성이 아니라 동시성을 지원하는 개념이다.
위의 설명을 코드로 다시 살펴보자.
코루틴도 루틴이다. 즉 쓰레드가 아니라 일반 서브루틴과 비슷한 루틴이기 때문에 하나의 쓰레드에 여러개가 존재할 수 있다.
위의 코드에서는 메인 쓰레드에 코루틴이 두 개가 있다. 하나는 왼쪽 도화지에
그림을 그리는 코드고 다른 하나는 오른쪽 도화지에 그림을 그리는 코드다.
메인 쓰레드가 실행되면서 먼저 왼쪽 코루틴인 drawPersonToPaperA()라는
함수를 만났다고 가정해보자.
해당 함수는 가장 코루틴 빌더인 startCoroutine {} 블럭으로 인해 코루틴이
되고, 함수를 중간에 나갔다가 다시 들어올 수 있는 힘을 얻게 된다.
drawPersonToPaperA()가 호출되어 suspend함수인 drawHead()를 만나게 되면
이 코루틴을 잠시 빠져나간다.
왼쪽 코루틴을 빠져나갔지만 그렇다고 메인쓰레드가 가만히 놀고 있진 않는다.
다른 suspend 함수들을 찾거나 resume되어지는 다른 코드들을 찾는다. 왼쪽 코루틴의
경우 2초 동안 drawHead() 작업을 하게된다. 그러나 delay(2000)는 쓰레드를
블락시키지 않았으므로 다른 일들을 할 수가 있다. 뿐만 아니라 drawHead() 함수 안에서
다른 쓰레드를 실행시킨다면 병행적으로도 실행이 가능하다.
왼쪽 코루틴을 빠져나온 쓰레드가 오른쪽 코루틴을 만나게 되어 또 한번 suspend 함수를
만나게 되면 아까 도화지 그림에서 설명한 것과 같은 현상이 일어 난다.
아까 오른손에 펜을 쥐고 왼쪽과 오른쪽 도화지를 아주 빠르게 왔다갔다 하면서 그림을
그리는 것 같은 셈이다.
이렇게 코루틴을 사용하여 쓰레드 하나에서 동시성 프로그래밍이 가능하다!
코루틴을 생성해서 동시성 프로그래밍을 하는 것은, 쓰레드를 사용해서 동시성
프로그래밍을 하는 것과 차원이 다른 효율성을 제공한다.
위에서 말한 동작을 쓰레드 두 개를 만들어 실행한다고 가정해보자.
왼쪽 쓰레드는 왼쪽 도화지에, 오른쪽 쓰레드는 오른쪽 도화지에 그림을 그리는 쓰레드이다. 그러나 CPU는 단 한 개뿐이다. 따라서 왼쪽에 조금, 오른쪽에 조금 반복하기 위해선 CPU가 매번 쓰레드를 점유했다가 놓아주고, 새로운 쓰레드를 점유했다가 놓아주고를 반복해야 한다. 이를 컨텍스트 스위칭이라고 하는데 하나의 쓰레드에서 단순히 함수를 왔다 갔다 하는 것과는 다르게 꽤 비용이 드는 작업이다.
아래는 함수형 코틀린 이라는 책에나오는 구문이다.
쓰레드를 이용하는 경우와 코루틴을 이용하는 경우를 아래 코드로 직접 실행하여 비교해보자.
fun main() = runBlocking {
repeat(100_000) { // launch a lot of coroutine
launch {
delay(1000L)
print(".")
}
}
}
위는 코루틴을 이용한 코드이고, 아래는 쓰레드를 이용한 코드이다.
문법이나 사용방법은 다음글에서 다룰 예정이며, 지금은
직접 실행해보며, 속도 차이를 확인해보자.
쓰레드를 10만개 또는 그 이상으로 만들게 되면, 컴퓨터 성능에 따라서
많은 부하가 생기고, out of memory도 생길 수 있지만,
코루틴은 그에 비해 훨씬 가볍다.
fun main() = runBlocking {
repeat(100_000) { // launch a lot of coroutine
thread {
Thread.sleep(1000L)
print(".")
}
}
}
위에서 설명한 코틀린의 능력으로 비동기 처리가 굉장히 쉬워진다. 심지어 이게 비동기 처리인가? 싶을 정도로 읽기 쉽고 짜기도 쉬운 코드를 볼 수 있다.
한가지 예시를 들어보자. 아침 기상으로부터 회사에 도착하기 까지의 과정이다.
위의 시나리오는 꼭 순서대로 이뤄져야하는 작업이다. 기상하지도 않았는데 옷을 입을 수 없고, 옷을 입지 않았는데 출근을 할 수도 없다. 또한 각 과정은 시간이 오래 걸리는 작업이라고 가정하자.
가장 먼저 callback을 사용해 구현해보자.
콜백으로 비동기 처리를 구현했을 때 흔히 겪을 수 있는 콜백 헬이다. 심지어 에러 처리도 하지 않는 코드인데 벌써 보기가 어렵다. 비동기 처리를 위한 다양한 방법론이 나오기 전에는 대부분 이런 방식으로 처리했다.
안드로이드 프로그래밍에서 공기와도 같은 존재가 된 RxJava, RxKotlin으로 짜여진 동일한 코드를 보자.
콜백 코드보다 훨씬 눈에 잘 들어오는 것 같다. 왜 잘들어 올까?
wakeUp(), takeShower(), putOnShirt() 등등의 함수들이 보통 사람이 생각하는
것 처럼 순차적으로 보이기 때문이 아닐까? 즉 각 과정의
함수들이 동일한 depth를 유지하며 동기적인 코드처럼 보이기 때문에 훨씬
보기가 편해졌다.
그런데 Rx를 모르는 사람이 이 코드를 본다면 어떨까? Observable이 무엇인지,
just가 무엇인지, flatMap은 무엇인지… 코드를 보면 어지러울 수도 있다.
뿐만 아니라 사실 Rx가 제공하는 마법같은 operation들이 상당히 많기
때문에 학습 곡선이 꽤 높기로 유명하다.
하지만 Rx를 아는사람들끼리는 너무나도 좋은 도구인것은 확실하며 높은 학습 곡선 만큼 효율성도 크다. 아무튼 Rx로 짜면 이런식이다.
이번엔 마지막으로 Kotlin + coroutine 조합을 보자.
“이게 비동기 처리를 하는 코드라고?”라는 생각이 들 수도 있다. 그러나
분명히 비동기 코드이다. 안에서 호출되는 각 함수들은
분명히 네트워크를 타고 실행되야하는 오래걸리는 작업이고, 언제 끝날지 모르는
비동기 작업들이지만 각자 함수들의 순서는 정확히 지켜진다.
takeShower() 함수는 wakeUp() 함수가 끝나야지만 실행되고, putOnShirt() 함수는
takeShower() 함수가 끝나야만 실행된다.
이게 가능한 이유는, goCompany라는 함수가 코루틴이기에 wakeUp을 만나면 wakeUp함수를 실행함과 동시에(여기서는 백그라운드 스레드에서 동시에 실행될 것이다) 잠시 goCompany를 빠져나간다. 그러다가 wakeUp이 자신의 일을 끝마치면 다시 goCompany로 돌아올 수 있기 때문이다. 이게 코루틴으로 비동기 처리를 할 때 생기는 장점이다.
코루틴에 대해서 살펴보다보면, 개념적으로 쓰레드와 동일하다라는
착각을 할 수 있다.
Difference between thread and coroutine in Kotlin를
참고해보자.
코루틴은 그저 하나의 쓰레드(혹은 스케줄러)위에서 실행이 시작될 수 있다.
하나의 쓰레드에 코루틴이 여러개 존재할 수가 있는데, 실행중이던 하나의
코루틴이 suspend(멈춤)되면, 현재 쓰레드에서 resume(재개)할 다른 코루틴을
찾는다.
다른 쓰레드에서 찾는게 아니라 같은 쓰레드에서 찾는것이다(물론 다른 쓰레드에서
resume할 수 있는 방법도 있다.)
따라서, 쓰레드를 switch(컨텍스트 스위치)하는데 드는 overhead가 없다.
또 하나, 위에서 말한 suspend, resume 등등을 모두 개발자가 직접 컨트롤 할 수 있다. 여러 작업을 가지고 동시성 프로그래밍을 할 때 모두 OS가 컨트롤 했던 쓰레드 방식과는 다르다.
코루틴이 무엇인지, 사용하면 어떤 이점이 있는지에 대해서 글을 작성해보았다.
개인적으로 코루틴을 공부할 때 한 가지 궁금했던점이 있었다.
어떻게 함수를 중간에 왔다 갔다 할수 있는거지? 에 대한 궁금증이
있었는데, CPS(Continuation-passing style)을
참고해보자.
다음글에서 코루틴을 직접 사용해보면서 이해해보자.
Reference
https://www.youtube.com/watch?v=ArSOSm1P9do
https://jaejong.tistory.com/61
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://myungpyo.medium.com/reading-coroutine-official-guide-thoroughly-part-0-20176d431e9d