본문 바로가기
프로그래밍/Coroutine

Kotlin Coroutine 101 영상 번역 및 요약

by Daniel.kwak 2020. 11. 26.

youtu.be/ZTDXo0-SKuU

 

Coroutine은 어떤 문제들을 해결할 수 있는가?

- 안드로이드 비동기 프로그래밍을 단순화 함. 우리가 비동기 프로그래밍을 말할 때는 동기적 프로그래밍 방식을 빼놓을 수 없다. 동기적 프로그래밍 방식으로 네트워크 콜을 메인 쓰레드에서 진행하면 onDraw() 메소드가 블럭되어서 유저는 UI Freezing 현상을 겪게 될 것. 이러한 네트워크 콜을 어떻게 메인 스레드를 블럭하지 않고 비동기적으로 만들 수 있을까? 

 

답은 콜백 방식이 있을 수 있다. (Retrofit의 enqueue)

콜백의 경우, 네트워크 콜을 다른 쓰레드에서 돌릴 수 있게 되어 메인 쓰레드를 블럭하지 않으므로 onDraw()가 잘 호출이 되고, 호출이 끝나면 콜백 함수에서 람다 구문을 실행시켜 UI를 업데이트 시킬 수 있다. 하지만 콜백의 치명적인 단점이 있는데, 유명한 콜백헬이다. 콜백 지옥이란 콜백으로 받은 데이터로 또 다른 콜을 발생시키고 그 응답으로 받은 결과로 또 콜을 발생시키는, 안티 패턴이라고 할 수 있다.

 

만약 단순한 동기적 코드와 강력한 비동기적 코드 그리고 쓰레드 스위칭이 모두 가능한게 있다면 어떨까? 이것이 코루틴이다!

 

여기서 suspend 키워드에 주목하자. 이 뜻은 코틀린 컴파일러에게 이 함수는 Coroutine 안에서 실행되어야 함을 뜻한다. 어떻게 메인 스레드를 블럭하지 않고 다른 스레드에서 Coroutine이 실행 될 수 있을까? 

- 코루틴은 메인 스레드를 블럭하는 대신에 실행을 suspend 할 수 있다. 그리고 실행할 무언가를 다른 스레드로 옮기는 행위가 일어나게 된다. 그래서 이 부분을 suspension 포인트라고 부른다. 그리고 그 네트워크 콜이 끝나면 중단되었던 지점에서 실행이 재개된다. 그래서 loadData 함수가 무슨 일을 하던, 중단된 지점에서부터 결과값과 함께 실행을 재개할 수 있다.

 

  

이 suspend 함수 내부를 자세히 보면 아이보리?색 부분은 네트워크 콜에서 콜백 부분에 해당한다. (데이터를 받아서 결과를 보여주는) 

Coroutine은 computaion이 suspend 될 때 이 콜백들을 내부 깊숙한 곳에 작성한다(?) Coroutine은 이러한 콜백들을 'Continuation'이라고 부른다. Continuation 은 콜백 인터페이스이고 suspend 함수를 아래 사진처럼 컴파일러가 변경시킨다. 

 

그리고 Contination Passing Style 이라는게 있는데, 이것은 loadData(state0- init) -> networkRequest(state1 - suspend) -> loadData(state2 - resume) -> exit(state3- exit) 순으로 상태가 변경되는데.. 사실 무슨말인지 잘 모르ㅡ겠다. 계속해서 보면 networkRequet 함수 또한 suspend 함수임을 알 수 있다. 하지만 이 때는 동기 버젼으로 데이터를 반환한다. networkRequst 함수는 어떻게 다른 스레드에서 실행 될 수 있는걸까? 그것은 또 다른 suspend 함수인 withContext 함수로 인해서 가능하다.

Dispatcher는 특정 쓰레드에서 동작을 시킬 수 있다. Dispatchers는 세가지가 있는데 Main, IO, Default가 있다. IO는 네트워크 콜과 디스크 I/O 작업에 사용하고 Default는 계산량이 많은 작업, Main은 메인 스레드에서 블럭되지 않는 작업흘 할 때 쓴다. 

 

