원본 - Coroutines Job Structures
코루틴은 다양한 상황에서 사용되기 때문에, 우리가 만든 Job들 사이의 관계를 이해하고 파악하는 것은 중요한 일입니다. 우리가 만든 coroutine Job들을 코루틴 라이브러리가 어떻게 취소시키는지는 이런 관계로 인해 결정됩니다. 이 글에서는 Job 계층을 만드는 예시를 자세히 들여다 보고, 그들이 cancellation에 미치는 영향을 알아보고, 더불어 Supervisor Job을 알아보겠습니다.
Problem
Android에서 coroutine을 사용한다고 가정해봅시다. 아마 우리는 coroutine을 launch하는 view model을 하나 준비하겠죠. 그 코루틴 안에는 또 다른 일을 하는 코루틴들을 launch할 수도 있습니다. 코루틴을 실행하는 방법에는 여러가지가 있습니다.
class MyViewModel(
repo1: MyRepository1,
repo2: MyRepository2
): ViewModel {
fun getData() {
viewModelScope.launch {
launch { ... }
launch { ... }
repo1.getData()
repo2.getData()
}
}
}
class MyRepository1 {
val coroutineScope = CoroutineScope(Dispatchers.IO)
fun getData() {
coroutineScope.launch { ... }
}
}
class MyRepository2(
val lifecycleScope: LifecycleCoroutineScope
) {
fun getData() {
lifecycleScope.launch(Dispatcers.IO) { ... }
}
}
이 예제에서 view model은 view models scope 에서 코루틴을 launch 합니다. launch 블록을 사용하죠. 또한 두 개의 repository에 의존하고 있는걸 볼 수 있습니다. 첫 번째 repository는 자기 자신 내부에서 scope를 만들며 그 scope에서 coroutine을 launch하고 있네요. 두 번째 repository는 생성자로 주입받은 lifecycle scope에서 coroutine을 launch하고 있습니다.
우리는 서로 서로 관계가 있는 coroutine들을 만들고 있습니다. 그들 중 일부는 독립되어 있기도 하구요. 아무튼 이런 관계는 코루틴 라이브러리가 clean-up을 할 때 혹은 coroutine 내부에서 exception이 발생했을 때 어떻게 worker를 취소 하는지에 영향을 미칩니다.
Coroutine Hierarchy
우리가 IO dispatcher를 포함하는 scope를 가지고 있고, 그 scope에서 3개의 코루틴을 launch한다고 해봅시다.
val scope = CoroutineScope(Dispatchers.IO)
// scope.coroutineContext[Job]
val job1 = scope.launch { ... }
val job2 = scope.launch { ... }
val job3 = scope.launch { ... }
이 예제에는 4개의 서로 다른 job들이 생성되어있습니다. launch의 리턴으로 생성되는 잡 3개와, scope자체의 job 1개죠. scope의 Job에 접근하는 방법은 아래와 같습니다.
scope.coroutineContext[Job]
이 Job은 다른 Job들과 관계를 맺고 있는데요, 이 scope의 Job은 나머지 3개 job의 부모가 됩니다.
Job Cancellation
위 코루틴간의 관계는 코루틴 cancellation에 영향을 미칩니다.
val scope = CoroutineScope(Dispatchers.IO)
val job1 = scope.launch {
while(isActive) { delay(2000) }
}
val job2 = scope.launch {
while(isActive) { delay(3000) }
}
val job3 = scope.launch {
while(isActive) { delay(3000) }
}
delay(1000)
scope.cancel()
각각의 자식 코루틴들은 isActive를 체크하며 각자의 일을 합니다. scope를 취소하는 것은 모든 자식들을 순회하며 그것들을 취소합니다.
Nested Coroutines
코루틴을 중첩하여 사용함으로써 조금 복잡한 구조로 사용하게 될 경우도 있습니다.
val scope = CoroutineScope(Dispatchers.IO)
val job = scope.launch {
val job1 = launch {
delay(2000)
}
val job2 = launch {
delay(3000)
}
val job3 = launch {
delay(4000)
}
}
예제를 보시면, scope에서 하나의 코루틴을 launch했고, 그 코루틴 안에서 세개의 다른 코루틴들을 launch했습니다. 각 코루틴은 몇 초씩 delay합니다. 이 세 코루틴의 Job은 scope의 손자 Job이 됩니다.
한편 Job의 자식이 몇 개인지 print해 볼 수도 있습니다.
scope.coroutineContext[Job]?.children?.count() // 1
위의 코드를 보면 scope의 Job은 단 1개 입니다. scope에서 직접 launch한 코루틴인 것이죠. 그 job은 세개의 Job을 가지고 있습니다.
job.children.count() // 3
Nested Coroutines Cancellation
만약 scope job을 취소하면 그 취소는 아래로 전파되어 모든 자식, 손자 Job들을 취소합니다.
val scope = CoroutineScope(Dispatchers.IO)
val job = scope.launch {
val job1 = launch {
delay(2000)
}
val job2 = launch {
delay(3000)
}
val job3 = launch {
delay(4000)
}
}
scope.cancel()
코루틴을 launch할 때 다른 scope에서 코루틴을 만들 수 있습니다. 이 때 이것의 의미를 제대로 이해하는 것이 중요합니다.
val scope1 = CoroutineScope(Dispatchers.IO)
val job = scope.launch {
val scope2 = CoroutineScope(Dispatchers.IO)
scope2.launch { ... }
val job1 = launch { ... }
val job2 = launch { ... }
val job3 = launch { ... }
}
scope1.cancel()
scope2에서 launch한 코루틴은 독립적입니다. 이 코루틴은 위 코드의 어떤 코루틴과의 위계 관계가 없습니다. 따라서 scope1의 job을 취소하여도 scope2는 취소되지 않습니다. 독립적인 코루틴이기 때문이죠. scope2에서 생성된 코루틴은 계속 유지되며 상황에 따라서는 memory leak으로 이어지기 때문에 조심해야 합니다.
Coroutine Exceptions
scope 위에서 자식 coroutine들을 만드는 것은 다 이유가 있고 이점이 있습니다. 그 코루틴들 중 하나가 exception을 내뿜으면 scope 안에 정의된 error handler로 에러가 전달됩니다.
val scope = CoroutineScope(
Dispatchers.IO +
CoroutineExceptionHandler { _, _, _
// exception will be given here
}
)
scope.launch { ... }
scope.launch {
throw Exception()
}
만약 스코프 위에서 여러개의 코루틴을 생성한다면, 그들 중 어떤 하나의 코루틴이exception을 내뿜으면 다른 모든 코루틴을 취소합니다. 아래의 코드를 보시면 세개의 코루틴이 취소되는 것을 볼 수 있습니다.
val scope = CoroutineScope(
Dispatchers.IO +
CoroutineExceptionHandler { _, _ ->
// exception will be given here
}
)
scope.launch { ... } <--- Cancel upon exception
scope.launch { <--- Cancel upon exception
throw Exception()
}
scope.launch { ... } <--- Cancel upon exception
반면 독립적인 코루틴을 생성하여 사용할 경우, 독립적인 코루틴에서 발생한 exception은 자신을 둘러싼 scope가 있다 하더라도 그 scope에 예외를 알리지는 않습니다.
val scope1 = CoroutineScope(
Dispatchers.IO +
CoroutineExceptionHandler { _, _, _
// exception will NOT be given here
}
)
scope1.launch { ... }
scope1.launch {
val scope2 = CoroutineScope(Dispatchers.IO)
scope2.launch {
throw Exception()
}
}
위 코드를 보시면 scope1 안에서 scope2라는 스코프를 새로 만들었고, scope2에서 새로운 코루틴을 생성하였습니다. 따라서 이 코루틴은 standalone, 즉 독립적인 코루틴입니다. 이 독립적인 코루틴 내부에서 exception이 발생하여도 socpe1까지 올라가진 않습니다. 이렇게 될 경우 예상치 못한 버그가 발생할 수 있겠지요. 코루틴 취소와 exception 전파를 위해서라도 위계 관계를 가지도록 코루틴을 생성하는 것이 좋습니다.
Supervisor Job
우리는 위에서 하나의 코루틴 내부에서 일어난 에러가 형제 코루틴들을 취소시키는 예제를 보았습니다. 물론 Supervisor Job을 사용하여 이를 제어할 수도 있습니다. Supervisor Job은 자식 코루틴 중 하나가 exception을 던져도 부모 코루틴은 계속해서 동작할 수 있게끔 할 수 있습니다.
val supervisorJob = SupervisorJob()
val scope = CoroutineScope(Dispatchers.IO + supervisorJob)
val job1 = scope.launch {
while(isActive) {
delay(2000)
}
}
val job2 = scope.launch {
throw Exception()
}
val job3 = scope.launch {
while(isActive) {
delay(2000)
}
}
위 코드에서는 scope
가 SupervisorJob
을 사용하고 있습니다. 그리고 그 scope
에서 3개의 코루틴을 실행시켰고 두 번째 코루틴에서는 Exception을 던지고 있습니다. 이 경우에 이전 예제들과는 달리 다른 코루틴들은 영향을 받지 않고 계속 자신의 일을 유지합니다.
또한 코루틴 라이브러리는 supervisorScope도 지원합니다.
Lifecycle Scope
코루틴간의 관계를 더 잘 이해하기 위해 이때까지 배운 지식을 가지고 처음 보았던 예제로 돌아가봅시다.
class MyViewModel(
repo1: MyRepository1,
repo2: MyRepository2
): ViewModel {
fun getData() {
viewModelScope.launch {
launch { ... }
launch { ... }
repo1.getData()
repo2.getData()
}
}
}
class MyRepository1 {
val coroutineScope = CoroutineScope(Dispatchers.IO)
fun getData() {
coroutineScope.launch { ... }
}
}
class MyRepository2(
val lifecycleScope: LifecycleCoroutineScope
) {
fun getData() {
lifecycleScope.launch(Dispatcers.IO) { ... }
}
}
viewModelScope
는 ViewModel의 extension입니다.
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
))
}
viewModelScope
는 SupervisorJob을 사용하여 scope를 만듭니다. 이것은 이전에 살펴본 것과 같이 자식 코루틴 중 하나가 error를 던진다 하여도 scope의 Job이 취소되지 않음을 의미합니다.
이 예제에서는 viewmodelScope의 Supervisor Job은 3개의 자식 Job을 가지고 있습니다.
fun getData() {
viewModelScope.launch {
launch { ... } <---- Job 1
launch { ... } <---- Job 2
repo1.getData() <--- Standalone Job
repo2.getData() <---- Job 3
}
}
repo1
에 의해 생성된 Job은 viewModelScope와 위계 관계가 없습니다. 따라서 안드로이드 프레임워크에 의해 viewModel의 onDestroy
가 호출될 때, 특별히 repo1
의 코루틴은 직접 취소시켜주어야 합니다(onDetroy되면 viewModelScope만 취소되기 때문).
또한 Job1, Job2, Job3 중 하나가 exception을 내뿜는다해도 부모인 supervisor job을 취소시키진 않습니다.
class MyRepository1 {
val coroutineScope = CoroutineScope(Dispatchers.IO)
fun getData() {
coroutineScope.launch { ... }
}
}
class MyRepository2(val lifecycleScope: LifeCycleScope) {
fun getData() {
lifecycleScope.launch(Dispatcers.IO) { ... }
}
}
MyRepository2는 생성자로 lifecycle scope를 주입받았고, 그 scope에서 코루틴을 생성했습니다. 반면 MyRepository1는 클래스 내부에서 스스로 Scope를 새로 생성했습니다. MyRepository2과 같이 코루틴을 생성하는 방식은 코루틴 생명 주기를 자신을 생성한 view model과 동일하게 가져가기 때문에 이점을 가집니다. 조금 응용하자면 lifecycle socpe에서 새로운 scope를 만들어 이를 repo에 주입시키는 방법도 있겠네요.
결론
이 글이 코루틴을 생성하는 방법이 가지는 의미를 이해하는데 도움이 되었으면 좋겠습니다. 코루틴을 생성할 때면 보통 위계 관계가 생성이 되는데요, 부모, 자식, 손자 관계가 생깁니다. 이는 코루틴의 Job이 어떻게 취소되는지를 결정하기 때문에 중요한 부분입니다.