본문 바로가기
Coroutine

[KotlinConf 2019 시리즈] Part 4 : Coroutines & Patterns for work that shouldn’t be cancelled

by DONXUX 2023. 3. 20.

이 글은 아래의 문서를 바탕으로 정리된 글임을 알려드립니다.

원문 : https://medium.com/androiddevelopers/coroutines-patterns-for-work-that-shouldnt-be-cancelled-e26c40f142ad

 

Coroutines & Patterns for work that shouldn’t be cancelled

Cancellation and Exceptions in Coroutines (Part 4)

medium.com

목차

개요

Part 2에서 더 이상 필요하지 않은 작업을 취소하는 것의 중요성을 배웠습니다. 안드로이드에서는 Jetpack에서 제공하는 CoroutineScope, 즉 viewModelScope 또는 lifecycleScope을 사용하여 코루틴을 실행할 수 있습니다. 이러한 스코프는 Activity/Fragment/Lifecycle이 종료될 때 실행 중인 모든 작업을 취소합니다. 만약 직접 CoroutineScope을 만든다면, Job과 연결하고 필요할 때 취소를 호출하는 것이 중요합니다.

 

하지만 사용자가 화면을 벗어나더라도 작업이 완료되길 원하는 경우도 있습니다. 예를 들면, 데이터베이스 입출력 또는 서버 요청 등이 있습니다. 이러한 경우에는 작업이 취소되면 안됩니다.

 

이 문서에는 이러한 목적을 달성하기 위한 패턴들이 소개되어 있습니다.

Coroutine냐 WorkManager냐

WorkManager를 사용해야 할 때 

코루틴은 프로세스가 살아있는 한 계속 실행됩니다. 하지만 프로세스를 초과해 실행되어야 하는 작업이 필요한 경우가 있습니다. 예를 들면, 로그를 원격 서버로 보내는 작업 등이 있습니다. 그러기 위해서 Android에서는 WorkManager를 사용해야합니다. WorkManager는 미래에 실행될 것으로 예상되는 중요한 작업에 대해 사용하는 라이브러리입니다.

Coroutine를 사용해야 할 때

현재 프로세스에서 유효하며 사용자가 앱을 종료할 때 취소할 수 있는 작업(예: 캐시하려는 네트워크 요청)에 대해서는 코루틴을 사용하세요.

Coroutines best practices

1. 클래스에 Dispatchers를 주입하기

새로운 코루틴을 생성하거나 withContext를 호출할 때 하드코딩하지 마세요. 그 대신에 Dispatcher를 클래스에 주입해보세요. 단위 테스트와 계측 테스트의 디스패처를 테스트 디스패처로 교체하여 테스트를 더 확정적으로 만들 수 있으므로 테스트하기가 더욱 쉬워집니다.

// DO inject Dispatchers
class NewsRepository(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}

// DO NOT hardcode Dispatchers
class NewsRepository {
    // DO NOT use Dispatchers.Default directly, inject it instead
    suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}

✅장점 : 단위 테스트와 계기 테스트에서 모두 쉽게 교체할 수 있으므로 테스트하기 쉽습니다.

 

2. ViewModel/Presenter 레이어에서 코루틴 생성하기

UI 작업이라면, UI 레이어에서 처리할 수 있을 것입니다. 만약 불가능하다고 생각하다면, 아마도 베스트 프렉티스 1번을 따르지 않은 것입니다. 뷰는 코루틴을 직접 트리거하여 비즈니스 로직을 실행하면 안 됩니다. 왜 그래야할까요?

뷰 레이어에서 직접 코루틴을 실행하게되면, 블록되거나 다른 뷰 레이어 작업이 지연될 수 있습니다. 이는 사용자에게 좋지 못한 경험을 제공할 수 있습니다.

대신 ViewModel에 맡기세요. 이렇게 하면 비즈니스 로직을 더 쉽게 테스트할 수 있습니다. 뷰 테스트에 필요한 계측 테스트를 사용하는 대신 ViewModel 객체를 대상으로 단위 테스트를 진행할 수 있기 때문입니다.

// DO create coroutines in the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun loadNews() {
        viewModelScope.launch {
            val latestNewsWithAuthors = getLatestNewsWithAuthors()
            _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
        }
    }
}

