코틀린 코루틴 2.7 - 코루틴 컨텍스트

Seogi·2025년 7월 4일

Kotlin

목록 보기
11/27

CoroutineContext는 원소나 원소들의 집합을 나타내는 인터페이스이다.

Job, CoroutineName, CoroutineDispatcher와 같은 Element 객체들이 인덱싱된 집합이라는 점에서 Map이나 Set과 같은 컬렉션이랑 비슷한 개념이다.

특이한 점은 각 Element 또한 CoroutineContext라는 점이다.

그리고 컨텍스트에서 모든 원소는 식별할 수 있는 유일한 Key를 가지고 있다. 각 키는 주소로 비교가 된다.

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

    val job: Job = Job()
    val jobElement: CoroutineContext.Element = job
    val jobContext: CoroutineContext = jobElement
}

CoroutineName이나 JobCoroutineContext 인터페이스를 구현한 CoroutineContext.Element를 구현한다.

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

    override fun toString(): String = "CoroutineName($name)"

    companion object Key : CoroutineContext.Key<CoroutineName>
}

interface Job : CoroutineContext.Element {
    companion object Key : CoroutineContext.Key<Job>

    // ...
}

CoroutineName이나 Job은 타입이나 클래스가 아닌 컴패니언 객체이다. 따라서, 찾거나 사용시 바로 사용하면 된다.

CoroutineContext에서 원소 찾기

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

    val coroutineName: CoroutineName? = ctx[CoroutineName]
    // 또는 ctx.get(CoroutineName)
    println(coroutineName?.name) // A name

    val job: Job? = ctx[Job]    // 또는 ctx.get(Job)
    println(job)                // null
}

CoroutineContext는 컬렉션 처럼 get 또는 대괄호를 사용해서 원소를 찾을 수 있다.

원소가 컨텍스트에 있으면 반환되고 없으면 null이 반환된다는 점에서 Map과 비슷하다.

컨텍스트 더하기

fun main() {
    val ctx1: CoroutineContext = CoroutineName("Name1")
    println(ctx1[CoroutineName]?.name) // Name1
    println(ctx1[Job]?.isActive)       // null

    val ctx2: CoroutineContext = Job()
    println(ctx2[CoroutineName]?.name) // null
    println(ctx2[Job]?.isActive)       // 'Active' 상태이므로 true이다.

    // 빌더를 통해 생성되는 잡의 기본 상태가 'Active' 상태이므로 true가 된다.

    val ctx3 = ctx1 + ctx2
    println(ctx3[CoroutineName]?.name) // Name1
    println(ctx3[Job]?.isActive)       // true
}

CoroutineContext의 정말 유용한 기능은 두 개의 CoroutineContext를 합쳐 하나의 CoroutineContext로 만드는 것이다.

다른 키를 가진 두 원소를 더해 만들어진 컨텍스트는 두 가지 키를 모두 가진다.

fun main() {
    val ctx1: CoroutineContext = CoroutineName("Name1")
    println(ctx1[CoroutineName]?.name) // Name1

    val ctx2: CoroutineContext = CoroutineName("Name2")
    println(ctx2[CoroutineName]?.name) // Name2

    val ctx3 = ctx1 + ctx2
    println(ctx3[CoroutineName]?.name) // Name2
}

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

비어 있는 코루틴 컨텍스트

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

    val ctxName = empty + CoroutineName("Name1") + empty
    println(ctxName[CoroutineName]) // CoroutineName(Name1)
}

CoroutineContext는 컬렉션이므로 빈 컨텍스트 또한 만들 수 있다.

빈 컨텍스트는 원소가 없으므로, 다른 컨텍스트에 더해도 아무런 변화가 없다.

원소 제거

fun main() {
    val ctx = CoroutineName("Name1") + Job()
    println(ctx[CoroutineName]?.name) // Name1
    println(ctx[Job]?.isActive)       // true

    val ctx2 = ctx.minusKey(CoroutineName)
    println(ctx2[CoroutineName]?.name) // null
    println(ctx2[Job]?.isActive)       // true

    val ctx3 = (ctx + CoroutineName("Name2"))
        .minusKey(CoroutineName)
    println(ctx3[CoroutineName]?.name) // null
    println(ctx3[Job]?.isActive)       // true
}

minusKey 함수에 키를 넣는 방식으로 원소를 컨텍스트에서 제거할 수 있다.

코루틴 컨텍스트와 빌더

fun CoroutineScope.log(msg: String) {
    val name = coroutineContext[CoroutineName]?.name
    println("[$name] $msg")
}

fun main() = runBlocking(CoroutineName("main")) {
    log("Started")                  // [main] Started

    val v1 = async {
        delay(500)
        log("Running async")        // [main] Running async
        42
    }

    launch {
        delay(1000)
        log("Running launch")       // [main] Running launch
    }

    log("The answer is ${v1.await()}") // [main] The answer is 42
}

CoroutineContext는 코루틴의 데이터를 저장하고 전달하는 방법이다.

부모-자식 관계의 영향 중 하나로 부모는 기본적으로 컨텍스트를 자식에게 전달한다.

fun main() = runBlocking(CoroutineName("main")) {
    log("Started")                      // [main] Started

    val v1 = async(CoroutineName("c1")) {
        delay(500)
        log("Running async")            // [c1] Running async
        42
    }

    launch(CoroutineName("c2")) {
        delay(1000)
        log("Running launch")           // [c2] Running launch
    }

    log("The answer is ${v1.await()}")  // [main] The answer is 42
}

모든 자식은 빌더의 인자에서 정의된 특정 컨텍스트를 가질 수 있다.
인자로 전달된 컨텍스트는 부모로부터 상속받은 컨텍스트를 대체한다.

defaultContext + parentContext + childContext

코루틴 컨텍스트를 계산하는 간단한 공식은 위와 같다.

새로운 원소가 같은 키를 가진 이전 원소를 대체하므로, 자식의 컨텍스트는 부모로부터 상속받은 컨텍스트 중 같은 키를 가진 원소를 대체한다.

디폴트 원소는 어디서도 키가 지정되지 않았을 때만 사용된다.

현재 디폴트로 설정되는 원소는 continuationInterceptor가 설정되지 않았을 때 사용되는 Dispatchers.Default이며, 애플리케이션이 디버그 모드일 때는 CoroutineId도 디폴트로 설정된다.

중단 함수에서 컨텍스트에 접근하기

public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}

suspend fun printName() {
    println(coroutineContext[CoroutineName]?.name)
}

suspend fun main() = withContext(CoroutineName("Outer")) {
    printName()                      // Outer

    launch(CoroutineName("Inner")) {
        printName()                  // Inner
    }

    delay(10)
    printName()                      // Outer
}

중단 함수 사이에 전달되는 컨티뉴에이션 객체는 컨텍스트를 참조하고 있다. 따라서 중단 함수에서 부모의 컨텍스트에 접근하는 것이 가능하다.

coroutineContext 프로퍼티는 모든 중단 스코프에서 사용 가능하며, 이를 통해 컨텍스트에 접근할 수 있다.

0개의 댓글