관리 메뉴

나만을 위한 블로그

[코틀린 코루틴] 13. 코루틴 스코프 만들기 본문

책/코틀린 코루틴

[코틀린 코루틴] 13. 코루틴 스코프 만들기

참깨빵위에참깨빵 2024. 3. 21. 22:19
728x90
반응형
CoroutineScope 팩토리 함수

 

CoroutineScope는 coroutineContext를 유일 프로퍼티로 갖는 인터페이스다.

 

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

 

CoroutineScope 인터페이스를 구현한 클래스를 만들고 내부에서 코루틴 빌더를 직접 호출할 수 있다.

 

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext

class SomeClass: CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = Job()

    fun onStart() {
        launch {
            // ...
        }
    }
}

 

이 방법은 자주 쓰이지 않는다. 편해 보이지만 CoroutineScope를 구현한 클래스에서 cancel이나 ensureActive 같은 다른 CoroutineScope 메서드를 직접 호출하면 문제가 발생할 수 있다.

갑자기 전체 스코프를 취소하면 코루틴이 더 이상 시작될 수 없다. 대신 코루틴 스코프 인스턴스를 프로퍼티로 갖고 있다가 코루틴 빌더를 호출할 때 쓰는 방법이 선호된다.

 

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

class SomeClass {
    val scope: CoroutineScope = ...

    fun onStart() {
        scope.launch {
            // ...
        }
    }
}

 

코루틴 스코프 객체를 만드는 가장 쉬운 방법은 CoroutineScope 팩토리 함수를 쓰는 것이다. 이 함수는 컨텍스트를 받아 스코프를 만든다. Job이 컨텍스트에 없으면 구조화된 동시성을 위해 Job을 추가할 수 있다.

 

public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job())

internal class ContextScope(context: CoroutineContext) : CoroutineScope {
    override val coroutineContext: CoroutineContext = context
    override fun toString(): String = "CoroutineScope(coroutineContext=$coroutineContext)"
}

 

안드로이드에서 스코프 만들기

 

대부분 안드로이드 앱에선 MVVM, MVP 아키텍처가 쓰인다. 어떤 아키텍처에선 유저에게 보여주는 부분을 뷰모델, 프레젠터로 추출한다. 일반적으로 코루틴이 가장 먼저 시작되는 객체다.

UseCase, Repository 같은 다른 계층에선 보통 중단 함수를 쓴다. 코루틴을 액티비티 / 프래그먼트에서 시작할 수도 있다.

안드로이드의 어느 부분에서 코루틴을 시작하든 코루틴 만드는 법은 모두 비슷하다. onCreate를 통해 MainViewModel이 데이터를 가져오는 경우, 특정 스코프에서 시작한 코루틴이 데이터를 가져오는 작업을 해야 한다.

BaseViewModel에서 스코프를 만들면 모든 뷰모델에서 쓰일 스코프를 단 한 번으로 정의한다. 따라서 MainViewModel에선 BaseViewModel의 scope 프로퍼티를 쓰기만 하면 된다.

 

abstract class BaseViewModel: ViewModel() {
    protected val scope = CoroutineScope(TODO())
}

class MainViewModel(
    private val userRepo: UserRepository,
    private val newsRepo: NewsRepository,
): BaseViewModel() {
    fun onCreate() {
        scope.launch {
            val user = userRepo.getUser()
            view.showUserData(user)
        }

        scope.launch {
            val news = newsRepo.getNews().sortedByDescending { it.date }
            view.showNews(news)
        }
    }
}

 

이제 스코프에서 컨텍스트를 정의한다. 안드로이드에선 메인 쓰레드가 많은 함수를 호출해야 해서 기본 디스패처를 Dispatchers.Main으로 정하는 게 가장 좋다.

 

abstract class BaseViewModel: ViewModel() {
    protected val scope = CoroutineScope(Dispatchers.Main)
}

 

다음으로 스코프를 취소 가능하게 만들어야 한다. 일반적으로 유저가 화면을 나가면 onDestroy를 호출하면서 진행 중인 모든 작업을 취소한다.

