Cancellation and Exceptions in Coroutines (Part 1) - Coroutines: first things first Medium 아티클 정리
이 글은 Coroutines: first things first : Cancellation and Exceptions in Coroutines (Part 1) 내용을 정리한 글입니다. 틀린 내용이 있을 수 있습니다.
CoroutineScope
CoroutineScope 는 CoroutineScope 내부에서 launch
나 async
함수를 통해 실행된 모든 코루틴을 컨트롤할 수 있다. CoroutineScope 내부에서 실행되는 코루틴들은 scope.cancel
함수를 통해 임의의 시점에서 모두 취소될 수 있다.
앱의 특정 레이어에서 코루틴들의 lifecycle 을 컨트롤 해주고 싶다면 CoroutineScope 를 만들고 사용해야한다. 안드로이드의 경우에는 몇몇 특정 Layer 를 위해서 미리 정의된 scope 들이 있는데, viewModelScope 와 lifecycleScope 가 그것들이다.
CoroutineScope를 만들때에는 CoroutineContext 라는 것을 인자로 전달해야 한다. CoroutineContext 는 코루틴이 실행될 배경에 대한 메타정보를 의미하는데 다음과 같은 요소들의 Set 형태이다.
// Job and Dispatcher are combined into a CoroutineContext which
// will be discussed shortly
val scope = CoroutineScope(Job() + Dispatchers.Main)
val job = scope.launch {
// new coroutine
}
Job
코루틴의 실행을 컨트롤 하는 핸들이다. launch
나 async
와 같은 코루틴 빌더를 통해 코루틴을 생성한 경우에 Job 객체를 반환해주는데, 이를 통해 코루틴 빌더를 이용해서 생성한 코루틴의 lifeCycle 을 관리해줄 수 있다. CoroutineScope 에도 Job 객체를 전달할 수 있는데, 해당 스코프의 Lifecycle 을 관리하는데 사용할 수 있다.
CoroutineContext
앞서 간략히 소개한대로 CoroutineContext 는 코루틴이 어떻게 동작할것인지에 대한 메타 정보를 Set 형태로 나타낸 것이다. 다음과 같은 요소들이 포함되어 있다.
- Job : 코루틴들의 실행을 컨트롤할 Job 객체
- Coroutine Dispatcher : 코루틴이 어느 쓰레드에서 실행될지를 결정한다.
- Coroutine Exception Handler : 내부에서 catch 되지 않는 예외들을 처리할 Exception Handler
- Coroutine Name : 코루틴의 이름
- 그 외 CoroutineContext.Element 인터페이스를 구현한 다른 클래스들이 있을 수 있다.
launch
나 async
함수를 통해 생성한 코루틴의 Context 는 다음과 같은 요소들로 구성된다
- 해당 코루틴의 lifecycle 을 관리할 새로 생성된 Job 객체
- Job 객체 외에 부모 코루틴이나 CoroutineScope 에서 물려받은 다른 CoroutineContext 들
하나의 CoroutineScope 나 Coroutine 내부에서는 다른 Coroutine 들을 생성할 수 있는데, 이들은 일종의 Task Hierarchy 를 생성하게 된다.
val scope = CoroutineScope(Job() + Dispatchers.Main)
val job = scope.launch {
// New coroutine that has CoroutineScope as a parent
val result = async {
// New coroutine that has the coroutine started by
// launch as a parent
}.await()
}
이런 Task Hierarchy 의 root 요소는 보통 CoroutineScope 인 경우가 대부분이다. 다음과 같이 시각화하여 표시할 수 있다.
Job Lifecycle
Job 은 실행상태에 따라서 다음과 같은 Lifecycle State 에 위치하게 된다 : New, Active, Completing Completed, Cancelling, Cancelled
이들 State 를 직접 접근할 수는 없지만 isActive
, isCancelled
, isCompleted
와 같은 값을 통해서 Job 이 어느 State 에 있는지 유추할 수 있다.
만약 coroutine 에서 예외가 발생하거나, cancel
을 통해 작업이 취소된 경우에 Job 의 상태는 Cancelling 상태가 되며 isActive == false
, isCancelled == true
값을 나타낸다. 이후 모든 child coroutine 의 작업 취소가 완료되면, isCompleted == true
가 된다.
Parent Coroutine Context Explained
task hierarchy 상에서 모든 코루틴은 부모를 갖는데 CoroutineScope 나 다른 Coroutine 일 수 있다. 그런데 Parent CoroutineContext 는 실제 부모 CoroutineScope 나 Coroutine 의 Context 와는 다를 수 있다. 왜냐하면 Parent CoroutineContext 는 다음과 같이 계산되기 때문이다.
Parent Coroutine Context : Default Context Value + Inheritance value + argument
- Default Context Value : 몇몇 CoroutineContext 요소들을 Default Value 를 갖는다. 예를 들어 CoroutineDispatcher 는 기본적으로 Dispathcers.Default 값을 갖고, CoroutineName 의 기본값으로는 "Coroutine" 이 설정되어 있다.
- Inheritance value : 부모 CoroutineScope 나 Coroutine 의 Context 를 의미한다.
- argument : 코루틴 빌더 함수를 통해 코루틴을 생성할때 전달한 인자를 의미한다.
CoroutineContext 는 Set 이기 때문에 +
연산자를 통해서 결합이 가능하다. +
연산자 오른쪽에 있는 값이 왼쪽의 Set 에 값을 override 하는 형태로 결합된다.
이제 새로 생성할 Coroutine 의 Parent Coroutine Context 를 알았으니, 새로 생성한 Coroutine 의 Context 를 살펴보자. 새로 생성한 Coroutine 의 Context 는 다음과 같이 계산된다.
New coroutine context = parent CoroutineContext + Job()
만약 위 이미지에서 보여지는 CoroutineScope 에서 코루틴을 다음과 같은 코드로 생성한다면
val job = scope.launch(Dispatchers.IO) {
// new coroutine
}
이 코루틴의 CoroutineContext 와 Parent CoroutineContext 는 어떻게 될까? 다음 정답을 살펴보자.
이 아티클 시리즈 Part3 에서 소개할 내용 중에 일반적인 Job 과는 다른 SupervisorJob 이라는 것이 있다. CoroutineScope 는 SupervisorJob 이라는 것을 Context 로 가질 수 있는데, 예외처리를 조금 다르게 하고 싶을때 사용할 수 있다. 따라서 CoroutineScope 를 부모로 갖는 Coroutine 의 경우 SupervisorJob 을 Parent CoroutineContext 의 Job 으로 가질 수 있다. 그러나 다른 Coroutine 을 부모로 갖는 Coroutine 의 경우에는 항상 Parent CoroutineContext 의 Job은 일반적인 Job 이 된다.
의문 사항
- Parent Coroutine Context Explained 섹션에서 Parent Coroutine Context 가 코루틴 빌더에 전달한 Argument 에 의해 실제 부모의 Context 와는 다른 Context Element 로 계산될 수 있다고 하였는데, 그럼 실제 부모의 Context 도 영구적으로 변경이 된걸까? 테스트 해보자.
결론적으로는 부모의 Context 도 영구적으로 변경이 되는 것은 아니고, 신규 코루틴을 생성할때마다 Parent Coroutine Context 가 다시 계산되어 사용되는 것같다.
다음과 같이 테스트 코드를 작성해보고 실행해봤다.
@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineTest {
@get:Rule
val mainCoroutineRule = MainCoroutineRule()
private val testScope = CoroutineScope(Job() + CoroutineName("Test") + CoroutineExceptionHandler { context, t -> } + Dispatchers.Default)
@Test
fun `코루틴 부모 Coroutine Context 테스트`() = runBlocking {
testScope.launch(Dispatchers.Main) {
println(coroutineContext)
}
testScope.launch {
println(coroutineContext)
}
delay(10000L)
}
}
실행 결과
[CoroutineName(Test), edu.nextstep.camp.calculator.CoroutineTest$special$$inlined$CoroutineExceptionHandler$1@dfddc9a, CoroutineId(2), "Test#2":StandaloneCoroutine{Active}@2b5f4d54, Dispatchers.Main]
[CoroutineName(Test), edu.nextstep.camp.calculator.CoroutineTest$special$$inlined$CoroutineExceptionHandler$1@dfddc9a, CoroutineId(3), "Test#3":StandaloneCoroutine{Active}@481ca5ff, Dispatchers.Default]
두번의 launch
를 통해 생성한 신규 코루틴들의 coroutineContext
를 프린트해보니 parent coroutine context 로부터 물려받은 Dispatcher 가 다른 것을 확인할 수 있다. 코루틴 빌더에 전달한 Dispatcher argument 가 영원히 부모의 Coroutine Context 에 영향을 주지는 않는 것이다.