Android의 코루틴 권장사항

이 페이지에서는 코루틴을 사용할 때 앱의 확장성과 테스트 가능성을 높여 긍정적인 영향을 미치는 권장사항을 설명합니다.

디스패처 삽입

새 코루틴을 만들거나 withContext를 호출할 때 Dispatchers를 하드코딩하지 마세요.

// 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) { /* ... */ }
}

이 종속 항목 삽입 패턴을 사용하면 단위 테스트와 계측 테스트의 디스패처를 테스트 디스패처로 교체하여 테스트를 더 확정적으로 만들 수 있으므로 테스트하기가 더욱 쉬워집니다.

정지 함수는 기본 스레드에서 호출하기에 안전해야 함

정지 함수는 기본 스레드에서 호출하기에 안전한 기본 안전 함수여야 합니다. 클래스가 코루틴에서 장기 실행 차단 작업을 실행하는 경우 withContext를 사용하여 기본 스레드에서 실행을 이동하는 역할을 합니다. 이 사항은 클래스가 있는 아키텍처의 부분과 관계없이 앱의 모든 클래스에 적용됩니다.

class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {

    // As this operation is manually retrieving the news from the server
    // using a blocking HttpURLConnection, it needs to move the execution
    // to an IO dispatcher to make it main-safe
    suspend fun fetchLatestNews(): List<Article> {
        withContext(ioDispatcher) { /* ... implementation ... */ }
    }
}

// This use case fetches the latest news and the associated author.
class GetLatestNewsWithAuthorsUseCase(
    private val newsRepository: NewsRepository,
    private val authorsRepository: AuthorsRepository
) {
    // This method doesn't need to worry about moving the execution of the
    // coroutine to a different thread as newsRepository is main-safe.
    // The work done in the coroutine is lightweight as it only creates
    // a list and add elements to it
    suspend operator fun invoke(): List<ArticleWithAuthor> {
        val news = newsRepository.fetchLatestNews()

        val response: List<ArticleWithAuthor> = mutableEmptyList()
        for (article in news) {
            val author = authorsRepository.getAuthor(article.author)
            response.add(ArticleWithAuthor(article, author))
        }
        return Result.Success(response)
    }
}

이 패턴을 통해 앱의 확장 가능성이 높아집니다. 정지 함수를 호출하는 클래스가 작업 유형에 어떤 Dispatcher를 사용할지 걱정할 필요가 없기 때문입니다. 이 책임은 작업을 실행하는 클래스에 있습니다.

ViewModel은 코루틴을 만들어야 함

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

또한 작업이 viewModelScope에서 시작되면 코루틴이 구성 변경에도 자동으로 유지됩니다. 대신 lifecycleScope를 사용하여 코루틴을 만들면 수동으로 처리해야 합니다. 코루틴이 ViewModel의 범위를 벗어나야 하면 비즈니스 및 데이터 레이어에서 코루틴 만들기 섹션을 확인하세요.

변경 가능한 유형 노출하지 않음

변경 불가능한 유형을 다른 클래스에 노출하는 것이 좋습니다. 이렇게 하면 변경 가능한 유형의 모든 변경사항이 한 클래스로 집중되어 문제가 발생할 때 더 쉽게 디버그할 수 있습니다.

// DO expose immutable types
class LatestNewsViewModel : ViewModel() {

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

    /* ... */
}

class LatestNewsViewModel : ViewModel() {

    // DO NOT expose mutable types
    val uiState = MutableStateFlow(LatestNewsUiState.Loading)

    /* ... */
}

데이터 및 비즈니스 레이어는 정지 함수와 흐름을 노출해야 함

데이터 및 비즈니스 레이어의 클래스는 일반적으로 일회성 호출을 실행하거나 시간 경과에 따른 데이터 변경사항에 관해 알림을 받는 함수를 노출합니다. 이러한 레이어의 클래스는 일회성 호출용 정지 함수데이터 변경사항에 관해 알리는 흐름을 노출해야 합니다.

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

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

이 권장사항을 통해 일반적으로 프레젠테이션 레이어인 호출자가 이와 같은 레이어에서 발생하는 작업의 실행과 수명 주기를 제어하고 필요하면 취소할 수 있습니다.

비즈니스 및 데이터 레이어에서 코루틴 만들기

여러 가지 이유로 코루틴을 만들어야 하는 데이터 또는 비즈니스 레이어의 클래스에는 다양한 옵션이 있습니다.

