본문 바로가기
개발/Android

[Android] (번역) Comparing Kotlin Coroutines with Callbacks and RxJava

by tempus 2021. 8. 20.
반응형

 

이 글은 https://www.lukaslechner.com/comparing-kotlin-coroutines-with-callbacks-and-rxjava/ 를 번역한 것입니다. (잘못된 부분이나 내용 개선에 관해서 피드백 환영합니다.)


Intro

나는 현재 Kotlin Coroutines에 많은 시간을 투자하고 있습니다. 그래서 Android에서 Coroutine을 사용하는 가장 일반적인 사례에 대해 조사하고 오픈소스 예제 프로젝트를 구현했습니다. 현재 16개의 일반적인 Coroutine 사용 사례가 포함되어 있습니다. 이 프로젝트의 샘플이 많은 개발자에게 도움이 될 수 있다고 생각합니다.


Coroutine의 실제 이점을 더 잘 이해하기 위해 비동기 프로그래밍에 대한 다른 접근 방식으로 Callback과 RxJava 2가지 사례도 구현했습니다. 이 블로그 게시물에서는 간단한 사용 사례를 구현하여 비교할 수 있도록 각 접근 방식의 코드를 보여주고자 합니다. 또한 각 접근 방식의 다양한 장점과 단점에 대해 논의하고 어떤 접근 방식이 향후 Android 프로젝트에 가장 적합한 접근 방식인지에 대한 개인적인 결과를 제공합니다.


Simple Use Case: Perform two sequential network requests

간단한 사용 사례는 미리 정의된 MockAPI에 대해 두 개의 순차적 네트워크 요청을 수행하는 것입니다. 하나는 모든 최신 Android 버전을 로드하고 다른 하나는 최신 Android 버전의 모든 기능을 로드합니다. 그러면 이 기능 목록이 화면에 표시됩니다. 코드를 실행해 보려면 프로젝트의 use case #2를 확인하시면 됩니다.

data_reflect_img

 


Callback Implementation

프로젝트는 비동기 프로그래밍을 위한 모든 관련 소스코드가 ViewModel에 있는 방식으로 구성됩니다. 다른 "boilerplate"는 ViewModel의 uiState LiveData 속성에 값을 설정하여 특정 이벤트(Loading, Error, Success)에 대해 알림을 받는 Activity에 있습니다. 다음은 Callback 구현을 위한 코드입니다.


class SequentialNetworkRequestsCallbacksViewModel(
    private val mockApi: CallbackMockApi = mockApi()
) : BaseViewModel<UiState>() {

    private var getAndroidVersionsCall: Call<List<AndroidVersion>>? = null
    private var getAndroidFeaturesCall: Call<VersionFeatures>? = null

    fun perform2SequentialNetworkRequest() {

        uiState.value = UiState.Loading

        getAndroidVersionsCall = mockApi.getRecentAndroidVersions()
        getAndroidVersionsCall!!.enqueue(object : Callback<List<AndroidVersion>> {
            override fun onFailure(call: Call<List<AndroidVersion>>, t: Throwable) {
                uiState.value = UiState.Error("Network Request failed")
            }

            override fun onResponse(
                call: Call<List<AndroidVersion>>,
                response: Response<List<AndroidVersion>>
            ) {
                if (response.isSuccessful) {
                    val mostRecentVersion = response.body()!!.last()
                    getAndroidFeaturesCall =
                        mockApi.getAndroidVersionFeatures(mostRecentVersion.apiVersion)
                    getAndroidFeaturesCall!!.enqueue(object : Callback<VersionFeatures> {
                        override fun onFailure(call: Call<VersionFeatures>, t: Throwable) {
                            uiState.value = UiState.Error("Network Request failed")
                        }

                        override fun onResponse(
                            call: Call<VersionFeatures>,
                            response: Response<VersionFeatures>
                        ) {
                            if (response.isSuccessful) {
                                val featuresOfMostRecentVersion = response.body()!!
                                uiState.value = UiState.Success(featuresOfMostRecentVersion)
                            } else {
                                uiState.value = UiState.Error("Network Request failed")
                            }
                        }
                    })
                } else {
                    uiState.value = UiState.Error("Network Request failed")
                }
            }
        })
    }

    override fun onCleared() {
        super.onCleared()

        getAndroidVersionsCall?.cancel()
        getAndroidFeaturesCall?.cancel()
    }
}

