타이탄의 도구들

[Android] 안드로이드 비동기 처리 2 (코루틴 소개) 본문

Dev Tools/Android

[Android] 안드로이드 비동기 처리 2 (코루틴 소개)

Titan04 2022. 7. 18. 12:18
728x90

해당 글부턴 대표적 라이브러리인

코루틴의 공식 문서들(코루틴 소개, 고급 코루틴 개념, 코루틴 플로우)을

하나씩 해석하고 공부하는 시간을 가져보도록 하겠습니다.

먼저 코루틴 소개부터 공부하도록 하겠습니다 :)

 


 

 

코루틴(Coroutines)이란?

안드로이드에서 비동기적으로 실행되는 코드를 간단하게 사용할 수 있는

동시 실행을 위한 디자인 패턴입니다.

코루틴은 Kotlin 버전 1.3에 추가되었으며

다른 언어에서 확립된 개념을 기반으로 합니다.

안드로이드에서 코루틴은 UI 스레드를 차단하여

앱이 응답하지 않게 만들 수도 있는 장기 실행 작업을 관리하는 데 도움이 됩니다.

코루틴을 사용하는 전문 개발자 중

50% 이상이 생산성이 향상되었다고 보고했습니다.

이 주제에서는 코루틴을 사용하여 이러한 문제를 해결해

더 명확하고 간결한 앱 코드를 작성하는 방법을 설명합니다.

코루틴의 특징

1. 매우 가볍습니다

코루틴을 실행 중인 스레드를 차단하지 않는 suspension을 지원하므로

단일 스레드에서 많은 코루틴을 실행할 수 있습니다.

suspension은 많은 동시 작업을 지원하면서도 메모리를 아껴줍니다.

2. 메모리 누수 감소

코루틴 스코프 내에서 작업을 실행하기 위해 '구조화된 동시 실행' 을 사용합니다.

3. 기본으로 제공되는 취소 기능

실행 중인 코루틴 계층 구조를 통해 코루틴을 자동으로 취소 할 수 있습니다.

4. Jetpack 통합

많은 Jetpack 라이브러리에 코루틴을 완전히 지원하는

확장 프로그램(extensions)이 포함되어 있습니다.

일부 라이브러리는 구조화된 동시 실행에 사용할 수 있는

자체 코루틴 스코프(coroutine scope)도 제공합니다.

예시

이 주제의 예에서는 앱 아키텍쳐 가이드에 따라 네트워크 요청을 보내고

결과를 UI 스레드로 반환합니다.

그러면 앱에서 요청 결과를 유저에게 보여줄 수 있습니다.

특히 ViewModel 아키텍처 컴포넌트는

UI 스레드에 있는 레포지토리 계층을 호출하여 네트워크 요청을 발생시킵니다.

이 가이드에서는 코루틴을 사용하여 UI 스레드를 '언블록 상태'로 유지하는 것

다양한 솔루션을 반복하여 보여드릴 것입니다.

ViewModel에는 코루틴과 직접 연동되는 KTX extension 모음이 포함됩니다.

이러한 extension은 lifecycle-viewmodel-ktx 라이브러리며, 이 가이드에서 사용됩니다.

build.gradle 파일의 dependencies를 설정해줍니다.

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:<VERSION>'
}
 

 

백그라운드 스레드에서 실행

UI 스레드에서 네트워크 요청을 보내면

응답을 받을 때까지 스레드가 대기(wait)하거나 차단(block)됩니다.

UI 스레드가 차단되는 경우 이로 인해 OS는 onDraw()를 호출할 수 없으므로

앱이 정지되고 애플리케이션 응답 없음(ANR) 대화상자가 표시될 수 있습니다.

더 나은 UX(User Experience) 제공을 위해

백그라운드 스레드에서 이 작업을 실행해 보겠습니다.

먼저 Repository 클래스를 살펴보고 네트워크 요청 방식을 확인하겠습니다.

 

sealed class Result<out R> {
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

class LoginRepository(private val responseParser: LoginResponseParser) {
    private const val loginUrl = "https://example.com/login"

