본문 바로가기
도서/코틀린 코루틴

2장 코루틴 라이브러리 - CoroutineContext

by 안스 인민군 2024. 2. 3.

CoroutineContext란

CoroutineContext는 코루틴이 실행되는 동안 필요한 모든 정보를 담고 있는 요소이다. 이는 다양한 요소들을 결합하여 코루틴의 동작 방식을 정의한다. CoroutineContext는 일종의 맵과 같이 작동하며, 각 요소는 키로 구분된다.

CoroutineContext는 다음과 같은 요소를 포함할 수 있다.

  1. Job: 코루틴의 생명주기를 제어하며, 코루틴의 취소와 완료를 추적한다.
  2. CoroutineDispatcher: 코루틴이 어느 스레드에서 실행될지 결정한다. 예를 들어, Dispatchers.Main은 메인 스레드에서, Dispatchers.IO는 입출력 작업을 위한 백그라운드 스레드에서 코루틴을 실행하도록 지시한다.
  3. CoroutineName: 디버깅을 위해 코루틴에 이름을 부여한다.
  4. CoroutineExceptionHandler: 코루틴에서 발생하는 예외를 처리하는 방법을 정의한다.

CoroutineContext plus 연산자(+) 사용하여 결합할 있으며, 같은 타입의 요소가 여러 있을 경우 나중에 추가된 요소가 이전 요소를 덮어쓴다. 이를 통해 코루틴의 동작 방식을 세밀하게 조절할 있다.


Coroutine Builder, Coroutine Scope, Continuation의 내부 구현을 살펴보면 전부 Coroutine Context 를 포함하고 있는 것을 확인할 수 있다.

Builder

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job

Scope

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

Continuation

public inline fun <T> Continuation(
    context: CoroutineContext,
    crossinline resumeWith: (Result<T>) -> Unit
): Continuation<T>

CoroutineContext 가 분명 중요한 역할을 하는 것 같은데, 해당 개념에 대해 알아보자.

CoroutineContext 인터페이스

CoroutineContext의 정의에 대해 책에서는 이렇게 말하고 있다.

Job, CoroutineName, CoroutineDispatcher와 같은 Element(정보)들의 집합으로 Set과 Map 같은 컬렉션의 개념과 유사하다.

Job

public interface Job : CoroutineContext.Element

CoroutineName

public data class CoroutineName(
    val name: String
) : AbstractCoroutineContextElement(CoroutineName)

public abstract class AbstractCoroutineContextElement(public override val key: Key<*>) : Element

CoroutineDispatcher

public abstract class CoroutineDispatcher :
    AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor
  
public interface ContinuationInterceptor : CoroutineContext.Element

CoroutineContext.kt

public interface CoroutineContext {

    public operator fun <E : Element> get(key: Key<E>): E?

    public fun <R> fold(initial: R, operation: (R, Element) -> R): R

    public operator fun plus(context: CoroutineContext): CoroutineContext

    public interface Element : CoroutineContext {

        public val key: Key<*>

        public override operator fun <E : Element> get(key: Key<E>): E?

        public override fun <R> fold(initial: R, operation: (R, Element) -> R): R 

        public override fun minusKey(key: Key<*>): CoroutineContext 
    }
}

가장 먼저 확인할 수 있는 것은, 모든 원소를 식별할 수 있는 Key를 가지고 있어 고유한 Key Element들을 구별하는 것을 알 수 있다.

fun main() {
    val name: CoroutineName = CoroutineName("name")
    val element: CoroutineContext.Element = name
    val context: CoroutineContext = name

    val job: Job = Job()
    val jobElement: CoroutineContext.Element = job
    val jobContext: CoroutineContext = jobElement
    
    val scope = CoroutineScope(Job() + Dispatchers.Main)
    // CoroutineScope의 생성자는 CoroutineContext가 필요하고, 
    // Job 과 Dispatchers는 CoroutineContext를 상속받으므로 operator 를 사용할 수 있다.
}

CoroutineContext에서 원소 찾기

CoroutineContext 에서 특정 원소를 찾기 위한 방법으로, 제공하는 함수 중 get 함수를 사용해 Key 로 찾을 수 있다.

operator function 이므로 대괄호를 이용해 사용할 수 있고, 만약 원소가 없을 경우 null이 반환된다.

fun main() {
    val ctx: CoroutineContext = CoroutineName("A name")

    val coroutineName: CoroutineName? = ctx[CoroutineName]
    println(coroutineName?.name) 

    val job: Job? = ctx[Job]
    println(job) // Key는 CoroutineName이다.
}
// 결과
A name
null

참고로..

클래스의 이름이 컴패니언 객체에 대한 참조로 사용되는 코틀린의 특징 때문에 ctxt[CoroutineName]는 ctx[CoroutineName.key]가 됩니다.

컨텍스트 더하기

CoroutineContext의 유용한 기능 중 하나로, 두 Context를 더할 수 있다. 이와 같은 경우 Context는 두 가지의 Key 를 가질 수 있습니다.

fun main() {
    val job = Job()
    val dispatcher = Dispatchers.Main
    val scope: CoroutineContext = job + dispatcher

    println(scope)
}
// 결과
[JobImpl{Active}@64bfbc86, Dispatchers.Main[missing, cause=java.lang.RuntimeException: Stub!]]

Context에 같은 키를 가진 또다른 원소가 더해지면 맵처럼 새로운 원소가 기존 원소를 대체한다.

fun main() {
    val coroutineContext1 = CoroutineName("1")
    val coroutineContext2 = CoroutineName("2")
    val coroutineContext3 = coroutineContext1 + coroutineContext2

    println(coroutineContext3[CoroutineName]?.name)
}
// 결과
2

비어 있는 코루틴 컨텍스트

CoroutineContext는 컬렉션으로 빈 컬렉션 또한 만들 수 있다.

해당 Context에는 원소가 없으므로 다른 Context를 더해도 아무런 변화가 없다.

fun main() {
    val empty : CoroutineContext = EmptyCoroutineContext
    println(empty[CoroutineName])
    println(empty[Job])
}
// 결과
null
null

코루틴 컨텍스트와 빌더

CoroutineContext는 기본적으로 부모-자식의 영향을 받았다. 따라서 자식 context 는 부모 context를 상속받는다고 할 수 있다.

fun main() {
    runBlocking(CoroutineName("hello")) {
        launch(CoroutineName("word")) {
            println(this.coroutineContext[CoroutineName]?.name) 
        }
        println(this.coroutineContext[CoroutineName]?.name) 
    }
}
// 결과
hello
word