[번역: Kotlin Coroutines: Deep Dive] (1) Kotlin Coroutines를 사용하는 이유
이 포스팅은 Kotlin Coroutines: Deep Dive의 글을 번역한 것입니다. 오역, 많은 의역, 생략이 있으니 감안하여 읽어주십시오.
우리가 Kotlin Coroutines을 배우는 이유가 무엇인가? RxJava, Reactor과 같이 이미 잘 만들어진 JVM 라이브러리가 있는데도 말이다. 더욱이 자바는 멀티스레딩을 지원한다. 즉 이미 비동기를 구현하기 위한 다양한 방법이 있다.
Kotlin Coroutines는 그것보다도 더 많은 것을 제공한다. 그 중 하나는 멀티플랫폼을 제공한다는 것이다. 즉슨 코틀린 플랫폼 사이에서 코루틴을 사용할 수 있음을 의미한다.
연습해보자. 코루틴과 다른 방법들 사이에 얼마나 차이가 있는지 보라. 아래에서는 안드로이드와 백엔드 비즈니스 로직의 두 가지 전형적인 예시를 보여줄 것이다.
안드로이드(와 다른 프론트엔드 플랫폼)에서 사용하는 코루틴
앱 로직을 수행할 때 가장 자주 진행되는 과정은 다음과 같다.
- API, 데이터베이스 등과 같은 소스의 데이터 가져오기
- 데이터 가공하기
- 그 데이터로 뷰에 보여주는 등의 작업 수행하기
더 잘 이해하기 위해 앱을 개발하는 중이라고 가정해보자. API를 통해 뉴스 “정보를 받아와서” 이를 “정렬”하고, “화면에 보여주는” 서비스를 만들 것이다. 원하는 기능을 그대로 넣은 게 다음과 같다.
1 | fun onCreate() { |
슬프지만 이대로 끝나선 안 된다. 안드로이드에서 각 애플리케이션은 뷰를 업데이트하는 데에 단 하나의 스레드를 가지고 사용한다. 이 스레드(Main Thread)는 매우 중요하여 절대 block(차단)되어서는 안 된다. 그런데 위 코드는 이 점을 만족시키지 못한다. 만약 메인 스레드에서 실행되었다면 getNewsFromApi 는 이 스레드를 block할 것이고, 앱은 죽을 것이다. 만약 다른 스레드였다면 앱은 showNews가 호출될 때 죽을 것이다. 이 작업은 메인 스레드에서 수행되어야 하는 작업이기 때문이다.
스레드 변경(switching)
이 문제는 스레드를 변경함으로써 해결할 수 있다. 우선 block할 수 있는 스레드에서 메인 스레드로 변경하자.
1 | fun onCreate() { |
이러한 스레드 변경은 어떤 앱에서는 여전히 사용하는 방식이긴하다. 그러나 이 방식은 몇가지 문제를 안고 있다.
- 이 스레드들을 cancel할 수 있는 방도가 없어서 종종 메모리 누수(memory leak) 상태에 직면한다.
- 스레드 비용이 많이 든다.
- 빈번한 스레드 변경은 혼란스럽고 관리를 어렵게 만든다.
- 이 코드는 불필요하게 크고 복잡하다.
당신이 뷰를 연 후 빠르게 닫는 걸 상상해보라. 열 때 몇 개의 스레드들이 시작하며 데이터를 처리한다. 뷰를 닫았음에도 존재하지 않는 뷰를 업데이트하기 위해 이 작업을 cancel하지 않고 계속 진행하게 된다. 불필요하다는 것이다.
이러한 문제에 대해 생각해보며 더 좋은 해결책을 보도록 하자.
콜백(Callbacks)
콜백은 이 문제를 해결하는 방법 중 하나이다. 이 방식은 기능이 non-blocking하게 만든다. 그러나 콜백 함수가 끝나야만이 시작할 수 있는 작업을 수행할 때만 적용할 수 있다. 코드에 적용하면 이렇다.
1 | fun onCreate() { |
여기서 작업을 cancel 할 수 없음에 주목하라. 취소할 수 있는 콜백 함수를 만들 수도 있을 것이다. 그러나 쉽지 않다. cancel을 위해 각 콜백 함수를 커스텀해야 할 뿐만 아니라 cancel을 할 가능성이 있는 모든 객체를 별도로 수집해야 한다.
1 | fun onCreate() { |
이러한 콜백 구조는 단점이 있다. 이걸 이해하기 위해 더 복잡한 예시를 보자. 세 개의 엔드포인트로부터 데이터를 가져오는 경우이다.
1 | fun showNews() { |
이 코드는 아래와 같은 이유로 완벽과는 거리가 멀다.
- news와 user 데이터는 사실 동시에 받아와도 된다. 그런데 지금 콜백 구조로는 그럴 수 없다.
- 전에 말한대로, 다른 더 많은 데이터를 불러오려고 할 때 cancel할 필요가 있다.
- 요구하는 게 점점 많아질수록 이 코드는 읽기 어려워질 것이다. 그러한 상황을 콜백 지옥(callback hell)이라고 부른다.
- 콜백을 사용할 때 작업의 순서를 제어하기 힘들다. 인디케이터를 다음과 같이 사용해도 진행도가 제대로 나타나지 않는다.
이게 콜백 구조가 어떤 면에서는 완벽하지 않은 이유이다. 다른 접근방식으로 RxJava를 사용한 예를 보자.
RxJava와 그밖의 reactive streams
Java에서 유명한 이 대체방식은 reactive streams(또는 Reactive Extensions)를 사용한다. 즉, RxJava 또는 successor Reactor를 말한다. 이 방식으로 말할 것 같으면, 모든 동작은 시작하여(started) 진행된(processed) 관찰되어지는(observed) 데이터 스트림(일련의 작업) 내에서 일어난다. 그래서 종종 앱에서는 동시에 진행되곤 한다.
다음 코드가 RxJava를 사용한 문제 해결 방식이다.
1 | fun onCreate() { |
위 예제에서 disposables은 사용자가 화면을 벗어났을 때 이 스트림을 cancel하기 위한 요소이다.
이는 분명 콜백보다 더 괜찮은 해결책이다. 메모리 누수가 없고, 작업 취소(cancel)를 할 수 있으며 스레드를 사용하는 데도 적절하다. 유일한 문제는 복잡하다는 것이다. 만약 맨처음 코드인 “이상적인” 코드와 비교한다면 공통점이 거의 없음을 알 수 있을 것이다.
subscribeOn, observeOn, map 그리고 subscribe라는 함수들을 모두 알아야지만 코드를 이해할 수 있다. cancelling(cancel할 수 있는 기능)은 분명 필요하다. 함수는 Observable이나 Single 클래스 안에 있는 객체를 반환해야 한다. 실제로 RxJava를 소개할 때 흔히 우리 코드를 아래와 같이 바꾼다.
두 번째 문제를 생각해보자. 데이터를 화면에 보여주기 전에 세 개의 엔드포인트를 호출해야 한다. 이건 RxJava에서는 사실 풀 수 있는 문제다. 그러나 코드가 지금보다 훨씬 더 복잡해진다.
1 | fun showNews() { |
이 코드는 정말 메모리 누수가 없고, concurrent하다. 그러나 zip, flatMap과 같은 RxJava 함수를 넣어야 하고, Pair로 값을 묶어야 하며, 이 구조를 해제해야 한다. 이 옳은 구현은 정말 복잡하다. 그래서 이번에야말로 코루틴으로 짠 코드를 보도록 하자.
코틀린 코루틴 사용하기
코루틴이 소개하는 핵심 기능은 어떤 지점에서 코루틴을 suspend(일시 정지)하고 후에 다시 resume(재시작) 할 수 있다는 것이다. 이 덕분에 메인 스레드에서 코드를 run(실행)하다가 API 데이터를 요청했을 때 suspend 할 수 있다. 코루틴이 suspend 되었을 때 스레드는 block(차단)되지 않고 뷰를 바꾸거나 다른 코루틴을 진행하는 등에 계속 사용할 수 있다. 데이터가 준비되면 코루틴은 메인 스레드를 기다린다. 드문 상황이지만 코루틴 대기열이 있을 수도 있다. 기다리던 스레드를 사용할 수 있게 되면 중지된 지점부터 계속 진행한다.
1 | suspend fun updateNews() { |
1 | val scope = CoroutineScope(Dispatchers.Main) |
그림에서 메인 스레드에서 분리된 코루틴으로 실행되는 updateNews와 updateProfile 함수를 보자. 두 함수(또는 코루틴)는 순서가 바뀌어도 된다. 스레드를 block하지 않고 코루틴을 suspend하기 때문이다. updateNews 함수가 네트워크 응답을 기다리고 있을 때 메인 스레드는 updateProfile이 사용한다. 여기서는 사용자 데이터는 이미 캐싱되었기 때문에 getUserData에서 suspend하지 않았다고 가정한다. 그러므로 작업을 완료할 수 있다. 네트워크 응답 시간이 충분하지 않아서 데이터를 받아오는 게 늦어지면 메인 스레드는 그 시간동안 사용되지 않는다(다른 함수가 사용할 수 있다). 데이터를 받으면 메인 스레드를 가져와 getNewsFromApi() 바로 다음 지점부터 시작하여 updateNews를 resume(재개)한다.
정의에 따르면 코루틴은 suspend와 resume이 가능한 컴포넌트다. JavaScript나 Rust, Python과 같은 언어에서 볼 수 있는 async/await나 generators와 같은 개념도 코루틴을 사용하지만 그 기능은 매우 제한적이다.
그래서 첫 번째 문제점은 다음 방식으로 해결한다.
1 | fun onCreate() { |
위 코드에서 현재 안드로이드에서 가장 흔한 viewModelScope를 사용했다. 이걸 대신해서 커스텀 scope를 사용할 수도 있다.
이 코드는 우리가 원하는 것에 거의 가깝다! 이 해결책에서 코드는 메인 스레드에서 run하지만 절대 block 하진 않는다. suspend 기법 덕에 데이터를 기다릴 필요가 있을 때 해당 코루틴을 block 대신 suspend 한다. 코루틴이 일시 정지했을 때 메인 스레드는 진행도를 예쁘게 보여주는 등의 다른 일을 할 수 있다. 그리고 데이터가 준비되면 코루틴은 메인 스레드를 다시 받아 멈췄던 부분부터 다시 시작한다.
그렇다면 어떻게 세 개의 API를 호출할까? 이 또한 유사한 방식으로 만들 수 있다.
1 | fun showNews() { |
이 해법은 제법 괜찮아보인다. 그러나 최선은 아니다. API 호출들을 하나가 끝나면 다음 호출을 부르듯, 순서대로 진행되고 있다. 그래서 각 작업이 1초 걸린다고 하면, 전체 함수는 2초가 아닌 3초가 걸린다. 여기서 코틀린 코루틴 라이브러리는 async와 같은 기능을 지원한다. 즉 일부 요청으로 다른 코루틴을 즉시 시작하고 (await 함수로)그 결과가 나중에 도착할 때까지 기다리는 데에 사용할 수 있다.
1 | fun showNews() { |
코드는 여전히 단순하고 가독성 있다. 이는 JavaScript나 C#에서 잘 사용되는 async/await 패턴을 이용한다. 효과적이고 메모리 누수도 없다. 단순한데다가 잘 구성되어있기까지 하다.
다른 예제
1 | // 모든 페이지를 동시에 로드하기 |
[번역: Kotlin Coroutines: Deep Dive] (1) Kotlin Coroutines를 사용하는 이유
https://dl137584.github.io/2023/11/19/028-kotlin-coroutines-deep-dive-01/