위와 같이 구현이 상당히 길고 (56줄) 들여 쓰기가 넓기 때문에 읽기 어렵습니다. 오류 처리는 4개의 다른 위치에서 오류를 처리해야 하므로 번거롭습니다. 또한 onCleared()에서 요청을 취소하는 것을 잊지 말아야 합니다. 그렇지 않다면 요청이 완료되지 않으면 ViewModel 및 Activity 인스턴스가 가비지에 수집되지 않기 때문에 심각한 메모리 누수가 발생합니다.


RxJava Implementation

class SequentialNetworkRequestsRxViewModel(
    private val mockApi: RxMockApi = mockApi()
) : BaseViewModel<UiState>() {

    private val disposables = CompositeDisposable()

    fun perform2SequentialNetworkRequest() {
        uiState.value = UiState.Loading

        mockApi.getRecentAndroidVersions()
            .flatMap { androidVersions ->
                val recentVersion = androidVersions.last()
                mockApi.getAndroidVersionFeatures(recentVersion.apiVersion)
            }
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeBy(
                onSuccess = { featureVersions ->
                    uiState.value = UiState.Success(featureVersions)
                },
                onError = {
                    uiState.value = UiState.Error("Network Request failed.")
                }
            )
            .addTo(disposables)
    }

    override fun onCleared() {
        super.onCleared()
        disposables.clear()
    }
}

RxJava를 통한 구현을 훨씬 더 간결합니다. (56줄 -> 32줄) 가독성을 높이기 위해 subscribeBy()와 addTo()와 같은 RxKotlin의 몇 가지 편리한 기능을 활용해 보았습니다. RxJava에 대한 기본 지식이 있으면 이 논리를 이해하기 쉽습니다. onError() 함수에서만 오류를 처리하므로 오류 처리도 간단해집니다. 메모리 누수를 방지하려면 onCleared()에서 수동으로 지워야 합니다.


Coroutines Implementation

class Perform2SequentialNetworkRequestsViewModel(
    private val mockApi: MockApi = mockApi()
) : BaseViewModel<UiState>() {

    fun perform2SequentialNetworkRequest() {
        uiState.value = UiState.Loading
        viewModelScope.launch {
            try {
                val recentVersions = mockApi.getRecentAndroidVersions()
                val mostRecentVersion = recentVersions.last()

                val featuresOfMostRecentVersion =
                    mockApi.getAndroidVersionFeatures(mostRecentVersion.apiVersion)

                uiState.value = UiState.Success(featuresOfMostRecentVersion)
            } catch (exception: Exception) {
                uiState.value = UiState.Error("Network Request failed")
            }
        }
    }
}

Coroutine 기반 구현은 21줄의 코드로 가장 간결한 구현입니다. viewModelScope에서 새로운 Coroutine이 실행되는 코드 라인을 제외하고 모든 개발자는 코드가 순차적인 방식으로 작성되기 때문에 Coroutine에 대한 지식이 없더라도 코드가 수행하는 작업을 이해할 수 있어야 합니다. Try-catch문과 같은 기존 코딩 구조를 사용하고 Google에서 제공하는 viewModelScope가 모든 것을 처리하므로 더 이상 취소 및 수명 주기 관리에 대해 생각할 필요가 없습니다.


Now, what is the best approach for asynchronous programming? 🤔

우리와 같이 전문적인 개발자로서 문제에 대한 3가지 솔루션을 비교하고 평가하는 가장 좋은 방법은 무엇일까? 내 생각으로는 다음과 같습니다.

  • "Callback은 Callback 지옥 때문에 나쁘다"
  • "RxJava는 경험이 없는 개발자가 이해하기 너무 어렵기 때문에 나쁘다."
  • "Coroutines은 구문이 좋고 간결하며 가독성이 좋기에 최고의 접근 방식이다"

위와 같은 것들로 전문적인 결정을 내리기에 충분하지 않습니다. 우리는 조금 더 생각할 필요가 있습니다.


My methodology for making decisions about the usage of a certain solution

