코루틴을 이해하기 위해서는 Thread에 대한 이해가 필요하다.
OS에서 메모리 공간을 할당 받아 실행중인 프로그램을 프로세스라고 한다. 하나의 프로그램은 1개 이상의 프로세스를 실행할 수 있고 각 프로세스는 1개의 Heap 메모리를 할당 받아 한 가지의 작업을 한다.
그리고 프로세스에는 1개 이상의 Thread가 Stack 메모리를 할당받아 프로세스 내에서 여러 작업을 독립적으로 수행할 수 있다.
안드로이드 앱에서 Main Thread는 가장 중요한 Thread로 액티비티의 UI를 그리고 사용자 이벤트를 전달받는 역할을 한다.
만약 액티비티가 높은 부하를 받는 작업으로 인해 사용자 이벤트에 대해 5초 이내로 반응하지 못한다면 ANR (Application Not Responding) 문제가 발생하고 앱이 강제로 종료된다.
Main Thread가 오래 걸리는 작업을 실행한다고 해서 그 자체로 ANR이 발생하지는 않는다. 예를 들어 아무리 오래걸리는 작업이라도 사용자 이벤트가 없다면 오류가 발생하지 않는다.
하지만 사용자 이벤트는 언제 발생할지 모르기 때문에 Activity를 작성할 때에는 ANR을 고려하여 앱이 종료되는 것을 방지해주어야 한다.
ANR 문제를 해결하는 방법은 Activity를 실행한 메인 스레드 이외의 스레드를 만들어 시간이 오래걸리는 작업을 담당하게 하면 된다. 이러한 방식을 통해 개발자가 만든 스레드에서 시간이 오래 걸리는 작업이 수행중이더라도 메인 스레드는 이벤트를 언제든지 처리할 수 있기 때문에 ANR이 발생하지 않는다.
하지만 이러한 방법은 ANR 오류는 해결할 수 있지만 화면을 변경할 수 없다는 새로운 문제가 생긴다.
화면을 변경하는 작업은 메인 스레드에서만 할 수 있기 때문에 개발자 스레드에서 화면 변경 작업을 하게 된다면 다음과 같은 에러가 발생한다.
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
따라서 액티비티의 ANR 문제와 개발자가 만든 스레드에서 화면을 변경할 수 없는 문제를 해결하기 위해서 Thread-Handler나 AsyncTask를 이용하였지만 두가지 모두 API 30 레벨에서 deprecated 되었고 Rx 라이브러리를 이용하기도 하였다.
이러한 접근 방식들의 문제점은 작업 단위가 Thread라는 점이다.
Thread는 생성 비용과 작업을 전환하는 비용이 높다. 한 Thread가 다른 Thread의 작업에 대한 결과물을 기다려야 할 때 blocking 되면 해당 Thread는 그 작업이 끝날때까지 기다려야하기 때문에 자원이 낭비되게 된다.
위 그림에서 처럼 Thread1에서 수행하는 작업은 Thread2 에서 수행하는 작업의 결과물이 필요하다. 그렇기 때문에 Thread2에서 수행하는 작업이 끝날때까지 기다렸다 결과를 전달받아 작업을 재개해야하는데 이 과정에서 Blocking이 발생하게 되고 자원이 낭비되는 것이다.
이러한 이유로 구글에서는 안드로이드의 비동기 프로그래밍에 코루틴을 사용할 것을 권장하고 있다.
사전적으로 코루틴이란 스레드 안에서 실행되는 일시 중단 가능한 작업의 단위이고 하나의 스레드에는 여러개의 코루틴이 존재할 수 있다.
코루틴과 스레드는 모두 비동기 작업을 처리하기 위해 사용되지만 비동기를 구현하는 방식에서 차이가 있다.
동기와 비동기
비동기 작업은 어떤 작업을 수행할 때 이 작업이 끝나는 것을 기다리지 않고 다른 작업을 수행하는 것이고 동기 작업은 해당 작업이 끝날 때까지 기다린 뒤 다음 작업을 수행하는 것이다.
비동기 작업은 병렬성과 동시성을 이용해 구현이 가능하다.
병렬성과 동시성
- 동시성 : 동시성 프로그래밍은 동시에 여러 작업을 수행하는 것 처럼 보이게 하는 작업이다. 여러 작업을 조금씩 나누어 매우 빠르게 번갈아가며 실행하는 것이다.
- 병렬성 : 병렬성 프로그래밍은 동시성과 다르게 진짜 여러 작업을 한번에 동시에 수행하는 것이다. 각각의 CPU 코어가 각자 맡은 작업을 수행하며 동시에 여러 작업을 수행하는 것이다. 그렇기 때문에 병렬성 프로그래밍은 CPU 코어가 여러개인 경우에만 가능하다.
즉 스레드는 두 개의 작업을 두 일꾼이 동시에 처리한다고 할 수 있고 코루틴은 한 명의 일꾼이 두 작업을 잘게 쪼개서 번갈아 가면서 수행함으로써 동시에 처리되는 것처럼 보이게 한다고 할 수 있다.
위 그림에서는 스레드가 2개 존재하고 첫번째 스레드에는 2개의 코루틴이 존재한다.
첫번째 스레드의 첫번째 코루틴이 첫번째 작업을 수행하고 두 번째 스레드에서는 두번째 작업을 수행한다. 아까와 마찬가지로 작업 1을 수행하기위해서는 작업 2의 결과가 필요하다.
스레드만 사용하는 경우에는 작업 2가 완료될때까지 작업 1이 블로킹되어 자원을 낭비하지만 코루틴을 같이 사용하는 경우에는 작업 1에서 블로킹이 발생하는 동안 작업 3을 수행하며 자원의 낭비를 막을 수 있게 되고 스레드의 자원을 최대한 활용할 수 있게된다.
그런데 만약 작업 1이 재개되기 전까지 작업 3이 끝나지 않으면 어떻게 될까?
여기서 코루틴의 특성인 연속성이 나온다.
코루틴은 특정 스레드에 종속적이지 않고 연속적으로 작업을 수행한다.
따라서 이러한 상황이 발생하게 된다면 작업 1을 수행하는 코루틴과 작업 2를 수행하는 코루틴은 적절한 스레드에 분배되어 비동기적으로 실행되게 된다. (Dispatcher가 이러한 역할을 수행한다.)
코루틴을 사용했을때 장점은 다음과 같다.
다음 두개의 라이브러리를 앱수준 gradle에 추가해준다.
'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1' : 코루틴을 사용하기 위한 기본적인 라이브러리
'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1' : 안드로이드의 스레드를 제어하기 위한 라이브러리
Dispatcher는 스레드에 코루틴을 보내는 역할을 한다.
코루틴에서는 스레드 풀을 만들고 Dispatcher를 통해서 코루틴을 배분한다. 코루틴을 만들고 해당 코루틴을 Dispatcher에 보내면 Dispatcher는 자신이 관리한느 스레드풀 내부에 존재하는 스레드의 부하 상황에 맞추어 코루틴을 분배한다.
스레드풀은 직접 만들수 있지만 스레드 풀을 직접 제어하는 것은 불가능하다. 그렇기 때문에 개발자는 Dispatcher에 코루틴을 보내줄수만 있고 스레드풀에 분배하는 작업은 Dispatcher가 하게 된다.
Dispatcher는 다음과 같은 방법으로 만들 수 있다.
// 스레드가 하나인 Dispatcher
val dispatcher = newSingleThreadContext("SingleThread")
// 스레드가 3개인 Dispatcher
val dispatcher = newFixedThreadPoolContext(3, "ThreadPool")
안드로이드에서는 이미 Dispatcher가 이미 구현되어 있기 때문에 별도로 생성할 필요가 없다.
안드로이드에서 제공하는 Dispatcher의 종류는 다음과 같다.
Coroutine Scope는 코루틴이 실행되는 범위로 코루틴을 실행하고 싶은 Lifecycle에 따라 원하는 Scope를 생성하여 코루틴이 실행될 작업 범위를 지정할 수 있다.
직접 범위를 지정해줄 수 있는 scope이다.
// Main 스레드에서 작업
CoroutineScope(Dispatchers.Main).launch {
...
}
//백그라운드에서 작업
CoroutineScope(Dispatchers.IO).launch {
...
}
앱이 실행될 때부터 종료될 때까지 실행한다.
// 앱의 라이프사이클동안 실행될 Scope
GlobalScope.launch {
// 백그라운드로 전환하여 작업
launch(Dispatchers.IO) {
}
// 메인쓰레드로 전환하여 작업
launch(Dispatchers.Main) {
}
}
ViewModel의 대상, ViewModel이 제거되면 코루틴 작업이 자동으로 취소된다.
class MyViewModel: ViewModel() {
init {
viewModelScope.launch {
}
}
}
Lifecycle 객체 대상(Activity, Fragment, Service 등), Lifecycle이 끝날 때 코루틴의 작업이 자동으로 취소된다.
class MyActivity : AppCompatActivity() {
init {
lifecycleScope.launch {
}
}
}
Coroutine Builder는 코루틴을 실행시키는 함수로 launch
, async
, runBlocking
이 있다.
runBlocking으로 감싸진 코드는 수행이 끝날 때까지 해당 스레드는 Blocking된다. 비동기로 동작하는 것이 아니기 때문에 조심해서 사용해야한다. (라이브러리 또는 프레임워크에서 코루틴을 지원하지 않을 때에 주로 사용한다.)
스레드를 블로킹하지 않고 새 코루틴을 시작하며 호출자에게 결과를 반환하지 않으며 반환되는 객체는 Job이다. (return 값이 필요 없는 모든 작업은 launch를 사용하여 실행할 수 있다.)
CoroutineScope(Dispatchers.Main).launch {
// 결과값이 필요없는 작업
}
Deferred 객체이며, 결과값을 반환한다. 반환받은 Deferred 객체는 await() 함수를 사용하여 실제 결과로 변환시킬 수 있다.
val deferred = CoroutineScope(Dispatchers.Main).async {
// 결과값
"Hello Coroutine!"
}
val message = deferred.await() // await()함수로 결과값 반환
println(message)
실제로 안드로이드에서 ANR 방지를 위해 코루틴을 사용하는 예제이다.
버튼을 누르면 1부터 2억까지의 합을 계산해 TextView에 표시하는 코드인데 1부터 2억까지의 합을 계산하는 동작은 약 6~8초가 걸리기 때문에 버튼을 누르게 되면 (사용자 이벤트가 발생하게 되면) ANR이 발생하게 된다.
따라서 이 작업을 코루틴을 이용해 백그라운드에서 작업하게 되면 ANR 문제를 방지할 수 있다.
val channel = Channel<Int>()
var sum = 0L
val backgroundScope = CoroutineScope(Dispatchers.Default + Job())
button.setOnClickListener{
backgroundScope.launch {
val time = measureTimeMillis {
for(i in 1..2000000000){
sum += i
}
}
Log.d("coroutine", "$time")
Log.d("coroutine", "$sum")
channel.send(sum.toInt())
}
GlobalScope.launch(Dispatchers.Main) {
channel.consumeEach {
textView.text = "$sum"
}
}
}