안드로이드 개발자 노트
[코틀린 코루틴] 1장. 코틀린 코루틴을 배워야 하는 이유 본문
코틀린 코루틴은 멀티플랫폼에서 작동시킬 수 있기 때문에 코틀린을 사용하는 모든 플랫폼(JVM, JS, iOS 또는 다른 모듈들)을 넘나들며 사용할 수 있습니다.
프론트엔드 단에서 애플리케이션 로직을 구현할 때 가장 흔하게 사용하는 방법은 다음과 같습니다.
- 하나 또는 다양한 소스(API, 뷰 구성요소, 데이터베이스, 설정, 다른 애플리케이션)로부터 데이터를 얻어온다.
- 데이터를 처리한다.
- 가공된 데이터로 무엇인가를 한다(뷰를 통해 보여 주기, 데이터베이스에 저장하기, API로 전송하기 등)
fun onCreate() {
val news = getNewsFromApi() // 데이터를 얻어온다.
val sortedNews = news
.sortedByDescending { it.publishedAt } // 데이터를 처리한다.
views.showNews(sortedNews) // 가공된 데이터로 무엇인가를 한다.
}
안드로이드에서는 하나의 앱에서 뷰를 다루는 스레드가 단 하나만 존재하며, 블로킹되면 안되기 때문에 이런 방법으로 구현할 수 없습니다.
스레드 전환
스레드 전환이 위에서 말한 문제를 푸는 가장 직관적인 방법입니다.
블로킹이 가능한 스레드를 먼저 사용하고, 이후에 메인 스레드로 전환하면 됩니다.
fun onCreate() {
thread {
val news = getNewsFromApi()
val sortedNews = news
.sortedByDescending { it.publishedAt }
runOnUiThread {
views.showNews(sortedNews)
}
}
}
위와 같은 스레드 전환 방식은 다음과 같은 문제가 있습니다.
- 스레드가 실행되었을 때 멈출 수 있는 방법이 없어 메모리 누수로 이어질 수 있다.
- 스레드를 많이 생성하면 비용이 많이 든다.
- 스레드를 자주 전환하면 복잡도를 증가시키며 관리하기도 어렵다.
- 코드가 쓸데없이 길어지고 이해하기 어려워진다.
콜백
콜백(callback)은 앞선 문제를 해결하는 또 다른 패턴입니다.
콜백은 함수를 논블로킹(non-blocking)으로 만들고, 함수의 작업이 끝났을 때 호출될 콜백 함수를 넘겨주는 것입니다.
fun onCreate() {
getNewsFromApi { news ->
val sortedNews = news
.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
}
그러나 위 구현은 중간에 작업을 취소할 수 없습니다.
취소할 수 있는 콜백 함수를 만들 수도 있지만 쉬운 일은 아닙니다.
콜백 함수 각각에 대해 취소할 수 있도록 구현해야 할 뿐 아니라, 취소하기 위해선 모든 객체를 분리해서 모아야 합니다.
fun onCreate() {
startedCallbacks += getNewsFromApi { news ->
val sortedNews = news
.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
}
콜백 구조는 이 문제를 간단하게 풀 수 있지만 단점이 많습니다.
세 군데서 데이터를 얻어오는 다음 예제를 살펴봅시다.
fun onCreate() {
getConfigFromApi { config ->
getNewsFromApi(config) { news ->
getUserFromApi { user ->
view.showNews(user, news)
}
}
}
}
위 코드는 다음과 같은 이유 때문에 해결책이 될 수 없습니다.
- 뉴스를 얻어오는 작업과 사용자 데이터를 얻어오는 작업은 병렬로 처리할 수 있지만, 콜백 구조로는 두 작업을 동시에 처리하기 어렵다.
- 취소할 수 있도록 구현하기 어렵다.
- 콜백이 많아질 수록, 콜백 지옥이 된다.
- 콜백을 사용하면 작업의 순서를 다루기 힘들어진다.
RxJava와 리액티브 스트림
RxJava나 Reactor와 같은 리액티브 스트림을 사용하면 데이터 스트림 내에서 일어나는 모든 연산을 시작, 처리 관찰할 수 있습니다.
리액티브 스트림은 스레드 전환과 동시성 처리를 지원하기 때문에 애플리케이션 내의 연산을 병렬 처리하는 데 사용됩니다.
fun onCreate() {
disposables += getNewsFromApi()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.map { news ->
news.sortedByDescending { it.publishedAt }
}
.subscribe { sortedNews ->
view.showNews(sortedNews)
}
}
RxJava를 사용한 방법이 콜백을 사용한 것보다 훨씬 더 좋은 방법입니다.
메모리 누수도 없고, 취소가 가능하며, 스레드를 적절하게 사용하고 있습니다.
그러나 이 방식은 '이상적인' 코드와 비교하면, 완전히 다른 형태의 코드라고 볼 수 있습니다.
fun onCreate() {
val news = getNewsFromApi()
val sortedNews = news
.sortedByDescending { it.publishedAt }
views.showNews(sortedNews)
}
RxJava를 도입하려면 수많은 코드를 바꿔야 합니다.
객체를 반환하는 함수들은 Observable이나 Single 클래스로 래핑(wrapping)해야 하며, 취소하는 작업 또한 명시적으로 표시해야 합니다.
fun getNewsFromApi(): Single<List<News>>
세 개의 엔드포인트(endpoint: 서비스에서 다른 서비스로 요청을 보내는 지점)를 호출해야 한다는 것도 코드를 복잡하게 만드는 원인입니다.
fun showNews() {
disposable += Observable.zip(
getConfigFromApi().flatMap { getNewsFromApi(it) },
getUserFromApi(),
Function2 { news: List<News>, config: Config ->
Pair(news, config)
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (news, config) ->
view.showNews(news, config)
}
}
위 코드는 동시성 처리도 되어 있으며 메모리 누수도 없지만, zip, flatMap과 같은 RxJava 함수를 사용해야 하고 값을 Pair로 묶고 분리도 해야 하여 복잡하다는 단점이 있습니다.
코틀린 코루틴의 사용
RxJava와 달리 코루틴을 도입하면 suspend 제어자를 추가하는 것으로 충분하기 때문에 현재 코드에서 큰 변화가 발생하지 않습니다.
suspend fun getArticle(
articleKey: String,
lang: Language
): ArticleJson? {
return articleRepository.getArticle(articleKey, lang)?.let { toArticleJson(it)}
}
코틀린 코루틴의 핵심 기능은 특점 지점에서 멈추고 이후에 재개할 수 있다는 것입니다.
코루틴을 중단시켰을 때 스레드는 블로킹되지 않으며 뷰를 바꾸거나 다른 코루틴을 실행하는 등의 또 다른 작업이 가능합니다.
코틀린 코루틴을 사용하면 뉴스를 별도로 처리하는 작업을 다음과 같이 구현할 수 있습니다.
fun onCreate() {
viewModelScope.launch {
val news = getNewsFromApi()
val sortedNews = news
.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
}
아까 봤던 '이상적인' 코드와 거의 동일합니다.
여기서 코드는 메인 스레드에서 실행되지만, 스레드를 블로킹하지는 않습니다.
코루틴은 세 개의 엔드포인트를 호출해야 하는 문제를 다음과 같이 풀 수 있습니다.
fun onCreate() {
viewModelScope.launch {
val config = getConfigNewsApi()
val news = getNewsFromApi(config)
val user = getUserFromApi()
view.showNews(user, news)
}
}
위 코드도 좋은 방법처럼 보이지만, 작동 방식에 오류가 있습니다.
호출은 순차적으로 일어나기 때문에, 각 호출이 1초씩 걸린다면 전체 함수는 3초(config + news + user)가 걸립니다.
만약 API를 병렬로 호출했다면 3초 대신 2초(config + news, user)만에 작업을 끝낼 수 있습니다.
이럴 때 async와 같이 코틀린이 제공하는 코루틴 라이브러리를 사용할 수 있습니다.
async는 요청을 처리하기 위해 만들어진 코루틴을 즉시 시작하는 함수로, await와 같은 함수를 호출하여 결과를 기다립니다.
fun onCreate() {
viewModelScope.launch {
val config = async { getConfigNewsApi() }
val news = async { getNewsFromApi(config.await()) }
val user = async { getUserFromApi() }
view.showNews(user.await(), news.await())
}
}
코틀린의 다른 기능을 활용하여 좀 더 다양한 방식으로 처리할 수도 있습니다.
// 모든 페이지를 동시에 받아옵니다.
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 > 코틀린 코루틴' 카테고리의 다른 글
[코틀린 코루틴] 4장. 코루틴의 실제 구현 (0) | 2024.03.24 |
---|---|
[코틀린 코루틴] 3장. 중단은 어떻게 작동할까? (0) | 2024.03.02 |
[코틀린 코루틴] 2장. 시퀀스 빌더 (0) | 2024.03.02 |