어떠한 중요한 소프트웨어 개발 프로젝트에서 결정을 내릴 때 가장 중요한 측면은 각 솔루션의 유지 관리 가능성을 평가하는 것이라고 생각합니다. 그러나 유지 관리 가능성(maintainability)은 정확히 무엇을 의미하는가? 유지 관리 가능성을 정의하는 방법은 다양하지만 내 생각은 다음과 같습니다.


소프트웨어 프로젝트의 유지 관리 기능성이 높다는 것은 변경하는 데 걸리는 시간과 이 변경으로 인해 새로운 버그가 발생할 위험이 낮다는 것을 의미한다.


하지만 이 정의로는 "callback, RxJava, Coroutine" 중 유지 관리가 더 나은가요?라는 질문에 대답하기 어렵기 때문에 충분하지 않습니다. 이것이 우리가 높은 유지 보수성을 가져오는 코드 품질의 몇 가지 세부적인 속성을 살펴봐야 하는 이유입니다. 그러면 우리는 좋은 평가를 할 수 있을 겁니다. 이러한 속성을 식별한 다음 이러한 속성을 가장 잘 충족하는 솔루션을 평가해보겠습니다.


버그를 수정하거나 애플리케이션의 동작을 수정 또는 확장하는 등 다양한 이유로 우리는 코드를 변경해야 합니다. 변경하는데 걸리는 시간은 다음에 따라 다릅니다.


  1. 코드를 이해하는데 필요한 시간
  2. 실제 코드를 변경하는데 필요한 시간

좋은 가독성(저는 개인적으로 이해성이라는 단어를 선호합니다)은 코드를 이해하고 변경하는데 필요한 시간을 줄여 궁극적으로 소프트웨어 프로젝트의 유지 관리 가능성을 높입니다. 따라서 우리는 비교를 위한 첫 번째 속성으로 이해성(comprehensibility)을 갖습니다.


실제 코드 변경에 필요한 시간은 변경해야 하는 코드의 양에 따라 다릅니다. 이는 향후 변경 사항에 대한 코드의 유연성에 따라 다릅니다. 나는 유연성(flexibility)을 두 번째 속성으로 가질 것입니다.


변경으로 인해 새로운 버그가 발생할 위험은 주로 다음에 따라 달라집니다.


  1. 컴포넌트 간의 결합도 , 결합이 높은 스파게티 코드에 새로운 결함을 생길 위험을 결합도가 낮은 프로젝트보다 훨씬 높습니다.
  2. 테스트 범위의 양, 광범위한 테스트 제품군이 있는 경우 새로운 버그를 조기에 식펼할 수 있습니다.

전반적으로 우수한 아키텍처는 구성 요소의 적절한 분리로 이어지고 견고한 테스트 전략은 적적한 test suites로 이어집니다. 애플리케이션에서 비동기 프로그래밍을 수행하는 방법에 대한 선택은 결합 수준이나 테스트 범위에 영향을 미치지 않습니다. 모든 접근 방식(callback, Rxjava, Coroutine)을 통해 적절하게 분리된 아키텍처와 광범위한 테스트 제품군을 가질 수 있습니다. 그렇기 때문에 이 범주에서 비교할 합당한 속성을 식별하지 못했습니다.


나는 평가를 위해 flexibilityreadability 2가지 속성을 확인했습니다.


Assessing Readability and Flexibility

각 접근 방식의 가독성을 평가하기 위해 다음과 같은 질문을 스스로에게 던졌습니다.


  • 코드가 하는 일을 이해하는데 얼마나 걸릴까? 코드를 이해하는데 시간이 오래 걸릴수록 가독성이 떨어집니다.

각 접근 방식의 유연성을 평가하기 위해 다음과 같은 몇 가지 가상 변경을 수행하는데 시간이 얼마나 걸리는지 생각했습니다.


  1. 각 기능에 대한 세부 정부를 로드하는 3 번째 네트워크 요청 수행
  2. 최신 Android 버전 2개의 기능을 병렬로 로드 (최신 기능 대신)
  3. 실패한 응답을 반환하는 경우 네트워크 요청을 2번 재시도

이러한 가상의 변경을 수행하기 위해 작성해야 하는 코드가 많을수록 접근 방식의 유연성은 떨어집니다.


Evaluation of Callback based Solution

Comprehensibility