// Prefer observable state rather than suspend functions from the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {
    // DO NOT do this. News would probably need to be refreshed as well.
    // Instead of exposing a single value with a suspend function, news should
    // be exposed using a stream of data as in the code snippet above.
    suspend fun loadNews() = getLatestNewsWithAuthors()
}

장점 : 비즈니스 로직 실행의 책임을 ViewModel/Presenter에 넘김으로써, Android에서 에뮬레이터가 필요한 instrument 테스트를 유용하게 할 수 있습니다.

 

3. ViewModel/Presenter 아래의 레이어는 suspend 함수와 Flows를 노출해야 합니다.

코루틴을 생성해야 한다면, coroutineScope나 supervisorScope를 사용하세요.

// Classes in the data and business layer expose
// either suspend functions or Flows
class ExampleRepository {
    suspend fun makeNetworkRequest() { /* ... */ }

    fun getExamples(): Flow<Example> { /* ... */ }
}

장점 : 호출자(일반적으로 ViewModel 레이어)는 이러한 레이어에서 발생하는 작업의 실행 및 수명주기를 제어할 수 있으며 필요할 때 취소할 수 있습니다.

코루틴에서 취소되어선 안되는 작업 처리

ViewModel과 Repository가 구현되어있는 앱이 있다고 가정해봅시다.

class MyViewModel(private val repo: Repository) : ViewModel() {
  fun callRepo() {
    viewModelScope.launch {
      repo.doWork()
    }
  }
}
class Repository(private val ioDispatcher: CoroutineDispatcher) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
      veryImportantOperation() // This shouldn’t be cancelled
    }
  }
}

veryImportantOption()은 ViewModel이 끝나도 취소되지 말아야하는 연산이라고 가정해봅시다. 예를 들면, 원격 데이터를 로컬에 동기화 시키는 작업이 있을 수 있습니다. 그렇다면 우리는 이 작업이 ViewModel이 종료되어도, 이 메소드는 종료되지 않도록 해야합니다. 어떻게 해야할까요? 

애플리케이션 클래스에서 스코프를 정의하고 거기서 코루틴을 생성해 작업을 실행할 수 있을 것입니다. 자신만의 코루틴을 만드는 것은 CoroutineContext를 통해 마음대로 코루틴을 설정할 수 있다는 이점이 있습니다.

applicationScope라고 부를 수 있으며 코루틴의 실패가 계층 구조에서 전파되지 않도록 SupervisorJob()을 포함해야 합니다.

class MyApplication : Application() {
  // No need to cancel this scope as it'll be torn down with the process
  val applicationScope = CoroutineScope(SupervisorJob() + otherConfig)
}

이 범위를 사용하여 호출 범위가 앱에서 제공할 수 있는 것보다 더 긴 수명이 필요한 코루틴을 실행할 수 있습니다.

취소해서는 안 되는 작업의 경우, 애플리케이션 CoroutineScope에 의해 생성된 코루틴에서 호출하세요.

새 Repository 인스턴스를 만들 때마다, 위에서 만든 applicationScope를 전달하세요. 테스트의 경우, 아래의 테스트 섹션을 확인하세요.

어떤 코루틴 빌더를 사용해야할까?

veryImportantOperation의 동작에 따라, 실행 또는 비동기를 사용하여 새로운 코루틴을 시작해야 합니다.

  • 코루틴에서 결과 값을 받고 싶다면 async를 사용하고 await을 호출하세요.
  • 그렇지 않다면, launch를 사용하고 join을 사용해 작업이 끝날 때까지 기다리세요.
class Repository(
  private val externalScope: CoroutineScope,
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
      externalScope.launch {
        // if this can throw an exception, wrap inside try/catch
        // or rely on a CoroutineExceptionHandler installed
        // in the externalScope's CoroutineScope
        veryImportantOperation()
      }.join()
    }
  }
}

async를 사용한다면:

class Repository(
  private val externalScope: CoroutineScope,
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork(): Any { // Use a specific type in Result
    withContext(ioDispatcher) {
      doSomeOtherWork()
      return externalScope.async {
        // Exceptions are exposed when calling await, they will be
        // propagated in the coroutine that called doWork. Watch
        // out! They will be ignored if the calling context cancels.
        veryImportantOperation()
      }.await()
    }
  }
}

ViewModel이 종료되어도 externalScope의 작업들은 종료되지 않습니다. 

더 간단하게 안될까요?

더 간단하게 할 수 있는 방법이 있습니다. 다음과 같이 withContext를 사용하여 externalScope의 컨텍스트에서 veryImportantOperation을 감싸고 있습니다. 