그래서 우리는 첫번째 질문을 돌아가서, Coroutine이 Android에서 비동기 프로그래밍을 효율적으로 가능하다는것을 알게 되었다.

 

그럼.. Coroutine이라는게 대체 뭘까?

초강력 슈퍼파워(...)이고 어떤 한 블록의 코드를 다른 쓰레드로 돌릴 수 있는 것을 말한다. 

 

그리고 비동기 프로그래밍을 순차적으로 가능하게 하여 읽고 이해하기 쉽다. 또한 예외처리와 cancellation도 쉽다. 다시 loadData suspend 함수로 돌아와보면, 

loadData함수는 suspend 함수이고, 이 함수가 Coroutine에서 실행되지 않았으므로 컴파일 에러가 발생한다. Coroutine을 생성하는 방법에 대해서는 나중에 알아보기로 하고 일단 launch 함수로 블록을 만들었다고 가정해보자. 이 때 누가 이 Coroutine을 취소시킬 수 있을까? 

유저가 폰 화면을 껏다고 해서 취소가 되는걸까? 특정한 라이프사이클이라도 따르는 것일까? 이러한 문제는 Strucrured concurrency 측면에서 풀어보고자 한다. 

 

Structured concurrency는 Coroutine의 디자인 시스템이며 Memory Leack을 해결하고자 한다. 이러한 개념들은 Scope이라는 새로운 개념을 통해 알아보자. 

- 생성한 Coroutine을 쭉 추적하고 

- 개발자로 하여금 취소할 수 있게 하며 

- 예외가 발생했을 시 notify를 준다. 

 

그러면 Scope은 어떻게 만들까? 굉장히 저렴한 비용으로 만들 수 있다. 

또한 Scope은 loadData에서 에러가 발생하면 handle 할 수 있으며 buttonClicked 함수 안의 coroutine은 scope의 라이프사이클에 따른다.  

이 경우는 scope이 부모 Coroutine이 되며, scope을 launch 한 coroutine은 자식 coroutine이 된다. 그래서 만약 ViewModel 안이라면, onCleared 콜백 안에서 scope.canel()을 호출하여 실행중인 모든 자식 Coroutine들을 취소시킬 수 있다. 

취소된 Coroutine은 다시 시작시킬 수 없다. 

다시 loadData 함수로 돌아와서, suspend 키워드가 붙은 함수는 반드시 Coroutine의 Scope 안에서 실행되어야 하고, 이는 동기적인 콜과 마찬가지로, Scope 안에서 실행되는 suspend 함수가 return 되면, 그것은 suspend 함수의 모든 작업이 완료됐음을 의미한다.

 

이제 예외처리 부분을 살펴보자.   

Scope은 job을 가질 수 있는데(CoroutineContext이다), job은 scope과 Coroutine 라이프사이클을 독립적으로 정의할 수 있다. 

이렇게 Job을 CoroutineScope에 넘긴다는 것은, 이 scope의 예외처리를 특별히 다르게 하겠다는 의미이다. 좀 더 자세히 살펴보자.

 

Scope 안에 한 child 가 실패했을 때, 다른 child들도 취소시킬 뿐만 아니라 scope은 Exception 을 받게 된다. 우리의 예제 코드에서는 만약 loadData가 실패한다면 scope에 실패했다고 알리게 되고 별다른 예외처리를 하지 않았다면 크래쉬가 발생한다. 그러나 이는 우리가 원하지 않는 상황일 것이다. 

 

 

이런 상황에서 우리는 그냥 Job이 아닌 SupervisorJob을 사용할 수 있다. SupervisorJob을 Scope에 넘겨주면 하나의 Child가 실패했다고 하더라도 다른 child에게 영향을 미치지 않는다. 그러나 다른 child를 취소시키지 않을 뿐 여전히 Exception 은 전달을 받으므로 이를 처리해주어야 한다. 

 

아래는 요약본이다.

 

이제 Coroutine을 생성하는 또 다른 방법이 대해서 알아보자. 지금까지 봤던 Launch 방식와 Async 방식이 있는데 차이점을 살펴보자.