콜백 접근 방식의 이해도를 평가하는 것은 약간 까다롭습니다. 한편으로는 기본적으로 Android, Kotlin 및 Retrofit에 대한 기본적인 이해가 있는 모든 개발자는 특정 프레임워크에 대한 지식이 필요하지 않기에 코드가 하는 작업을 이해할 수 있습니다. 따라서 여기에서는 RxJava에서 flatMap()이 어떻게 작동하는지 또는 시작 Coroutine 빌더가 하는 작업 같이 따로 찾을 필요가 없습니다.

반면에 깊은 중첩(콜백 지옥), 번거로운 오류 처리 및 수명 주기 관리는 여전히 코드를 추론하기 어렵게 만듭니다. 또한 콜백의 코드가 실제로 실행되는 스레드를 파악하기 어려운 경우가 있습니다. 이것은 비동기 기능 구현의 세부사항 또는 우리의 경우 Retrofit에 따라 다릅니다. "콜백은 애플리케이션의 메인 스레드에서 실행된다"를 알기 위해서는 콜백 인터페이스 문서를 찾아봐야 합니다.


Flexibility

위에서 언급한 3가지 가상의 변경 사항을 달성하기 위해 콜백 기반 코드를 수정하는 것은 매우 지루합니다. 일부 기능의 세부 정보를 로드하기 위해 또 다른 네트워크 요청을 해야 하는 경우 콜백 지옥이 더욱 악화되고 오류 처리가 훨씬 더 복잡해집니다. onCleared()에서 다른 요청을 취소해야 하므로 수명주기 관리도 확장해야 합니다.

다른 2가지 가상 변경 사항, 프로젝트에서 두 개의 네트워크 요청을 병렬로 수행하고 재시도하는 것은 프로젝트 안에서 확인해보기 바랍니다. 확인해보면 많은 추가 코드가 필요하므로 콜백 기반 구현의 유연성은 매우 나쁘다는 것을 알 수 있습니다.


Evaluation of RxJava based Solution

Comprehensibility

RxJava 기반 솔루션의 이해도를 평가하려면 기본적으로 개발자가 RxJava에 대한 이해가 있는지 없는지를 나누어야 합니다. 이해가 있는 개발자의 경우 코드가 수행하는 작업을 파악하는 것은 정말 쉬울 것입니다. 하지만 이해가 없다면 어려움을 겪을 것입니다. 이들은 Single이 무엇인지, flatMap()이 하는 일, subscribeOn() 및 observeOn()에 의해 스레딩이 제어되는 방식, 오류 처리 및 수명 주기 관리가 작동하는 방식을 이해해야 합니다. 우리는 RxJava를 배우는 것이 어렵다는 것을 알고 있습니다.


Flexibility

가상 변경 1번을 구현하려면 스트림에 추가 flatMap()이면 충분합니다. 2번의 경우 zip() 연산자가 필요합니다. 3번의 경우 요구 사항을 충족하기 위해 충분해야 하는 retry() 연산자가 종료됩니다.

대체로 RxJava 접근 방식을 유연하고 변경하기 쉽습니다. 더 복잡한 사용 사례는 프로젝트 안에서 확인이 가능합니다.


Evaluation of Coroutine based Solution

Comprehensibility

Coroutine에 대한 지식이 있는 개발자와 없는 개발자를 구분해야 한다고 생각합니다. Coroutine 지식이 있는 엔지니어는 구현이 어떻게 작동하는지 파악하는 데 어려움을 겪지 않아야 합니다. Coroutine 지식이 없는 개발자는 viewModelScopelaunch{} Coroutine builder에 대해서만 공부하면 됩니다. 다른 모든 코드는 try-catch와 같은 기존 구문만 포함하는 일반 순차 코드이기 때문에 Coroutine 지식이 없어도 이해하기 쉽습니다. 이를 설명하기 위해 Coroutine 코드를 다시 살펴봅시다.

try {
    val recentVersions = mockApi.getRecentAndroidVersions()
    val mostRecentVersion = recentVersions.last()

    val featuresOfMostRecentVersion =
        mockApi.getAndroidVersionFeatures(mostRecentVersion.apiVersion)

    uiState.value = UiState.Success(featuresOfMostRecentVersion)
} catch (exception: Exception) {
    uiState.value = UiState.Error("Network Request failed")
}

모든 개발자는 이 코드를 이해할 수 있어야 합니다. 수명 주기 처리는 어떻게 동작하는지 알 필요 없이 viewModelScope()가 올바르게 수행하고 있다는 것만 믿으면 됩니다.


