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

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

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

정지 함수는 기본 스레드에서 호출하기에 안전한 기본 안전 함수여야 합니다. 클래스가 코루틴에서 장기 실행 차단 작업을 실행하는 경우 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입니다. 이 경우 coroutineScopesupervisorScope를 사용해야 합니다.

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    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(defaultDispatcher) {
                booksRepository.getAllBooks()
            }
            val authors = async(defaultDispatcher) {
                authorsRepository.getAllAuthors()
            }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

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

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
    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
    }
}

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

테스트에서 TestCoroutineDispatcher 삽입

TestCoroutineDispatcher 인스턴스는 테스트의 클래스에 삽입해야 합니다. TestCoroutineDispatcher는 즉시 작업을 실행하고 테스트에서 코루틴 실행 시기를 개발자가 제어할 수 있도록 합니다.

테스트 본문에서 TestCoroutineDispatcherrunBlockingTest를 사용하여 이 디스패처를 사용하는 모든 코루틴이 완료될 때까지 대기합니다.

class ArticlesRepositoryTest {

    private val testDispatcher = TestCoroutineDispatcher()

    @Test
    fun testBookmarkArticle() {
        // Execute all coroutines that use this Dispatcher immediately
        testDispatcher.runBlockingTest {
            val articlesDataSource = FakeArticlesDataSource()
            val repository = ArticlesRepository(
                articlesDataSource,
                // Make the CoroutineScope use the same dispatcher
                // that we use for runBlockingTest
                CoroutineScope(testDispatcher),
                testDispatcher
            )
            val article = Article()
            repository.bookmarkArticle(article)
            assertThat(articlesDataSource.isBookmarked(article)).isTrue()
        }
        // make sure nothing else is scheduled to be executed
        testDispatcher.cleanupTestCoroutines()
    }
}

테스트 중인 클래스에서 만든 모든 코루틴이 동일한 TestCoroutineDispatcher를 사용하고 테스트 본문에서 runBlockingTest를 사용하여 실행될 때까지 대기하므로 테스트가 확정적으로 되고 경합 상태가 발생하지 않습니다.

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 (error: Throwable) {
                // Notify view login attempt failed
            }
        }
    }
}

CoroutineExceptionHandler의 사용 및 기타 시나리오에 관한 자세한 내용은 코루틴의 예외 블로그 게시물을 참고하세요.

코루틴 자세히 알아보기

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