[Android / Kotlin] 코루틴(Coroutine) 이해하기 (4) - Codelab

문승연·2023년 8월 25일
0

Kotlin 코루틴

목록 보기
4/6

이 포스트는 안드로이드 공식 Codelab을 기반으로 작성되었습니다. 링크
이 포스트는 이전 포스트에서 이어지는 포스트입니다.

9. 콜백을 코루틴으로 전환

ViewModel, Repository, Room, Retrofit 에 코루틴을 추가해보자.

앞서 프로젝트의 각 구성요소들이 어떤 역할을 맡고 있는지 알아보자.
1. MainDatabase : Room 을 이용하여 Title 데이터 값을 저장하고 가져온다.
2. MainNetwork : 새로운 title 값을 가져오는 네트워크 API를 포함한다. Retrofit 으로 title을 가져오며 Retrofit 은 랜덤하게 에러나 mock 데이터를 반환하도록 설계되어있다.
3. TitleRepository : 네트워크와 데이터베이스의 데이터를 조합함으로써 title 값을 가져오거나 새로고침하도록 하는 1개의 API를 포함한다.
4. MainViewModel : 화면의 상태를 표시하고 각종 이벤트를 핸들링한다. 이벤트가 들어오면 repository에게 taps 값을 변경하고 title을 새로고침하도록 요청한다.

네트워크 요청이 UI 이벤트로부터 발생하고 그곳이 코루틴이 시작되는 지점이기 때문에 ViewModel 에서 코루틴을 시작하는 것이 가장 자연스럽다.

MainViewModel.kt

/**
* Update title text via this LiveData
*/
val title = repository.title


// ... other code ...


/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   // TODO: Convert refreshTitle to use coroutines
   _spinner.value = true
   repository.refreshTitleWithCallbacks(object: TitleRefreshCallback {
       override fun onCompleted() {
           _spinner.postValue(false)
       }

       override fun onError(cause: Throwable) {
           _snackBar.postValue(cause.message)
           _spinner.postValue(false)
       }
   })
}

기존 코드는 콜백 형식으로 작성되어있다.

위 코드를 코루틴 방식으로 수정하기 위해선 먼저 refreshTitleWithCallbacks 라는 콜백 기반 함수를 코루틴 기반으로 작동하는 refreshTitle 함수로 수정할 필요가 있다.

TitleRepository.kt

suspend fun refreshTitle() {
	// TODO: Refresh from network and write to database
    delay(500)
}

TitleRepository.ktrefreshTitle 이라는 suspend 함수를 추가한다.

MainViewModel.kt

/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           repository.refreshTitle()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

MainViewModelrefreshTitle 함수를 위와 같이 수정한다. 역시나 viewModelScope 에서 코루틴을 시작한다.

10. main-safe한 코루틴 작성

기존의 refreshTitleWithCallbacks() 함수를 살펴보자.

// TitleRepository.kt

fun refreshTitleWithCallbacks(titleRefreshCallback: TitleRefreshCallback) {
   // This request will be run on a background thread by retrofit
   BACKGROUND.submit {
       try {
           // Make network request using a blocking call
           val result = network.fetchNextTitle().execute()
           if (result.isSuccessful) {
               // Save it to database
               titleDao.insertTitle(Title(result.body()!!))
               // Inform the caller the refresh is completed
               titleRefreshCallback.onCompleted()
           } else {
               // If it's not successful, inform the callback of the error
               titleRefreshCallback.onError(
                       TitleRefreshError("Unable to refresh title", null))
           }
       } catch (cause: Throwable) {
           // If anything throws an exception, inform the caller
           titleRefreshCallback.onError(
                   TitleRefreshError("Unable to refresh title", cause))
       }
   }
}

위 함수는 BACKGROUND 로 메인 스레드가 아닌 백그라운드 스레드를 생성해 fetch 작업을 수행함으로써 main-safe하다.

코루틴으로 위 함수를 작성할때도 마찬가지로 main-safe 하게 작성해야한다.

