[번역: Kotlin Coroutines: Deep Dive] (1) Kotlin Coroutines를 사용하는 이유

이 포스팅은 Kotlin Coroutines: Deep Dive의 글을 번역한 것입니다. 오역, 많은 의역, 생략이 있으니 감안하여 읽어주십시오.

우리가 Kotlin Coroutines을 배우는 이유가 무엇인가? RxJava, Reactor과 같이 이미 잘 만들어진 JVM 라이브러리가 있는데도 말이다. 더욱이 자바는 멀티스레딩을 지원한다. 즉 이미 비동기를 구현하기 위한 다양한 방법이 있다.

Kotlin Coroutines는 그것보다도 더 많은 것을 제공한다. 그 중 하나는 멀티플랫폼을 제공한다는 것이다. 즉슨 코틀린 플랫폼 사이에서 코루틴을 사용할 수 있음을 의미한다.

연습해보자. 코루틴과 다른 방법들 사이에 얼마나 차이가 있는지 보라. 아래에서는 안드로이드와 백엔드 비즈니스 로직의 두 가지 전형적인 예시를 보여줄 것이다.

안드로이드(와 다른 프론트엔드 플랫폼)에서 사용하는 코루틴

앱 로직을 수행할 때 가장 자주 진행되는 과정은 다음과 같다.

  • API, 데이터베이스 등과 같은 소스의 데이터 가져오기
  • 데이터 가공하기
  • 그 데이터로 뷰에 보여주는 등의 작업 수행하기

더 잘 이해하기 위해 앱을 개발하는 중이라고 가정해보자. API를 통해 뉴스 “정보를 받아와서” 이를 “정렬”하고, “화면에 보여주는” 서비스를 만들 것이다. 원하는 기능을 그대로 넣은 게 다음과 같다.

1
2
3
4
5
fun onCreate() {
val news = getNewsFromApi()
val sortedNew = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNew)
}

슬프지만 이대로 끝나선 안 된다. 안드로이드에서 각 애플리케이션은 뷰를 업데이트하는 데에 단 하나의 스레드를 가지고 사용한다. 이 스레드(Main Thread)는 매우 중요하여 절대 block(차단)되어서는 안 된다. 그런데 위 코드는 이 점을 만족시키지 못한다. 만약 메인 스레드에서 실행되었다면 getNewsFromApi 는 이 스레드를 block할 것이고, 앱은 죽을 것이다. 만약 다른 스레드였다면 앱은 showNews가 호출될 때 죽을 것이다. 이 작업은 메인 스레드에서 수행되어야 하는 작업이기 때문이다.

스레드 변경(switching)

이 문제는 스레드를 변경함으로써 해결할 수 있다. 우선 block할 수 있는 스레드에서 메인 스레드로 변경하자.

1
2
3
4
5
6
7
8
9
fun onCreate() {
thread {
val news = getNewsFromApi()
val sortedNew = news.sortedByDescending { it.publishedAt }
runOnUiThread {
view.showNews(sortedNew)
}
}
}

이러한 스레드 변경은 어떤 앱에서는 여전히 사용하는 방식이긴하다. 그러나 이 방식은 몇가지 문제를 안고 있다.

  • 이 스레드들을 cancel할 수 있는 방도가 없어서 종종 메모리 누수(memory leak) 상태에 직면한다.
  • 스레드 비용이 많이 든다.
  • 빈번한 스레드 변경은 혼란스럽고 관리를 어렵게 만든다.
  • 이 코드는 불필요하게 크고 복잡하다.

당신이 뷰를 연 후 빠르게 닫는 걸 상상해보라. 열 때 몇 개의 스레드들이 시작하며 데이터를 처리한다. 뷰를 닫았음에도 존재하지 않는 뷰를 업데이트하기 위해 이 작업을 cancel하지 않고 계속 진행하게 된다. 불필요하다는 것이다.

이러한 문제에 대해 생각해보며 더 좋은 해결책을 보도록 하자.

콜백(Callbacks)

콜백은 이 문제를 해결하는 방법 중 하나이다. 이 방식은 기능이 non-blocking하게 만든다. 그러나 콜백 함수가 끝나야만이 시작할 수 있는 작업을 수행할 때만 적용할 수 있다. 코드에 적용하면 이렇다.

1
2
3
4
5
6
fun onCreate() {
getNewsFromApi { news ->
val sortedNew = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNew)
}
}

여기서 작업을 cancel 할 수 없음에 주목하라. 취소할 수 있는 콜백 함수를 만들 수도 있을 것이다. 그러나 쉽지 않다. cancel을 위해 각 콜백 함수를 커스텀해야 할 뿐만 아니라 cancel을 할 가능성이 있는 모든 객체를 별도로 수집해야 한다.

1
2
3
4
5
6
fun onCreate() {
startedCallbacks += getNewsFromApi { news ->
val sortedNew = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNew)
}
}