공통점으로는 둘 다 새로운 Coroutine을 생성하여 suspend function 을 시작할 수 있다는 점이다. 하지만 Coroutine을 생성하는 목적이 다르다.

 

Launch는 한 번 생성하면 잊혀진다. 어떠한 로그를 서버에 업로드 하는 작업을 생각해보면, 우리는 그 작업을 돌려놓고 그냥 신경쓰지 않으면 된다. 

Async 방식은 새로운 Coroutine을 생성하고, 그 값을 반환하는 것을 기다린다! Async는 deferred라는 자바의 future or Promise 객체와 비슷한 걸 return 하고 deferred 객체에서 await를 호출할 수 있는데, suspend function 이 동작을 완료하고 값을 반환할 때 까지 기다리게 되고 값을 Coroutine이 반환하는 값을 가질 수 있게 된다. 

 

나머지 부분은 모두 동일하며, 또 한가지 차이점이라면 에러 핸들링 부분이다. Launch는 Exception이 발생하면 throw하지만, async는 Exception이 발생하더라도 await를 호출하기 전까지는 hold하고 있는다. 이제 예외처리 부분을 살펴보자.

 

기본적인 예외처리 방법은 try-catch 방식으로 감싸는 것이다.

 

Launch 방식에서는 launch 구문 안에서 try-catch 로 감싸서 error를 캐치할 수 있다. 좋은 방법은 아니지만, 확실하다. 

 

async 방식에서는, 앞에서 얘기했듯이 async 내부에서는 에러 핸들링을 할 필요 없고 실질적인 값을 받아오는 await 구문에서 try-catch 작업을 해주면 된다. 예외처리 부분은 앞서 포스팅 한 부분을 더 자세히 참고해보자.

 

이제 cancellation을 얘기해보자. 

 

 

이런 코드가 있을 때 중간에 scope.cancel을 호출하면 Coroutine의 실행이 취소될까 아니면 무시될까? 

 

결론은 취소되지 않는다(?)는 것이고, 이유는 cancellation은 co-operation이 필요한 작업이기 때문이다. 이런 readFile 과 같은 무겁고 expensive 한 작업은 너무 busy 하여 cancellation을 듣기가 어렵기 때문에 협력을 해야 하고, 해당 Coroutine이 취소가 됐으면 내부에서 현재 Coroutine이 active한지 체크를 해야한다. 결론은 heavy한 작업을 Coroutine에서 진행한다면, cancellation 체크를 꼭 해야 한다는 것이다. 

 

이제 다른 토픽으로 넘어가서, 우리는 언제 함수를 suspend 하게 만들어야 할까? 답은 간단하다. suspend function을 호출할 때 붙이며 된다(?)

이 예제에서 loadData 함수는 suspend 함수이고, 이유는 networkRequest함수가 suspend 함수이기 때문이다. networkRequest는 withContext함수로 구성되어 있고, 이 또한 suspend function 이다. 그렇다면 언제 suspend 키워드를 쓰면 안되는 것일까? 

button Click함수는 suspend 함수가 아니라 그냥 콜백이다. suspend함수가 아닌 함수를 부를 때 suspend키워드를 붙이지 않으면 된다. 답은 Coroutine의 Scope에 있는데, launch나 async는 suspend 함수가 아니고 Coroutine을 trigger하는 함수이므로 onButtonClicked 함수에는 suspend를 붙이지 않아도 된다.

 

이제 Coroutine을 어떻게 테스트 하는지 살펴보자. 비동기적인 테스트 코드를 작성하는 것은 쉽지 않다. 왜냐하면 우리는 늘 그 테스트 코드가 똑같이 동작하길 원하기 때문이다. 그래서 각 유즈 케이스마다 다른 방식을 사용하면 된다. 먼저 첫번째 유즈 케이스로는 새로운 Coroutine을 만들지 않는다이다. 

 