사용할 수 있는 방법 중 하나는 코루틴 도중 withContext 를 사용하여 dispatcher 를 전환하는 것이다. 시간이 오래 걸리는 작업을 수행할 때 Dispatchers.Main 이 아닌 Dispatchers.IO 등 백그라운드 스레드로 전환함으로써 main-safe 를 지키는 것이다.

// TitleRepository.kt

suspend fun refreshTitle() {
   // interact with *blocking* network and IO calls from a coroutine
   withContext(Dispatchers.IO) {
       val result = try {
           // Make network request using a blocking call
           network.fetchNextTitle().execute()
       } catch (cause: Throwable) {
           // If the network throws an exception, inform the caller
           throw TitleRefreshError("Unable to refresh title", cause)
       }
      
       if (result.isSuccessful) {
           // Save it to database
           titleDao.insertTitle(Title(result.body()!!))
       } else {
           // If it's not successful, inform the callback of the error
           throw TitleRefreshError("Unable to refresh title", null)
       }
   }
}

위 함수는blocking 되는 호출을 포함하고있다. execute() 라던가 insertTitle(...) 처럼 스레드를 멈추게할 수 있는 함수가 있지만 IO Dispatcher로 스레드를 전환했기 때문에 메인 스레드는 suspend 된 상태로 따로 동작하게된다.

11. Room & Retrofit 에 코루틴 적용

다음은 Room 데이터베이스에 코루틴을 적용할 차례다. 먼저 MainDatabase.kt 을 열어 insertTitle 함수에 suspend 키워드를 추가한다.

// add the suspend modifier to the existing insertTitle

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)

이제 Room의 쿼리는 main-safe 가 되었다. 이제 insertTitle 쿼리는 코루틴 내부에서만 호출할 수 있게 되었다.

그 다음 Retrofit 에 코루틴을 적용한다.

MainNetwork.kt

// add suspend modifier to the existing fetchNextTitle
// change return type from Call<String> to String

interface MainNetwork {
   @GET("next_title.json")
   suspend fun fetchNextTitle(): String
}

Room 에서와 마찬가지로 suspend 키워드를 추가하고 return type에 Call wrapper를 없애주면 된다.

RoomRetrofit 에 코루틴을 적용하였으므로 이제 TitleRepository.ktrefreshTitle 도 수정해줘야한다.

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

코드가 훨씬 간략해졌음을 확인할 수 있다.

12. 상위 함수에 코루틴 적용하기

MainViewModelrefreshTitle 함수를 리팩토링해보자.

먼저 기존 refreshTitle 코드를 살펴보자.

// MainViewModel.kt

fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           // this is the only part that changes between sources
           repository.refreshTitle() 
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

위 코드에서 실질적으로 데이터를 변경시키는 부분은 repository.refreshTitle() 단 한 줄이다.
따라서 위 코드를 필요한 부분만 보이게 하고 나머지 부분은 일반화(generalize) 시키는 작업을 진행할 수 있다.

MainViewModel.kt

// Add this code to MainViewModel.kt

private fun launchDataLoad(block: suspend () -> Unit): Job {
   return viewModelScope.launch {
       try {
           _spinner.value = true
           block()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}
fun refreshTitle() {
   launchDataLoad {
       repository.refreshTitle()
   }
}

13. WorkManager 활용해보기

WorkManager 에 대해서 아직 제대로 다뤄본 적이 없기 때문에 간략하게 살펴보자.

WorkManager 란 지속적인 백그라운드 작업 처리에 활용할 수 있는 API이다.

override suspend fun doWork(): Result {
   val database = getDatabase(applicationContext)
   val repository = TitleRepository(network, database.titleDao)

   return try {
       repository.refreshTitle()
       Result.success()
   } catch (error: TitleRefreshError) {
       Result.failure()
   }
}
profile
"비몽(Bemong)"이라는 앱을 개발 및 운영 중인 안드로이드 개발자입니다.

0개의 댓글