    // 현재 스레드를 차단(blocking)하여 네트워크 요청을 만드는 함수
    fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {
        val url = URL(loginUrl)
        (url.openConnection() as? HttpURLConnection)?.run {
            requestMethod = "POST"
            setRequestProperty("Content-Type", "application/json; utf-8")
            setRequestProperty("Accept", "application/json")
            doOutput = true
            outputStream.write(jsonBody.toByteArray())
            return Result.Success(responseParser.parse(inputStream))
        }
        return Result.Error(Exception("Cannot open HttpURLConnection"))
    }
}

 

makeLoginRequest 함수는 동기적으로 처리되며,

호출하는 스레드를 차단(block) 합니다.

네트워크 요청의 응답을 모델링하기 위해 자체 Result 클래스를 사용합니다.

ViewModel은 사용자가 예를 들어 버튼을 클릭할 때 네트워크 요청을 발생시킵니다.

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        val jsonBody = "{ username: \"$username\", token: \"$token\"}"
        loginRepository.makeLoginRequest(jsonBody)
    }
}
위의 코드에서

LoginViewModel은 네트워크 요청을 보낼 때 UI 스레드를 차단합니다.

이 실행을 UI 스레드 외부로 이동하는 가장 간단한 방법은

새로운 코루틴을 만들고 I/O 스레드에서 네트워크 요청을 실행하는 것입니다.

 

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        // 실행을 UI 스레드에서 하지 않기 위해 새로운 코루틴을 만들어 줍니다.
        viewModelScope.launch(Dispatchers.IO) {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            loginRepository.makeLoginRequest(jsonBody)
        }
    }
}
 

로그인 함수에서 코루틴 코드를 분석해 봅시다.

- viewModelScope는 ViewModel KTX extensions에 포함된 '코루틴 스코프'입니다.

모든 코루틴은 범위 내에서 실행해야 합니다.

'코루틴 스코프'는 하나 이상의 관련 코루틴을 관리합니다.

- launch는 코루틴을 만들고 함수 본문의 실행을 해당 Dispatcher에 전달하는 함수입니다.

- Dispatchers.IO는 이 코루틴을 I/O 작업용으로 예약된 스레드에서 실행해야 함을 나타냅니다.

cf)

Dispatchers.Main

안드로이드 UI 스레드에서 코루틴을 실행하는 Dispatcher

Dispatcher는 UI와 상호작용하는 작업을 실행하기 위해서만 사용해야 한다.

Dispatchers.IO

디스크 또는 네트워크I/O(Input/Output) 작업을 실행하는 데 최적화되어 있는 디스패처

Dispatchers.Default

CPU를 많이 사용하는 작업을 UI 스레드 외부에서 실행하도록 최적화 되어있는 Dispatcher.

정렬 작업이나 JSON 파싱 작업 등에 최적화 되어있다.

출처: https://kotlinworld.com/141

login 함수는 다음과 같이 실행됩니다.

앱이 기본 스레드의 View 레이어에서 login 함수를 호출합니다.

launch가 새 코루틴을 만들며,

I/O 작업용으로 예약된 스레드에서 독립적으로 네트워크 요청이 이루어집니다.

코루틴이 실행되는 동안 네트워크 요청이 완료되기 전에 login 함수가 계속 실행되어 결과를 반환합니다. 편의를 위해 지금은 네트워크 응답이 무시됩니다.

이 코루틴은 viewModelScope로 시작되므로 ViewModel 범위에서 실행됩니다.

유저가 화면 밖으로 이동하는 것으로 인해

ViewModel이 소멸되는 경우 viewModelScope가 자동으로 취소되고

실행 중인 모든 코루틴도 취소됩니다.

위의 예에서 한 가지 문제는 makeLoginRequest를 호출하는 모든 항목이

명시적으로 실행을 UI 스레드 외부로 이동해야 한다는 점입니다.

이 문제를 해결하기 위해 Repository를 수정하는 방법을 알아보겠습니다.

main-safey를 위해 코루틴 사용하기

UI 스레드에서 UI 업데이트를 차단하지 않는 함수를

main-safe한 함수로 간주합니다.

UI 스레드에서 makeLoginRequest를 호출하면

