
저번 글에서 Coroutine Builder를 중점적으로 다뤘다. 이번 글에서는 마르친 모스카와의 Kotlin Coroutine 7장의 내용을 기반으로 Coroutine Context를 살펴보기로 한다.
저번 글에서 다룬 Coroutine Builder의 정의를 보면, 별 다른 설명 없이 넘어가긴 했으나 첫 번째 파라미터가 Coroutine Context임을 알 수 있다.
fun <T> runBlocking(
context: CoroutineContext = EmptyCoroutineContext, /* Coroutine Context*/
block : suspend CoroutineScope.() -> T
) : T
fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
) : Job
fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
) : Deferred<T>
CoroutineScope의 정의를 자세히 확인해보자.
public interface CoroutineScope {
public val coroutineContext : CoroutineContext
}
CoroutineScope가 마치 CoroutineContext를 감싸는 Wrapper처럼 보인다. Continuation도 다시 복기해보자.
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
Continuation 역시 CoroutineContext를 포함하고 있다. 우연의 일치일까?
CoroutineContext란 원소나 원소들의 집합을 나타내는 인터페이스로 정의할 수 있다.
Job이나 CoroutineName, Dispatcher 같은 Element 객체들이 인덱싱된 집합이 CoroutineContext인데, 마치 set, map과 같은 Collection과 개념이 유사하다.
다만 특이한 점은, CoroutineContext Interface의 Element 또한 CoroutineContext라는 것이다.
따라서, Collection 내 모든 원소는 그 자체만으로도 Collection이라고 볼 수 있다.
책에서는 머그잔을 예시로 든다.
- 머그잔은 자체로도 하나의 원소다.
- 머그잔은 단 하나의 원소(액체류)를 포함하는 Collection이기도 하다.
- 머그잔 하나를 추가하면 두 개의 원소를 가진 Collection이 된다.
아래 예시에서 볼 수 있듯, Contxt의 지정/변경을 편리하게 하고자 CoroutineContext의 모든 원소가 CoroutineContext로 되어있음을 확인할 수 있다.
launch(CoroutineName("Name1")) { ... }
launch(CoroutineName("Name2") + Job()) { ... }
여기서 Context 내 모든 원소는 식별가능한 Primary Key를 갖고 있으며, 해당 키는 주소로 비교된다. 이 때, 위의 CoroutineName이나 Job은, CoroutineContext를 구현하는 CoroutineContext.Element를 구현하게 된다.
fun main() {
val name: CoroutineName = CoroutineName("A name")
val element: CoroutineContext.Element = name
val context: CoroutineContext = element
...
}
CoroutineContext는 Collect과 유사하다고 소개했었다. 그렇기에 get을 이용해 Primary Key를 가진 원소를 찾는 것도 가능하다. Kotlin에서 get 메서드를 활용할 때 대괄호([])를 사용할 수도 있기에, 대괄호를 이용한 방식도 가능하다. 원소가 Context에 있다면 반환되고, 그렇지 않다면 null이 반환된다.
fun main() {
val ctx: CoroutineContext = CoroutineName("A name")
val coroutineName = ctx[CoroutinName] /* ctx.get(CoroutineName)도 가능하다. */
...
val job: Job? = ctx[Job]
println(job) /* null */
}
상기 예시에서 CoroutineName은 있지만 Job은 없었다. 따라서 null이 반환됨을 알 수 있다.
CoroutineName을 찾으려면 CoroutineName을 사용하기만 하면 되는데, CoroutineName이 타입이나 클래스가 아니라 컴패니언 객체이기 때문이다. Kotlin에서는 클래스 이름이 컴패니언 객체에 대한 참조로 사용되기에 ctx[CoroutineName]은 ctx[CoroutineName.key]가 된다.
CoroutineContext의 유용한 기능은, 두 개의 CoroutineContext를 합쳐서 단일 CoroutineContext로 만들 수 있다는 것이다.
각 CoroutinContext는 Key를 가진다고 앞서 소개했는데, 둘을 합치면 어떻게 될까?
정답은 간단하다, 두 가지 Key를 모두 갖게 된다.
fun main() {
val ctx1: CoroutineContext = CoroutineName("Name1") /* ctx1[CoroutineName]?.name == Name1 */
val ctx2: CoroutineContext = Job() /* ctx2[Job]?.isActive() == true*/
val ctx3 = ctx1 + ctx2 /* 이게 가능하다! */
/* ctx3은 name과 job을 모두 가진다. */
}
그럼 같은 Key를 가진 CoroutineContext를 합치면 어떻게 될까? 새 원소가 기존 원소를 대체한다.
fun main() {
val ctx1: CoroutineContext = CoroutineName("Name1") /* ctx1[CoroutineName]?.name == Name1 */
val ctx2: CoroutineContext = CoroutineName("Name2") /* ctx2[CoroutineName]?.name == Name2 */
val ctx3 = ctx1 + ctx2 /* ctx3[CoroutineName]?.name == Name2 */
}
CoroutineContext는 앞서 소개했듯 Collection이다. 따라서, 빈 Context를 만들 수도 있다. 빈 Context는 원소가 없기에, 다른 Context에 더해도 변화가 없다.
minusKey 함수에 Key를 넣는 방식으로 해당 원소를 Context에서 제가할 수 있다.
fun main() {
val ctx1 = CoroutineName("Name1") /* ctx1[CoroutineName]?.name == Name1 */
val ctx2 = ctx1.minusKey(CoroutineName)
}
CoroutineContext는 Coroutine의 데이터를 저장하고, 전달하는 방법이다. 보통 부모는 자식에게 Context를 전달하므로, 자식은 부모로부터 Context를 상속받는다고 볼 수 있다.
모든 자식은 Builder의 인자에서 정의된 특정 Context를 가질 수도 있는데, 그런 경우, 해당 인자로 전달된 Context가 부모로부터 상속받은 Context를 대체하게 된다.
CoroutineContext를 계산하는 공식은 아래와 같다.
defaultContext + parentContext + childContext
아까 소개했듯 새 원소가 동일 Key를 가진 기존 원소를 대체하기에, ChildContext가 parentContext 중 동일 Key를 가진 원소를 대체한다. defaultContext는 child든 parent든 해당 key가 지정되지 않은 경우 사용된다.
CoroutineScope는 Context를 접근할 때 사용하는 coroutineContext 프로퍼티를 갖고 있다. 중단함수에서 어떻게 접근할 수 있는 걸까?
앞선 글에서 보았듯이, 중단 함수 간 전달되는 Continuation 객체가 Context를 참조하고 있다. 그렇기에 중단함수에서 parent context에 접근하는 것이 가능한 것이다.
따라서, coroutineContext 프로퍼티는 모든 중단 스코프에서 사용가능하며, 그걸 매개로 Context에 접근한다.
Coroutine Context를 Custom하게 만들 수 있을까? 흔치는 않지만 의외로 방법은 간단하다. 가장 쉬운 방법은 CoroutineContext.Element Interface를 구현하는 클래스를 내가 직접 만드는 것이다.
다만, 책의 저자의 경우 테스트 환경과 프로덕션 환경에서 서로 다른 값을 주입하고자 할 때 말고는 일반적으로 사용하지 않는다고 소개했기에 나 역시도 이번 글에서는 다루지 않는다.
이번 글을 다루는 내용이 방대해 목차도 세부적으로 나눴고, 무엇보다도 길이가 길다.
이번 글을 요약하자면 아래와 같다.
- CoroutineCotext는 우리가 흔히 사용하는 map, set 같은 Collection과 개념적으로 비슷하다.
- CoroutineContext는 Element Interface의 인덱싱 된 집합이며, 그 Element 역시 CoroutineContext이다.
- CoroutineContext 내 원소는 모두 Primary Key를 가진다.
- CoroutineContext는 Coroutine 관련 정보를 그룹화하고 전달하는 일반적인 방법이다.
꽤 깊고, 어려운 부분이 많으니 여러 차례 읽어볼 것을 권유한다.
다음 장에서는 CoroutineContext 중 가장 필수적인 것을 다룰 예정이다.