이러한 콜백 구조는 단점이 있다. 이걸 이해하기 위해 더 복잡한 예시를 보자. 세 개의 엔드포인트로부터 데이터를 가져오는 경우이다.

1
2
3
4
5
6
7
8
9
fun showNews() {
getConfigFromApi { config ->
getNewsFromApi(config) { news ->
getUserFromApi { user ->
view.showNews(user, news)
}
}
}
}

이 코드는 아래와 같은 이유로 완벽과는 거리가 멀다.

  • news와 user 데이터는 사실 동시에 받아와도 된다. 그런데 지금 콜백 구조로는 그럴 수 없다.
  • 전에 말한대로, 다른 더 많은 데이터를 불러오려고 할 때 cancel할 필요가 있다.
  • 요구하는 게 점점 많아질수록 이 코드는 읽기 어려워질 것이다. 그러한 상황을 콜백 지옥(callback hell)이라고 부른다.
  • 콜백을 사용할 때 작업의 순서를 제어하기 힘들다. 인디케이터를 다음과 같이 사용해도 진행도가 제대로 나타나지 않는다.
    1
    2
    3
    4
    5
    6
    fun onCreate() {
    showProgressBar()
    showNews {
    hideProgressBar()
    }
    }

이게 콜백 구조가 어떤 면에서는 완벽하지 않은 이유이다. 다른 접근방식으로 RxJava를 사용한 예를 보자.

RxJava와 그밖의 reactive streams

Java에서 유명한 이 대체방식은 reactive streams(또는 Reactive Extensions)를 사용한다. 즉, RxJava 또는 successor Reactor를 말한다. 이 방식으로 말할 것 같으면, 모든 동작은 시작하여(started) 진행된(processed) 관찰되어지는(observed) 데이터 스트림(일련의 작업) 내에서 일어난다. 그래서 종종 앱에서는 동시에 진행되곤 한다.

다음 코드가 RxJava를 사용한 문제 해결 방식이다.

1
2
3
4
5
6
7
8
9
10
11
fun onCreate() {
disposables += getNewsFromApi()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.map { news ->
news.sortedByDescending { it. publishedAt }
}
.subscribe { sortedNews ->
view.showNews(sortedNews)
}
}

위 예제에서 disposables은 사용자가 화면을 벗어났을 때 이 스트림을 cancel하기 위한 요소이다.

이는 분명 콜백보다 더 괜찮은 해결책이다. 메모리 누수가 없고, 작업 취소(cancel)를 할 수 있으며 스레드를 사용하는 데도 적절하다. 유일한 문제는 복잡하다는 것이다. 만약 맨처음 코드인 “이상적인” 코드와 비교한다면 공통점이 거의 없음을 알 수 있을 것이다.

subscribeOn, observeOn, map 그리고 subscribe라는 함수들을 모두 알아야지만 코드를 이해할 수 있다. cancelling(cancel할 수 있는 기능)은 분명 필요하다. 함수는 Observable이나 Single 클래스 안에 있는 객체를 반환해야 한다. 실제로 RxJava를 소개할 때 흔히 우리 코드를 아래와 같이 바꾼다.

1
fun getNewsFromApi(): Single<List<News>>

두 번째 문제를 생각해보자. 데이터를 화면에 보여주기 전에 세 개의 엔드포인트를 호출해야 한다. 이건 RxJava에서는 사실 풀 수 있는 문제다. 그러나 코드가 지금보다 훨씬 더 복잡해진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun showNews() {
disposables += Observable.zip(
getNewsFromApi()
.flatMap { getNewFromApi(it) }
.subscribeOn(Schedulers.io()),
getUserFromApi()
.subscribeOn(Schedulers.io())
) { news: News, user: User ->
Pair(news, user)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (news, user) ->
view.showNews((news, user))
}
}

이 코드는 정말 메모리 누수가 없고, concurrent하다. 그러나 zip, flatMap과 같은 RxJava 함수를 넣어야 하고, Pair로 값을 묶어야 하며, 이 구조를 해제해야 한다. 이 옳은 구현은 정말 복잡하다. 그래서 이번에야말로 코루틴으로 짠 코드를 보도록 하자.

코틀린 코루틴 사용하기

코루틴이 소개하는 핵심 기능은 어떤 지점에서 코루틴을 suspend(일시 정지)하고 후에 다시 resume(재시작) 할 수 있다는 것이다. 이 덕분에 메인 스레드에서 코드를 run(실행)하다가 API 데이터를 요청했을 때 suspend 할 수 있다. 코루틴이 suspend 되었을 때 스레드는 block(차단)되지 않고 뷰를 바꾸거나 다른 코루틴을 진행하는 등에 계속 사용할 수 있다. 데이터가 준비되면 코루틴은 메인 스레드를 기다린다. 드문 상황이지만 코루틴 대기열이 있을 수도 있다. 기다리던 스레드를 사용할 수 있게 되면 중지된 지점부터 계속 진행한다.