loadData는 launch나 async를 콜 하지 않고 suspend function 이므로 내부적으로 새로운 Coroutine을 만들지 않는다.(?) 이런 함수는 runBlocking 을 이용해서 테스트 할 수 있다. runBlocking은 block안의 코드가 실행을 마칠 때 까지 스레드를 블럭하는 특징을 가지고 있다. 그래서 loadData는 suspend 함수이므로 runBlocking 내에서 synchronouse 하게 동작하여 밑에서 assert 할 수 있다. 

 

이번엔 새로운 Coroutine을 trigger 할 때의 테스트를 하는 경우를 알아보자.

fun onButtonClicked 함수를 테스트 한다고 생각해보자.

 

이 함수 내에서는 새로운 Coroutine이 생성된다. onButtonClicked 함수는 새로운 Coroutine을 launch 함수로 만들고 있고, 이는 곧 새로운 쓰레드에서 동작할 가능성이 있기도 하다. 따라서 만약에 runBlocking을 사용한다면 onButtonClicked 함수는 비동기적으로 동작하게 된다. 그래서 onButtonClicked 다음 라인데 Assert 를 하고 싶어도 onButtonClicked가 아직 돌고있기 때문에 테스트를 할 수 없는것이다. 그래서 이럴 때는 CountDownLatch, LiveDataTestUtil, Mockito await 등 onButtonClicked 내의 Coroutine이 끝날 때 까지 기다리는 다른 방법을 취해야 한다. 그러나 이는 빠른 테스트가 불가능하고 기다려야 한다는 점에서 냄새나는 코드(?) 이다. 좋은 해결책이 뭘까?

 

바로 특정 dispatcher를 주입시켜서 Coroutine을 실행하는 것이다. 그래서 테스트 코드를 작성할 때도 특정 Dispatcher를 넘겨줄 수 있게 된다. 

 

그래서 우리는 테스트코드 상에서 testDispatcher를 만들어서 Coroutine을 특정 dispatcher에서 실행시킬 수 있다. 그래서 이 때는 runBlocking 이 아니라 testDispatcher의 runBlockingTest를 사용하여 이 testDispatcher에서 실행되는 모든 Coroutine 들은 동기적으로 실행됨을 보장받을 수 있다. 따라서 onButtonClicked() 함수가 끝나고 다음 라인으로 이동하면, onButtonClicked 함수가 완전히 실행이 끝났음을 보장할 수 있다. 

 

하지만 만약에 onButtonClicked 함수 내에서 launch Coroutine을 하기 전에 다른 작업을 한다고 가정하면, 이것은 어떻게 테스트 할 수 있을까? 

 

TestCoroutineDispatcher는  pauseDispathcer(), resumeDispatcher() 함수를 통해 Coroutine을 잠시 중단 및 재개 같은 동작을 제어할 수 있고, Coroutine 이전에 할 동작들을 테스트 하고, resumeDispatcher를 하면 Coroutine이 끝나고 난 뒤의 상황을 테스트 할 수 있는 것이다. 

 

이 영상은 여기서 끝이다. 배운것을 한 번 정리해보면

1. Coroutine은 동기/비동기적인 프로그래밍을 단순화 시켰고

2.  Dispatcher와 withContext를 통해서 다른 쓰레드로 전환하여 작업을 할 수 있다.

3. Coroutine이 무엇인지 생각해보면, runnable with super power(...) 이고 

4. Coroutine의 내부에서는 suspend function 을 콜백을 사용하도록 재작성한다.

5. Structured Concurrency의 원리는 메모리 릭을 피하기 위함이며 Coroutine의 라이프 사이클을 어떻게 관리할 건지 강제로생각하도록 한다. 

6. Launch 와 Async 두 가지 방식으로 Coroutine을 생성할 수 있고

7. 언제 suspend 키워드를 붙이고, 언제 붙이지 말아야 할지

8. suspend fun을 테스트 하는 방법과 Coroutine을 새로 launch 할 때는 어떻게 테스트를 해야 하는지

 

와 같은 것을 다루었다.!  이어서 소개하는 영상은

 

Coroutine을 테스트 하는 방법에는 - ADS 2019에 자세히 소개되어 있고 

Cancellation과 Exception 을 소개하는 영상은 2019 Kotlin Conf 에 있다. 

(후)