Flexibility

1번 같이 추가 요청을 하려면 Retrofit 일시 중단 기능을 간단히 호출하면 됩니다. 2번의 네트워크 요청을 병렬로 수행하려면 오류 처리 측면에서 약간 미묘한 async() Coroutine 빌더를 사용하면 됩니다. 3번 재시도 동작은 각각의 고차 함수를 생성하여 쉽게 구현할 수 있습니다.

Coroutine이 더 복잡한 상황에서 어떻게 확장되는지는 프로젝트를 통해 확인해 봅시다


Ranking

comprehensibility에 따른 내가 매긴 랭킹은 다음과 같습니다.


  1. Coroutines
  2. RxJava
  3. Callbacks

Coroutine은 여기에서 나의 승자입니다. Coroutine에 대한 지식이 없는 개발자라도 Coroutine 내의 코드는 모든 개발자에게 친숙한 평범한 순차 코드이기 때문에 어플리케이션이 수행하는 작업을 쉽게 파악할 수 있습니다. 물론 콜백 기반 접근 방식은 추가 프레임워크를 포함하고 배울 필요 없기 때문에 가장 간단한 접근 방식입니다.

하지만 RxJava 또는 Coroutine 같은 프레임워크를 학습하는데 시간을 투자하고 매우 복잡하고 오류가 발생하기 쉬운 콜백 기반 코드를 더 이해하기 쉽게 리팩터링 하는 것이 더 효과가 있다고 생각합니다.


flexibility에 따른 랭킹은 다음과 같습니다.


  1. Coroutines & RxJava
  2. Callbacks

Coroutine과 RxJava는 제 생각에 동일한 수준의 유연성을 제공합니다. 두 프레임워크를 모두 사용하면 쉽게 변경할 수 있습니다. 콜백을 사용하여 정교한 사용 사례를 구현하는 것은 엄청 복잡해집니다.


Conclusion

이 블로그 게시물에서는 저는 Callbacks, RxJava 및 Coroutines를 사용하여 간단하고 더 복잡한 사용 사례의 구현을 보여주었습니다. 그런 다음 장단점에 대해 이야기했습니다. 다음으로, 이러한 다양한 접근방식을 어떻게 평가할 수 있는지 생각하고 이해도와 유연성 수준에 따라 순위를 매기는 것이 합리적이라는 결론에 도달했습니다. Coroutine은 최고의 이해도를 가지고 있고 RxJava와 Coroutine은 둘 다 같은 수준의 유연성을 가지고 있습니다.


이러한 순위를 기반으로 Kotlin Coroutine은 코드 기반에 대한 최상의 유지 관리성을 제공하므로 다음 Android 프로젝트에서 가장 적합한 선택일 것입니다. 그것들을 사용하면 복잡한 사용 사례에서도 가장 이해하기 쉬운 코드를 작성할 수 있습니다. RxJava보다 배우고 이해하기가 약간 더 쉽습니다. 그러나 Coroutine이 비동기 및 다중 스레드 코드의 복잡한 특성을 갑자기 간단하게 만든다고 생각하지는 않습니다. 오류 처리의 정확한 작동과 같이 Coroutine에 대해 알아야 할 것이 여전히 있습니다.


Coroutine은 Google에서 많이 홍보하고 대부분의 androidx 라이브러리는 기본 제공 지원을 받습니다. 이들은 단순한 타사 라이브러리가 아니라 기본 프레임워크입니다. Jetbrains와 Google의 자사 지원이 있습니다. 우리는 빠른 버그 수정, 지속적인 발전 및 적극적인 개선에 의존할 수 있습니다. 몇 년 안에 더 많은 Android 개발자가 RxJava보다 Coroutine에 대한 지식을 갖게 될 것으로 기대합니다. 따라서 Coroutine 경험이 있는 새 팀원을 찾는 것이 실제로 더 쉬울 것입니다. 이것이 Coroutine을 선호하는 또 다른 이유입니다.


애플리케이션에서 RxJava를 사용하는데 만족한다면 Kotlin Coroutine에 대한 값비싼 리팩토링에 투자할 필요는 없습니다. 이것 또한 Google의 일부 전문가의 의견이기도 합니다.

 

반응형

댓글


loading