class Repository(
  private val externalScope: CoroutineScope,
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
      withContext(externalScope.coroutineContext) {
        veryImportantOperation()
      }
    }
  }
}

하지만 주의사항이 있습니다. 

  • veryImportantOperation이 실행되는 동안 doWork를 호출하는 코루틴이 취소되면, withContext(externalScope.coroutineContext)는 다른 범위에서 실행되기 때문에, veryImportantOperation이 실행을 마친 후가 아니라 다음 취소 지점까지 계속 실행됩니다.
  • withContext에서 context를 사용할 경우 CoroutineContextHandler 예상한대로 작동하지 않습니다. 예외가 다시 throw되기 때문입니다.

테스트

테스트 환경에서는 어떤 Dispatchers와 Scopes를 주입해야할까요?

🔖 Legend: TestCoroutineDispatcher, MainCoroutineRule, TestCoroutineScope, AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher()

대안

❌ GlobalScope

코루틴은 대부분의 경우에, 애플리케이션 내에서 적절한 범위를 지정하여 코루틴을 실행하는 것을 권장합니다. 그에 반해 GlobalScope는 다음과 같은 이유로 권장되지 않습니다.

  • GlobalScope는 애플리케이션 전체에 걸쳐 유지되므로 테스트할 때 예측 가능한 결과를 얻기 어렵습니다.
  • GlobalScope에서 실행되는 코드는 전체가 공유할 수 있는 자원에 접근할 수 있으므로, 예측하지 못한 동작이 발생할 수 있습니다.

❌ ProcessLifecycleOwner scope in Android

안드로이드에는 androidx.lifecycle:lifecycle-process 라이브러리에서 사용할 수 있는 applicationScope가 있으며, ProcessLifecycleOwner.get().lifecycleScope로 액세스할 수 있습니다.

아래는 lifecycleScope를 이용한 코드입니다.

val scope = ProcessLifecycleOwner.get().lifecycle.coroutineScope
scope.launch {
    Log.i("ProcessLifecycle Test", "Dispatcher : ${coroutineContext[CoroutineDispatcher].toString()}")
}

그리고 위 코드를 이용해 ProcessLifecycleOwner의 스코프가 사용하는 디스패처를 보면 Dispatchers.Main.immediate를 사용한다는 것을 알 수 있습니다. 즉, 비용이 많이 들수 있는 백그라운드 작업에는 권장되지 않습니다.

2023-03-20 02:02:37.216 10603-10603 ProcessLifecycle Test   
com.donxux.testapplication           
I  Dispatcher : Dispatchers.Main.immediate

때문에, 이 대안은 Application 클래스에서 CoroutineScope를 만드는 것보다 더 많은 작업이 필요합니다. 또한, 뷰 레이어를 제외한 레이어는 플랫폼에 의존하지 않아야 하기 때문에 ViewModel/Presenter 아래의 레이어에서 Android 수명 주기와 관련된 클래스를 갖는 것은 권장되지 않습니다.

❌ ✅ NonCancellable 사용

Part 2에서 코루틴 작업 취소 이후, 리소스를 닫거나, 추가적인 정리 작업이 필요한 작업이 요구된다면 NonCancellable를 사용하여 추가 작업 완료 후 취소될 수 있도록 구현하길 권장했습니다. 하지만 NonCancellable를 남용해서는 안됩니다.

 

veryImportantOperation()이 NonCancellable를 이용해 실행된다고 가정해봅시다.

class Repository(
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
      withContext(NonCancellable) {
        veryImportantOperation()
      }
    }
  }
}

저희는 이 메소드에 어떠한 로직이 있는지 모릅니다. 만약 veryImportantOperation() 내부에 이러한 로직이 있다면?

  • 테스트에서 작업을 멈출 수 없습니다.
  • 만약 endless한 루프가 존재하고, 정말 끝없이 루프를 돈다면, 끝없이 취소되지 않을 것입니다.
  • Flow 같은 클래스의 인스턴스가 collect 중이라면 있다면, collect 하는 동안 취소되지 않을 것입니다.
  • ...

즉, 자신이 생각했던 취소 타이밍보다 훨씬 시간이 지난 뒤에 취소되거나, 프로세스가 종료될 때까지 취소되지 않을 수 있습니다. 이러한 버그들을 디버깅을 어렵게 할 수 있습니다.