천복만복 프로그래밍/천복만복 코틀린

Cancellation and Exceptions in Coroutines (Part 4) - Coroutines & Patterns for work that shouldn’t be cancelled 아티클 정리

U&MeBlue 2022. 9. 15. 15:51

Part 2 에서 더이상 실행될 필요가 없는 코루틴을 취소하는 것의 중요성과 방법을 알아봤다. 안드로이드 개발에서는 Jetpack 에서 제공하는 viewMdoelScopelifecycleScope 를 사용하여 이를 처리해줄 수 있었다. 만약 직접 CoroutineScope 를 생성한다면 특정한 하나의 Job 에 스코프를 묶어주고, cancel 까지 직접 호출해줘야 한다.

그런데 만약 사용자가 앱 화면을 벗어난 경우에도 코루틴의 실행을 취소하지 말아야 하는 경우에는 어떻게 해야할까? 이 아티클에 대해서는 그러한 패턴을 구현하는 방법에 대해서 알아본다.

Coroutines or WorkManager?

코루틴은 앱의 프로세스가 살아있는 동안 계속해서 동작할 수 있다. 앱이 죽으면 코루틴도 종료된다. 만약 앱의 프로세스 라이프사이클보다 더 길게 유지되어야 하는 작업을 해야한다면 Android WorkManager 를 사용해야 한다.

만약 앱의 프로세스가 살아있는 동안에만 동작을 수행해야할 필요가 있다면 코루틴을 사용할 수 있다. 이러한 동작을 구현할 수 있는 패턴은 무엇이 있을까?

Coroutines best practices

앞서 말한 요구사항을 구현할 패턴은 Coroutines best practices 들을 기반으로 만들어지기 때문에 Coroutines best practices 를 다시 한번 복습해보자.

1. Inject Dispatchers into classes

클래스에서 코루틴을 생성하거나 withContext 와 같은 함수로 컨텍스트를 변환할때 Dispatchers 를 하드코딩 하지 말고 클래스에 Inject 하는 방식으로 구현해야 한다. 이런 식으로 구현하면 테스트시 Dispatchers 를 테스트 용도에 맞게 바꿔서 전달할 수 있어서 테스트 코드 작성을 쉽게 할 수 있다.

2. The ViewModel/Presenter layer should create coroutines

UI 레이어가 직접 비즈니스 로직을 처리하지 않아야 한다. 비즈니스 로직은 ViewModel/Presenter 에서 처리하도록 한다. UI 레이어는 테스트를 하기 위해 안드로이드 에뮬레이터가 필요해서, 애뮬레이터가 필요없는 ViewModel/Presenter 를 테스트하는 것이 더 쉽다.

3. The layers below the ViewModel/Presenter layer should expose suspend functions and Flows

Data 레이어와 같은 영역은 suspend functions 나 Flows 를 반환하도록 메소드를 작성해야 한다. 이렇게 함으로써 ViewModel Layer 에서 코루틴을 생성하고 해당 코루틴의 lifecycle 을 관리해줄 수 있다. 만약 코루틴을 생성할 필요가 있다면 coroutineScope 나 supervisorScope 를 이용해서 sub scope 를 만든 다음 생성해야 한다(?)

만약 다른 lifecycle 의 scope 를 사용하고 싶다면 이 글을 계속 읽어라

Operations that shouldn’t be cancelled in Coroutines

다음 예제 코드를 보자

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
    }
  }
}

만약 doWork() 을 실행하는 코루틴의 lifecycle 과는 상관없이 veryImportantOperation() 가 반드시 실행되게 하고 싶으면 어떻게 해야할까?

이런 경우에는 앱의 커스텀 Application 클래스에 직접 CoroutineScope 를 만들고 CoroutineScope를 이용해서 새로운 코루틴을 생성한 뒤 그 코루틴 내부에서 veryImportantOperation() 실행되게 함으로써 요구사항을 구현할 수 있다. 이 scope 는 클래스에 inject 되어 사용되어야 한다.

이 스코프를 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)
}

앱이 종료될때 앱 내부의 모든 코루틴이 종료되기 때문에 따로 이 applicationScope 를 취소시켜줄 필요는 없다.

Which coroutine builder to use?

veryImportantOperation 의 유형에 따라 사용해야할 코루틴 빌더의 유형이 달라질 수 있다. launch 나 async

  • 만약 값을 반환해야 하는 경우에는 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()
    }
  }
}
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()
    }
  }
}

doWork 를 호출한 viewModelScope 가 종료된다고 하더라도 veryImportantOperation() 의 실행은 종료되지 않을 것이다. 또한 veryImportantOperation()가 종료되기 전까지는 doWork 함수 호출이 종료되지 않을 것이다.

What about something simpler?

veryImportantOperation 를 처리할 또다른 시도를 해볼 수 있는데 withContext 를 이용해서 extenralScope 의 context 로 변환해주는 것이다.

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

그러나 이러한 방법에는 주의해야할 함점이 있다.

  • doWork 를 실행하는 코루틴이 취소되는 경우 veryImportantOperation() 가 완료된 시점이 아니라 다음 suspend 포인트에서 doWork() 취소가 마무리된다.
  • extenralScope 에 설정한 CoroutineExceptionHandlers 가 예외를 잡지 못하는데, withContext 를 사용하는 경우 예외를 재발생 시키기 때문에다.
728x90