스코프를 취소 가능하게 만들려면 Job이 필요하다. 실제로 CoroutineScope 함수가 Job을 추가해서 따로 추가하지 않아도 상관없지만 이 방식이 더 명시적이다. 이제 onCreate에서 스코프를 취소할 수 있다.

 

abstract class BaseViewModel: ViewModel() {
    protected val scope = CoroutineScope(Dispatchers.Main)

    override fun onCleared() {
        scope.cancel()
    }
}

 

전체 스코프 대신 스코프가 가진 자식 코루틴만 취소하는 게 더 좋다. 자식 코루틴만 취소하면 뷰모델이 액티브 상태로 유지되는 한 같은 스코프에서 새 코루틴을 시작할 수 있다.

 

abstract class BaseViewModel: ViewModel() {
    protected val scope = CoroutineScope(Dispatchers.Main + Job())

    override fun onCleared() {
        scope.coroutineContext.cancelChildren()
    }
}

 

마지막으로 중요한 기능은 잡히지 않은 예외를 처리하는 기본 방법이다.

안드로이드에선 다양한 예외가 발생할 경우 할 행동을 정의한다. 응답 종류에 따라 대화창, 스낵바, 토스트를 표시한다.

BaseActivity에 예외 처리 핸들러를 한 번만 정의하고 생성자를 통해 뷰모델에 전달하는 방법이 많이 쓰인다. 잡히지 않은 예외가 있다면 CoroutineExceptionHandler를 통해 해당 함수를 호출할 수 있다.

 

abstract class BaseViewModel(
    private val onError: (Throwable) -> Unit
): ViewModel() {
    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        onError(throwable)
    }

    private val context = Dispatchers.Main + SupervisorJob() + exceptionHandler

    protected val scope = CoroutineScope(context)

    override fun onCleared() {
        context.cancelChildren()
    }
}

 

viewModelScope, lifecycleScope

 

안드로이드에서 스코프를 따로 정의하는 대신 viewModelScope 또는 lifecycleScope를 쓸 수 있다.

Dispatchers.Main, SupervisorJob을 쓰고 뷰모델이나 라이프사이클이 종료될 때 Job을 취소한다는 점에서 만들었던 스코프와 거의 동일하다고 볼 수 있다.

 

public val ViewModel.viewModelScope: CoroutineScope
    get() {
        val scope: CoroutineScope? = this.getTag(JOB_KEY)
        if (scope != null) {
            return scope
        }
        return setTagIfAbsent(
            JOB_KEY,
            CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
        )
    }

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    override fun close() {
        coroutineContext.cancel()
    }
}

 

스코프에서 CoroutineExceptionHandler 같은 특정 컨텍스트가 필요없다면 viewModelScope, lifecycleScope를 쓰는 게 편하고 더 좋다.

 

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

class ArticlesListViewModel(
    private val produceArticles: ProduceArticlesUseCase
): ViewModel() {
    private val _progressBarVisible = MutableStateFlow(false)
    val progressBarVisible: StateFlow<Boolean> = _progressBarVisible

    private val _articlesListState = MutableStateFlow<ArticlesListState>(Initial)
    val articlesListState: StateFlow<ArticlesListState> = _articlesListState

    fun onCreate() {
        viewModelScope.launch {
            _progressBarVisible.value = true
            val articles = produceArticles.produce()
            _articlesListState.value = ArticlesLoaded(articles)
            _progressBarVisible.value = false
        }
    }
}

 

추가 호출을 위한 스코프 만들기

 

추가 연산을 시작하기 위한 스코프를 종종 만들 수 있다. 이런 스코프는 주로 함수나 생성자 인자를 통해 주입된다.

스코프를 호출 중단을 위해서만 쓰려는 경우 SupervisorJob을 쓰는 것으로 충분하다.

 

val analyticsScope = CoroutineScope(SupervisorJob())

 

다른 디스패처를 설정하는 것도 자주 쓰는 커스텀 방법이다. 스코프에서 블로킹 호출을 한다면 Dispatchers.IO를 쓰고 안드로이드의 메인 뷰를 다뤄야 한다면 Dispatchers.Main을 쓴다.

반응형
Comments