이러한 코루틴에서 실행할 작업이 사용자가 현재 화면에 있을 때만 관련이 있으면 호출자의 수명 주기를 따라야 합니다. 대부분의 경우 호출자는 ViewModel이 되고 사용자가 화면에서 벗어나 ViewModel이 삭제되면 호출이 취소됩니다. 이 경우 coroutineScopesupervisorScope를 사용해야 합니다.

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
) {
    suspend fun getBookAndAuthors(): BookAndAuthors {
        // In parallel, fetch books and authors and return when both requests
        // complete and the data is ready
        return coroutineScope {
            val books = async { booksRepository.getAllBooks() }
            val authors = async { authorsRepository.getAllAuthors() }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

실행할 작업이 앱이 열려 있는 경우에만 관련이 있으며 특정 화면에 바인딩되지 않는 경우 작업은 호출자의 수명 주기보다 오래 지속되어야 합니다. 이 시나리오의 경우 취소하면 안 되는 작업의 코루틴 및 패턴 블로그 게시물에 설명된 대로 외부 CoroutineScope를 사용해야 합니다.

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch { articlesDataSource.bookmarkArticle(article) }
            .join() // Wait for the coroutine to complete
    }
}

externalScope는 현재 화면보다 오래 지속되는 클래스에서 만들고 관리해야 합니다. Application 클래스 또는 탐색 그래프로 범위가 지정된 ViewModel로 관리할 수도 있습니다.

테스트에 TestDispatcher 삽입

TestDispatcher 인스턴스를 테스트의 클래스에 삽입해야 합니다. kotlinx-coroutines-test 라이브러리에 사용 가능한 구현이 2가지 있습니다.

  • StandardTestDispatcher: 스케줄러로 시작된 코루틴을 큐에 추가하고 테스트 스레드가 사용 중이 아닐 때 실행합니다. 테스트 스레드를 정지하여 큐에 추가된 다른 코루틴이 advanceUntilIdle과 같은 메서드를 사용하여 실행되도록 할 수 있습니다.

  • UnconfinedTestDispatcher: 새로운 코루틴을 차단 방식으로 적극 실행합니다. 이렇게 하면 일반적으로 테스트를 작성하기가 더 쉬워지지만 테스트 중 코루틴이 실행되는 방식을 세밀하게 제어할 수는 없습니다.

자세한 내용은 각 디스패처 구현 문서를 참고하세요.

코루틴을 테스트하려면 runTest 코루틴 빌더를 사용하세요. runTestTestCoroutineScheduler를 사용하여 테스트의 지연을 건너뛰고 개발자가 가상 시간을 제어할 수 있도록 합니다. 필요에 따라 이 스케줄러를 사용하여 추가 테스트 디스패처를 만들 수도 있습니다.

class ArticlesRepositoryTest {

    @Test
    fun testBookmarkArticle() = runTest {
        // Pass the testScheduler provided by runTest's coroutine scope to
        // the test dispatcher
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)

        val articlesDataSource = FakeArticlesDataSource()
        val repository = ArticlesRepository(
            articlesDataSource,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

모든 TestDispatchers는 동일한 스케줄러를 공유해야 합니다. 이렇게 하면 모든 코루틴 코드를 단일 테스트 스레드에서 실행하여 테스트를 확정적으로 만들 수 있습니다. runTest는 같은 스케줄러에 있거나 테스트 코루틴의 하위 항목인 모든 코루틴이 완료되기를 기다렸다가 복구됩니다.

GlobalScope 피하기

디스패처 삽입 권장사항과 비슷합니다. GlobalScope를 사용하면 클래스에서 사용하는 CoroutineScope를 하드코딩하게 되고 다음과 같은 단점이 발생합니다.

  • 하드코딩 값을 승격합니다. GlobalScope를 하드코딩하면 Dispatchers를 하드코딩할 수도 있습니다.

  • 제어되지 않는 범위에서 코드가 실행되므로 테스트가 매우 어려워지고 실행을 제어할 수 없습니다.

  • 범위 자체에 빌드된 모든 코루틴에서 실행할 공통 CoroutineContext를 보유할 수 없습니다.

대신 현재 범위보다 오래 지속되어야 하는 작업에 CoroutineScope를 삽입해 보세요. 이 주제에 관한 자세한 내용은 비즈니스 및 데이터 레이어에서 코루틴 만들기 섹션을 참고하세요.

// DO inject an external scope instead of using GlobalScope.
// GlobalScope can be used indirectly. Here as a default parameter makes sense.
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope = GlobalScope,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch(defaultDispatcher) {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

// DO NOT use GlobalScope directly
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
) {
    // As we want to complete bookmarking the article even if the user moves away
    // from the screen, the work is done creating a new coroutine with GlobalScope
    suspend fun bookmarkArticle(article: Article) {
        GlobalScope.launch {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

GlobalScope와 대안에 관한 자세한 내용은 취소하면 안 되는 작업의 코루틴 및 패턴 블로그 게시물을 참고하세요.

코루틴을 취소 가능하게 만들기

코루틴의 취소는 협력적입니다. 즉, 코루틴의 Job이 취소될 때 코루틴은 정지되거나 취소를 확인할 때까지 취소되지 않습니다. 코루틴에서 차단 작업을 실행하는 경우 코루틴이 취소 가능한지 확인합니다.

예를 들어 디스크에서 여러 파일을 읽는 경우 각 파일을 읽기 전에 코루틴이 취소되었는지 확인합니다. 취소를 확인하는 방법 중 하나는 ensureActive 함수를 호출하는 것입니다.

someScope.launch {
    for(file in files) {
        ensureActive() // Check for cancellation
        readFile(file)
    }
}

withContextdelay와 같은 kotlinx.coroutines의 모든 정지 함수가 취소 가능합니다. 코루틴에서 이러한 함수를 호출하면 추가 작업을 실행하지 않아도 됩니다.

코루틴의 취소에 관한 자세한 내용은 코루틴의 취소 블로그 게시물을 참고하세요.

예외에 주의

코루틴에서 발생하는 예외를 처리하지 않으면 앱이 비정상 종료될 수 있습니다. 예외가 발생할 가능성이 있으면 viewModelScope 또는 lifecycleScope를 사용하여 만든 코루틴의 본문에서 예외를 포착해야 합니다.

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

    fun login(username: String, token: String) {
        viewModelScope.launch {
            try {
                loginRepository.login(username, token)
                // Notify view user logged in successfully
            } catch (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

자세한 내용은 Kotlin 문서의 코루틴 예외 또는 코루틴 예외 처리 블로그 게시물을 참고하세요.

코루틴 자세히 알아보기

코루틴 리소스에 관한 자세한 내용은 Kotlin 코루틴 및 흐름용 추가 리소스 페이지를 참고하세요.