1
2
3
4
5
6
7
8
9
10
11
12
suspend fun updateNews() {
showProgressBar()
val news = getNewsFromApi()
val sortedNews = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
hideProgressBar()
}

suspend fun updateProfile() {
val user = getUserData()
view.showUser(user)
}
1
2
3
4
5
6
val scope = CoroutineScope(Dispatchers.Main)

fun onCreate() {
scope.launch { updateNews() }
scope.launch { updateProfile() }
}

Kotlin Coroutine Example

그림에서 메인 스레드에서 분리된 코루틴으로 실행되는 updateNews와 updateProfile 함수를 보자. 두 함수(또는 코루틴)는 순서가 바뀌어도 된다. 스레드를 block하지 않고 코루틴을 suspend하기 때문이다. updateNews 함수가 네트워크 응답을 기다리고 있을 때 메인 스레드는 updateProfile이 사용한다. 여기서는 사용자 데이터는 이미 캐싱되었기 때문에 getUserData에서 suspend하지 않았다고 가정한다. 그러므로 작업을 완료할 수 있다. 네트워크 응답 시간이 충분하지 않아서 데이터를 받아오는 게 늦어지면 메인 스레드는 그 시간동안 사용되지 않는다(다른 함수가 사용할 수 있다). 데이터를 받으면 메인 스레드를 가져와 getNewsFromApi() 바로 다음 지점부터 시작하여 updateNews를 resume(재개)한다.

정의에 따르면 코루틴은 suspend와 resume이 가능한 컴포넌트다. JavaScript나 Rust, Python과 같은 언어에서 볼 수 있는 async/await나 generators와 같은 개념도 코루틴을 사용하지만 그 기능은 매우 제한적이다.

그래서 첫 번째 문제점은 다음 방식으로 해결한다.

1
2
3
4
5
6
7
fun onCreate() {
viewModelScope.launch {
val news = getNewsFromApi()
val sortedNews = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
}

위 코드에서 현재 안드로이드에서 가장 흔한 viewModelScope를 사용했다. 이걸 대신해서 커스텀 scope를 사용할 수도 있다.

이 코드는 우리가 원하는 것에 거의 가깝다! 이 해결책에서 코드는 메인 스레드에서 run하지만 절대 block 하진 않는다. suspend 기법 덕에 데이터를 기다릴 필요가 있을 때 해당 코루틴을 block 대신 suspend 한다. 코루틴이 일시 정지했을 때 메인 스레드는 진행도를 예쁘게 보여주는 등의 다른 일을 할 수 있다. 그리고 데이터가 준비되면 코루틴은 메인 스레드를 다시 받아 멈췄던 부분부터 다시 시작한다.

그렇다면 어떻게 세 개의 API를 호출할까? 이 또한 유사한 방식으로 만들 수 있다.

1
2
3
4
5
6
7
8
fun showNews() {
viewModelScope.launch {
val config = getCondfigFromApi()
val news = getNewsFromApi(config)
val user = getUserFromApi()
view.showNews(user, news)
}
}

이 해법은 제법 괜찮아보인다. 그러나 최선은 아니다. API 호출들을 하나가 끝나면 다음 호출을 부르듯, 순서대로 진행되고 있다. 그래서 각 작업이 1초 걸린다고 하면, 전체 함수는 2초가 아닌 3초가 걸린다. 여기서 코틀린 코루틴 라이브러리는 async와 같은 기능을 지원한다. 즉 일부 요청으로 다른 코루틴을 즉시 시작하고 (await 함수로)그 결과가 나중에 도착할 때까지 기다리는 데에 사용할 수 있다.

1
2
3
4
5
6
7
8
fun showNews() {
viewModelScope.launch {
val config = async { getCondfigFromApi() }
val news = async { getNewsFromApi(config.await()) }
val user = async { getUserFromApi() }
view.showNews(user.await(), news.await())
}
}

코드는 여전히 단순하고 가독성 있다. 이는 JavaScript나 C#에서 잘 사용되는 async/await 패턴을 이용한다. 효과적이고 메모리 누수도 없다. 단순한데다가 잘 구성되어있기까지 하다.

다른 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 모든 페이지를 동시에 로드하기
fun showAllNews() {
viewModelScope.launch {
val allNews = (0 until getNumberOfPages())
.map { page -> async { getNewsFromApi(page) }
.flatMap { it.await() }
view.showAllNews(allNews)
}
}

// 다름 페이지를 차례로 로드하기
fun showPagesFromFirst() {
viewModelScope.launch {
for (page in 0 until getNumberOfPages()) {
val news = getNewsFromApi(page)
view.showNextPage(news)
}
}
}

[번역: Kotlin Coroutines: Deep Dive] (1) Kotlin Coroutines를 사용하는 이유

https://dl137584.github.io/2023/11/19/028-kotlin-coroutines-deep-dive-01/

Author

LEEJS

Posted on

2023-11-19

Updated on

2024-03-22

Licensed under

댓글