UI가 차단되므로 makeLoginRequest 함수는 main-safe한 함수가 아닙니다.

코루틴 라이브러리의 withContext() 함수를 사용하여

코루틴 실행을 다른 스레드로 이동합니다.

class LoginRepository(...) {
    ...
    suspend fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {

        //코루틴 실행을 I/O Dispatcher로 이동시킵니다.
        return withContext(Dispatchers.IO) {
            // 현재 스레드인 I/O 스레드를 차단(blocking)하여 네트워크 요청을 만드는 함수
        }
    }
}

'withContext(Dispatchers.IO)'는 코루틴 실행을 I/O 스레드로 이동하여

호출 함수를 main-safe한 함수로 만들고

필요에 따라 UI를 업데이트할 수 있습니다.

makeLoginRequest에는 'suspend 키워드'도 표시됩니다.

이 키워드는 코루틴 내에서 함수가 호출되도록 강제하는 코틀린 만의 방식입니다.


다음 예에서는 LoginViewModel에 코루틴을 만듭니다.

makeLoginRequest가 withContext를 통해 I/O 스레드에서 작동하므로,

이제 login 함수의 코루틴이 UI 스레드에서 실행될 수 있습니다.

 

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {

        // UI 스레드에서 새로운 코루틴을 생성합니다.
        // cf) viewModel은 기본적으로 UI 스레드에서 동작합니다.
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"

           // 네트워크 호출을 수행하고 완료될 때까지 실행을 일시 중단합니다.
           // withContext(Dispatchers.IO)를 통해 I/O 스레드에서 작동함
            val result = loginRepository.makeLoginRequest(jsonBody)

            // 유저에게 네트워크 요청 결과 표시
            when (result) {
                is Result.Success<LoginResponse> -> // 응답 성공 시 작업
                else -> // UI에 에러 표시
            }
        }
    }
}
makeLoginRequest가 suspend 함수이므로 코루틴은 여전히 필요하며,

모든 suspend 함수는 코루틴에서 실행되어야 합니다.

이 코드는 위의 login 예와 몇 가지 차이점이 있습니다.

- launch가 Dispatchers.IO 매개변수를 사용하지 않습니다.

Dispatcher를 launch에 전달하지 않으면 viewModelScope에서 실행된 코루틴은

기본적으로 UI 스레드에서 실행됩니다.

- 네트워크 요청의 결과가 이제 성공 또는 실패 UI를 표시하도록 처리됩니다.

이제 로그인 함수가 다음과 같이 실행됩니다.

1. 앱이 UI 스레드의 View 레이어에서 login() 함수를 호출합니다.

2. launch가 기본 스레드에서 네트워크 요청을 보낼 새 코루틴을 만들며,

코루틴이 실행을 시작합니다.

3. 코루틴 내에서

이제 loginRepository.makeLoginRequest() 호출은

makeLoginRequest()의 withContext 블록 실행이 끝날 때까지

코루틴의 추가 실행을 일시 중단(block)합니다.

4. withContext 블록이 완료되면 login()의 코루틴이

네트워크 요청의 결과와 함께 UI 스레드에서 실행을 재개합니다.

cf)

ViewModel 레이어에서 View와 통신하려면 앱 아키텍처 가이드에서 권장하는 대로 LiveData를 사용합니다.

이 패턴을 따르면 ViewModel의 코드가 UI 스레드에서 실행되므로 MutableLiveData의 setValue() 함수를

직접 호출할 수 있습니다.


예외 처리

Repository 레이어에서 발생할 수 있는 예외를 처리하려면

Kotlin에서 built-in support for exceptions를 사용하세요.

다음 예에서는 try-catch 블록을 사용합니다.

 

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun makeLoginRequest(username: String, token: String) {
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            val result = try {
                loginRepository.makeLoginRequest(jsonBody)
            } catch(e: Exception) {
                Result.Error(Exception("Network request failed"))
            }
            when (result) {
                is Result.Success<LoginResponse> -> // 응답 성공 시 작업
                else -> // UI에 에러 표시
            }
        }
    }
}
이 예에서는 makeLoginRequest() 호출에 의해 발생한
728x90
Comments