앞서 코루틴을 이해하기 위한 두 번의 발악이 있었지만, 이번에는 더 원론적인 코루틴에 대해서 알아보려 한다. 코루틴의 개념이 정확하게 서야 응용도 가능하기에 이번 포스팅으로 개념을 확실하게 알아갔으면 좋겠다.
인트로
코루틴은 코틀린만의 것이 아니다. 이름이 비슷해서 코틀린의 것이라고 생각할 수 있지만 파이썬, C#, Go, Javascript 등 여러 언어에서 지원하고 있는 개념이다. Javascript를 사용하고 있으면서 async await를 사용하고 있다면 이미 코루틴을 사용해본 경험이 있는것이다. 아무튼 코루틴은 새로운 개념, 새로운 기술이 아니라 프로그래밍이 세상에 나온 초창기 부터 존재하던 개념이다.
우리가 코틀린 코루틴을 공부해볼 가치가 있는 이유
앱이든 웹이든 비동기 처리가 핵심인 클라이언트 프로그래밍에서 지금까지 가장 핫한 키워드는 rx programming일 것이다. 그러나 구글이 안드로이드 공식 언어를 자바에서 코틀린으로 변경한 이후, 최근 들어 대표적인 샘플 예제들인 bluprint와 sunflower 앱의 비동기처리를 coroutine으로 바꾸었다(아직 rx로 짠 코드를 특정 브랜치로 남겨놓고 있긴하다). Rx 라이브러리를 걷어내고 코루틴으로 새로 작성한 것이다. 이와 더불어 상당히 많은 외국 자료들이 올라오고있다. 그 이유는 코루틴을 사용하면 비동기 처리가 너무나도 쉽게 이루어 질 수 있기 때문이라고 생각한다. 이런 이유 만으로 코루틴을 공부해 볼 가치는 충분하다.
코루틴이란!
이제 코루틴이 무엇인지 한번 알아보자. 코루틴을 3가지 키워드 정도로 알아보려고 한다.
- 협력형 멀티 태스킹
- 동시성 프로그래밍 지원
- 비동기 처리를 쉽게 도와줌
가장 중요한 개념은 1번, 협력형 멀티 태스킹이다. 사실 협력형 멀티태스킹에 대한 내용을 이해한다면 코루틴이란 것을 다 알게되는 것이다. 그러나 코루틴을 내 것으로 만들기 위해서는 동시성 프로그래밍과, 비동기 처리에 대한 관점에서 이해하는 것도 중요하다.
협력형 멀티태스킹
협력형 멀티태스킹을 프로그래밍 언어로 표현하자면
Co + Routine 이다.
Co라는 접두어는 “협력”, “함께”라는 의미를 지니고 있다. Routine은 하나의 태스크, 함수 정도로 생각하면 된다. 즉 협력하는 함수다. 더 진도를 나가기에 앞서 Routine에 대해서 좀 더 알아보자.
Routine에는 우리가 흔히 알고있는 main routine과 sub routine이 존재한다. 이런 단어들이 생소할 수도 있지만, 우리가 늘 작성하고 있는 코드들이다.
위의 자바 코드를 보자(이후부터는 코틀린으로 다룬다).
main
함수가 말 그대로 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()
부터 재개(resume)된다.
위의 과정에서 보았듯이 코루틴 함수는 언제든지 나왔다가 다시 들어올 수 있다. 이 대목에서 이미 눈치를 챈 분들도 있을것 같은데, 코루틴의 이런 성향은 동시성 프로그래밍과 밀접한 관계가 있다.
동시성 프로그래밍
함수를 중간에 빠져나왔다가, 다른 함수에 진입하고, 다시 원점으로 돌아와 멈추었던 부분부터 다시 시작하는 이 특성은 동시성 프로그래밍을 가능하게 한다.
동시성 프로그래밍의 개념을 잡고가자면, 병렬성 프로그래밍과 완전히 다른 개념이다. 예를 들어 양쪽에 놓여진 두 개의 도화지에 사람 그림을 각자 그린다고 가정해보자.
동시성 프로그래밍이란 오른쪽 손에만 펜을 쥐고서 왼쪽 도화지에 사람 일부를 조금 그리고, 오른쪽 도화지에 가서 잠시 또 사람을 그리고, 다시 왼쪽 도화지에 사람을 찔끔 그리고… 이 행위를 아주 빨리 반복하는 것이다. 사실 내가 쥔 펜은 한 순간에 하나의 도화지에만 닿는다. 그러나 이 행위를 멀리서 본다면 마치 동시에 그림이 그려지고 있는 것 처럼 보일 것이다. 이것이 동시성 프로그래밍이다.
병렬성 프로그래밍은 이 것과 다르다. 병렬성은 실제로 양쪽 손에 펜을 하나씩 들고서 왼쪽과 오른쪽에 실제로 동시에 그리는 것이다. 같은 시간동안 두 개의 그림을 그리는 것이다.
코루틴은 개념자체로만 보면 병렬성이아니라 동시성을 지원하는 개념이다. 위의 설명을 코드로 다시 살펴보자.
코루틴도 루틴이다. 즉 쓰레드가 아니라 일반 서브루틴과 비슷한 루틴이기 때문에 하나의 쓰레드에 여러개가 존재할 수 있다.
위의 코드에서는 메인 쓰레드에 코루틴이 두 개가 있다. 하나는 왼쪽 도화지에 그림을 그리는 코드고 다른 하나는 오른쪽 도화지에 그림을 그리는 코드다. 메인 쓰레드가 실행되면서 먼저 왼쪽 코루틴인 drawPersonToPaperA()
라는 함수를 만났다고 가정해보자. 해당 함수는 가상 코루틴 빌더인 startCoroutine {}
블럭으로 인해 코루틴이 되고, 함수를 중간에 나갔다가 다시 들어올 수 있는 힘을 얻게된다. drawPersonToPaperA()
가 호출되어 suspend
함수인 drawHead()
를 만나게 되면 이 코루틴을 잠시 빠져나간다.
왼쪽 코루틴을 빠져나갔지만 그렇다고 메인쓰레드가 가만히 놀고있진 않는다. 다른 suspend
함수들을 찾거나 resume되어지는 다른 코드들을 찾는다. 왼쪽 코루틴의 경우 2초 동안 drawHead()
작업을 하게된다. 그러나 delay(2000)
는 쓰레드를 블락시키지 않으므로 다른 일들을 할 수가 있다. 뿐만 아니라 drawHead()
함수 안에서 다른 쓰레드를 실행시킨다면 병행적으로도 실행이 가능하다. 왼쪽 코루틴을 빠져나온 쓰레드가 오른쪽 코루틴을 만나게 되어 또 한번 suspend
함수를 만나게 되면 아까 도화지 그림에서 설명한 것과 같은 현상이 일어난다. 아까 오른손에 펜을 쥐고 왼쪽과 오른쪽 도화지를 아주빠르게 왔다 갔다 하면서 그림을 그리는 것 같은 셈이다. 이렇게 코루틴을 사용하여 쓰레드 하나에서 동시성 프로그래밍이 가능하다
코루틴을 생성해서 동시성 프로그래밍을 하는 것은, 쓰레드를 사용해서 동시성 프로그래밍을 하는 것과 차원이 다른 효율성을 제공한다. 위에서 말한 동작을 쓰레드 두 개를 만들어 실행한다고 가정해보자.
왼쪽 쓰레드는 왼쪽 도화지에, 오른쪽 쓰레드는 오른쪽 도화지에 그림을 그리는 쓰레드이다. 그러나 CPU는 단 한 개뿐이다. 따라서 왼쪽에 조금, 오른쪽에 조금을 반복하기 위해선 CPU가 매번 쓰레드를 점유했다가 놓아주고, 새로운 쓰레드를 점유했다가 놓아주고를 반복해야 한다. 이를 컨텍스트 스위칭이라고 한다. 하나의 쓰레드에서 단순히 함수를 왔다 갔다 하는것과는 다르게 꽤 비용이 드는 작업이다.
아래는 “함수형 코루틴”이라는 책에나오는 구문이다.
비동기 처리가 이렇게 편해지다니
위에서 설명한 코틀린의 능력으로 비동기 처리가 굉장히 쉬워진다. 심지어는 이게 비동기 처리인가? 싶을 정도로 읽기 쉽고 짜기도 쉬운 코드를 볼 수 있다.
한 가지 예시를 들어보겠다. 필자의 아침 기상으로부터 회사에 도착하기 까지의 과정이다.
- 8시 기상
- 샤워하기
- 옷입기
- 출발하기
- 회사도착하기
- 일하기
위의 시나리오는 꼭 순서대로 이뤄져야하는 작업이다. 기상하지도 않았는데 옷을 입을수 없고, 옷을 입지 않았는데 출근을 할 수도 없다. 또한 각 과정은 시간이 오래 걸리는 작업이라고 가정하자. 코드로 치면 네트워크 처리쯤?
callback
가장 먼저 callback을 사용해 구현해보자.
콜백으로 비동기 처리를 구현했을 때 흔히 겪을 수 있는 콜백 헬이다. 심지어 에러 처리도 하지 않는 코드인데 벌써 보기가 어렵다. 비동기 처리를 위한 다양한 방법론이 나오기 전에는 대부분 이런방식으로 처리했다.
안드로이드 프로그래밍에서 공기와도 같은 존재가 된 RxJava, RxKotlin으로 짜여진 동일한 코드를 보자.
RxKotlin
콜백 코드보다 훨씬 눈에 잘 들어오는 것 같다. 왜 잘들어 올까? wakeUp()
, takeShower()
, putOnShirt()
등등의 함수들이 보통 사람이 생각하는 것 처럼 순차적으로 보이기 때문이 아닐까? 즉 각 과정의 함수들이 동일한 depth를 유지하며 동기적인 코드처럼 보이기 때문에 훨씬 보기가 편해졌다.
그런데 Rx를 모르는 사람이 이 코드를 본다면 어떨까? Observable
이 무엇인지, just
가 무엇인지, flatMap
은 무엇인지… 코드를 보면 어지러울수도 있다. 뿐만 아니라 사실 Rx가 제공하는 마법같은 operation들이 상당히 많기 때문에 학습 곡선이 꽤 높기로 유명하다.
하지만 Rx를 아는사람들 끼리는 너무나도 좋은 도구인것은 확실하며 높은 학습 곡선 만큼 효율성도 크다. 아무튼 Rx로 짜면 이런 식이다.
Kotlin + coroutine
이번엔 마지막으로 Kotlin + coroutine 조합을 보자.
“이게 비동기 처리를 하는 코드라고?”라는 생각이 들 수도 있다. 그러나 분명히 비동기 코드다! 안에서 호출되는 각 함수들은 분명히 네트워크를 타고 실행되야하는 오래걸리는 작업이고, 언제 끝날지 모르는 비동기 작업들이지만 각자 함수들의 순서는 정확히 지켜진다. takeShower()
함수는 wakeUp()
함수가 끝나야만 실행되고, putOnShirt()
함수는 takeShower()
함수가 끝나야만 실행된다.
이게 가능한 이유는, goCompany
라는 함수가 코루틴이기에 wakeUp
을 만나면 wakeUp
함수를 실행함과 동시에(여기서는 백그라운드 스레드에서 동시에 실행될 것이다.) 잠시 goCompay
를 빠져나간다. 그러다가 wakeUp
이 자신의 일을 끝마치면 다시 goCompany
로 돌아올 수 있기 때문이다. 이게 코루틴으로 비동기 처리를 할 때 생기는 장점이다.
결론
비동기 처리에 있어서 Rx는 매우 훌륭한 도구이고 나 역시 코루틴을 공부하기 전까지는 더 할나위 없는 기술이라 생각하며 사용해 왔다. 그러나 점점 생각이 바뀌었다. Rx를 사용하는 가장 주된 이유 중 하나가 비동기 처리인데, 결국은 Rx도 비동기 처리를 쉽게 하기 위한 하나의 발악이라고 볼 수 있기 때문이다(물론 데이터를 흐름으로 바라보며 처리한다는 점은 훌륭하다). 결국 발악은 발악이고 궁극적으로 비동기 처리를 위하여 여러가지 헬퍼 코드들이 덕지 덕지 붙게 된다. 반면 코루틴은 비동기 처리를 하기 위해 그런 발악, 또는 시도를 거부하는 것 처럼 느껴진다. 마치 “왜 비동기 처리 때문에 고생하니? 그냥 동기처럼 짜면 그만인데”라고 이야기하는 것 같다.
코루틴을 공부하고도 Rx를 더 선호하는 사람이 있을 수 있고, 코루틴에 푹 빠지게 되는 사람도 있을 것이다. 무엇이 더 좋고 더 나쁜가에 대한 정답은 없지만, 개인 적인 생각으로는 비동기 처리의 궁극적인 모습은 마치 비동기 코드가 아닌것 처럼 짜는 모습이 아닐까 생각한다.
Bonus
코루틴이 무엇인지, 사용하면 어떤 이점이 있는지에 대해서 글을 작성해보았다. 개인적으로 코루틴을 공부할때 한 가지 궁금했던점이 있었다. “대충 개념은 알겠는데… 어떻게 함수를 중간에 왔다 갔다 할수 있는거지?”에 대한 궁금증이었다.
이걸 가능하게 하는 방법론이라고 해야할까? 키워드라 해야할까. 아무튼 이 글에서 다루기에는 또 너무 방대한 이야기가 되므로 툭 던져놓자면 CPS(Continuatino Passing Style)이다.
끄
읕