- Coroutine 에 대한 기본 지식이 없다면 해당 포스팅이 어려울 수 있습니다.
- Dispatcher 에 대해 잘 모른다면 코루틴 디스패처 (Dispatcher)
를 참고해주세요 🙇♂️
코루틴을 활용해서 비동기 작업할 때 Dispatcher
를 지정해줄 일이 웬만하면 없다. 이는 JetPack에서 제공하는 viewModelScope
, lifecycleScope
에는 이미 Dispatcher.Main.immediate
로 지정되어 있고, Retrofit 과 Room 에서 내부적으로 코루틴을 적절한 백그라운드 스레드에서 스케줄링해주기 때문이다.
Dispatcher
를 지정해주지 않으면, ANR이 발생할 수 있다!먼저, 안드로이드에서 비동기 작업을 어떻게 처리하는지 알아보자 💪
viewModelScope
에서 retrofit
을 활용해 네트워크 통신 후, UiState 를 업데이트하는 코드다. 코드의 순서에 따라 코루틴이 어떻게 스케줄링되고 분배되는지 살펴보겠다.
fun fetchRemoteData() {
viewModelScope.launch {
// 네트워크 통신
val result = retrofitService.fetchData()
// UiState 업데이트
updateUiState(result)
}
}
viewModelScope.launch
에 의해 코루틴(Coroutine1)이 생성되고, Dispatcher.Main.immediate
에 의해 바로 메인 스레드로 분배된다.
- Dispatcher.Main.immediate 는 별도의 스케줄링 없이 MainThread 에 코루틴을 분배된다.
retrofitService.fetchData()
를 호출하여 Coroutine1 은 Suspend
상태가 되어 MainDispatcher의 작업큐로 들어간다. 그리고, Retrofit 내부 백그라운드 스레드에 서버 작업을 요청한다.
- Retrofit 내부에서 코루틴을 어떻게 스케줄링하는지 궁금하면 해당 글을 참고하자
네트워크 작업 동안 메인 스레드는 Blocking 되지 않기 때문에, 다른 UI 관련 작업들을 수행할 수 있다.
네트워크 작업이 완료되면 Coroutine1 이 resume이 되어 다시 Main Thread 로 분배된다.
결과값을 바탕으로 UIState를 업데이트하면 비동기 처리 작업이 끝난다.
이렇게, 코루틴을 활용하면 메인 스레드를 Blocking 하지 않고 비동기 작업을 수행할 수 있다.
스레드 관점에서 비동기 작업을 나타내면 다음과 같다.
코루틴을 사용해도 Blocking 될 수 있는 경우를 예시를 통해 알아보자
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val button = findViewById<Button>(R.id.button)
button.setOnClickListener {
lifecycleScope.launch {
val res = cpuIntensiveTask()
textView.text = res.toString()
}
}
}
private suspend fun cpuIntensiveTask(): Int {
return List(10_000_000) { it }
.shuffled().sorted()
.shuffled().sorted()
.shuffled().first()
}
}
1000만 개의 데이터에 대한 복잡한 연산처리를 트리거하는 버튼이 하나 있다.
이때, 버튼을 여러번 누르면 다음과 같이 ANR이 발생한다.🤯
버튼이 클릭되었음에도 5초 안에 응답하지 않아 ANR이 발생했다. 여기서 다음과 같은 의문이 생길 수 있다.
🤔 코루틴을 사용하면 정지/재개를 보장해주는거 아니었나??
cpuIntensiveTask()
는suspend
함수인데 왜 중단되지 않고blocked
된거지?
suspend
키워드는 해당 함수가 중단 가능 함수
임을 나타내는 키워드이다. 반드시 중단이 일어난다는 것을 보장하지 않는다. suspend
함수는 코루틴 스코프 내부 코드 조각에 불과하다.
이에 대한 자세한 설명은 저자의 이전글을 참고하자
위 코드는 아래 코드와 완벽하게 일치한다.
button.setOnClickListener {
lifecycleScope.launch {
val res = List(10_000_000) { it }.(생략).shuffled().sorted()
textView.text = res.toString()
}
}
즉, CPU는 cpuIntensiveTask()
작업을 끝낼 때까지 MainThread 를 놔주지 않는다.그래서, 다음과 같이 메인 스레드가 Blocking 된 것이고, 다른 코루틴들은 작업큐에서 작업이 끝날때까지 무한정 대기하게 된다.
그래서, 공식문서에서는 메인 스레드에서 UI 작업이나 같은 가벼운 작업만 수행하라고 권장한다.
CPU 를 많이 사용하는 작업은 Dispatchers.Default
, IO 작업은 Dispatchers.IO
에 의해 백그라운드 스레드에서 수행하는 것이 좋다.
위 코드는 다음과 같이 개선할 수 있다.
suspend fun cpuIntensiveTask() = withContext(Dispatchers.Default) {
List(10_000_000) { it }
.shuffled().sorted()
.shuffled().sorted()
.shuffled()
.first()
}
withContext
함수를 활용하여 Dispatchers.Default
의 백그라운드 스레드에서 작업을 수행하도록 했다.
아마 실서비스에서는 사실 위와 같은 복잡한 로직은 Repository 나 Usecase 에 위치할 것이다.
class GetDataUseCase() {
suspend operator fun invoke(): Data {
// CpuintensiveTask..
}
}
// viewModel
viewModelScope.launch {
val data = getDataUseCase()
...
}
위와 같이, Usecase 를 viewModelScope 에서 호출하는 경우, 메인 스레드에서 해당 작업이 돌아간다.
suspend 함수는 호출자의 corotuineContext 를 사용하기 때문이다.
예상치 못하게 화면이 버벅거리거나 ANR 이 발생할 경우, 백그라운드 스레드가 아닌 메인 스레드에서 무거운 작업을 하고 있다는 의심해볼 수 있다.
Coroutine 이라면 withContext()
, Flow 라면 flowOn()
로 적절한 디스패처를 지정해주자!
class GetDataUseCase() {
// Coroutine
suspend operator fun invoke() = withContext(Dispatchers.Default) { 👍👍
// CpuintensiveTask..
}
// or Flow
operator fun invoke() = flow<Int> {
// CpuintensiveTask..
}.flowOn(Dispatchers.Default) 👍👍
}
예제에서는 CPU Blocking 만을 다루었지만, IO 라이브러리(retrofit, room)에서 별도의 백그라운드 스레드를 지원하지 않을 경우에는 Dispatcher.IO
와 같은 별도의 디스패처를 지정해줘야 한다는 사실도 잊지 말자!