본문 바로가기

Android

[Kotlin - Coroutine] Coroutine Context 와 Scope

이 글은 아래 글을 번역한 것입니다. 예제 실행이 곧바로 되지 않는 관계로 일부 예제는 변형되었으나 내용은 동일합니다.

elizarov.medium.com/coroutine-context-and-scope-c8b255d59055

 

실제로 거의 동일한 사물을 두고서도, 그 사용목적이 다를 때는 원래와는 다른 이름을 붙이는 경우가 많습니다. 사용목적에 따라, 뱃사람들은 밧줄 하나를 부를 때도 목적에 따라 십여개의 다른 이름을 쓰는데 실질적으로는 같은 밧줄인 경우가 많습니다. (Wikipedia on Hindley-Milner type system)

 

 

Coroutine Context and Scope

Kotlin Coroutines have a context. There is a also a concept of coroutine scope that looks very much like a context. What’s the difference?

elizarov.medium.com

 

모든 코틀린의 코루틴(Coroutine)은 컨텍스트(Context)를 가지고 있는데, 이것은 CoroutineContext 인터페이스를 구현한 객체입니다. 컨텍스트는 다양한 구성요소(Element)를 가지고 있고, 현재의 코루틴 컨텍스트는 coroutineContext 속성을 통해 얻을 수 있습니다.

 

import kotlinx.coroutines.runBlocking

fun main() = runBlocking<Unit> {
    println("My context is: $coroutineContext")
}

 

코루틴 컨텍스트는 불변(immutable) 객체이지만, plus 연산자를 이용하여 구성요소를 추가할 수 있습니다. 마치 Set 에 구성요소를 추가하는 것과 비슷합니다.

 

import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.runBlocking

fun main() = runBlocking<Unit> {
    println("My context is: $coroutineContext")
    println("A context with name: ${coroutineContext + CoroutineName("Test")})")
}

결과:

불변객체인 coroutineContext 에 이름 속성 "Test"가 추가되었습니다.

My context is: [BlockingCoroutine{Active}@67424e82, BlockingEventLoop@42110406]
A context with name: [BlockingCoroutine{Active}@67424e82CoroutineName(Test), BlockingEventLoop@42110406])

 

코루틴 하나는 하나의 잡(Job) 으로 볼 수 있습니다. 잡은 코루틴의 라이프사이클, 취소, 그리고 부모-자식 관계를 책임집니다. 현재의 잡이 무엇인지는 현재 코루틴의 컨텍스트를 호출해서 확인할 수 있습니다. 

My context is: [BlockingCoroutine{Active}@67424e82, BlockingEventLoop@42110406]

 

다른 중요한 것으로는 CoroutineScope라는 인터페이스가 있습니다. 오직 하나의 속성만을 가지고 있는데, val coroutineContext: CoroutineContext 가 바로 그것입니다. 다른 것은 아무것도 없이 오직 컨텍스트 하나만을 가지고 있습니다. 그렇다면 이것이 컨텍스트 그 자체와는 어떻게 다른 걸까요? 컨텍스트와 스코프의 차이점은 그것들을 어디에 쓸지 "의도된 목적"이 다르다는 점입니다.

 

코루틴은 일반적으로 코루틴 빌더 중 launch 로 실행되는 경우가 많습니다.

fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
// ...
): Job

이것은 CoroutineScope의 확장함수이고, CoroutineContext를 파라메터로 받습니다. 결과적으로는 원래 있었던 스코프, 그리고 파라메터로 받은 CoroutineContext 이렇게 "두개의" 코루틴 컨텍스트를 가지고 있습니다. (왜냐하면 스코프는 단순히 컨텍스트의 참조이기 때문) 

 

launch는 이 파라메터로 뭘 하는 걸까요?  launch는 plus 연산자를 이용하여 그 구성요소를 하나로 모은 집합(Set) 을 만듭니다. 이것은 컨텍스트 파라메터가 스코프 내의 다른 구성요소 보다 더 높은 우선순위를 가지게 하는데 이용됩니다.

 

"결과물인 컨텍스트"는 "새로운 코루틴"을 만드는 데 사용되지만, "새로운 코루틴"의 컨텍스트가 아닙니다. "새로운 코루틴"의 "부모 컨텍스트"입니다. 새로운 컨텍스트는 자신의 자식 Job 인스턴스를 만들고 (이 컨텍스트의 Job을 부모로 이용) 그 "자식 컨텍스트"를 부모 컨텍스트 + 자신의 job 으로 정의합니다.

 

 

(역주:

- CoroutineScope.launch를 실행하게 되면, 원래 CoroutineScope 가 가지고 있던 스코프가 하나 있습니다.

- 추가로 CoroutineContext 를 파라메터로 받아서, 결과적으로는 두 개의 CoroutineContext가 존재하게 됩니다.

- launch 내에서 이 두개를 plus 연산자로 더해서 새로운 코루틴 컨텍스트를 만드는데, 

- 이 새로운 코루틴 컨텍스트는 새롭게 만들어지는 코루틴의 "부모 컨텍스트" 입니다. 새롭게 만들어지는 코루틴의 컨텍스트가 아닙니다.

- 새 코루틴은 자식 Job 인스턴스를 만드는데, 이 새 Job의 부모는 부모의 Job 입니다.

- Child context 도 만들어지는데, Parent context 와 Child Job을 더한 결과 값입니다.)

 

 

launch, 그리고 모든 다른 코루틴빌더에서의 CoroutineScope 리시버의 목적은 새로운 코루틴이 실행되었을 때, 그 스코프를 참조하는 것입니다. 관례상, CoroutineScopeJob을 가지고 있는데, 이 Job은 새로운 코루틴이 실행되면 그것의 부모가 됩니다. (GlobalScope의 exception을 이용하면 그걸 피할 수 있긴 합니다)

 

한편 launch에서의 context: CoroutineContext 파라메터의 목적은, 원래 가지고 있었던 "부모에게 상속받은 구성요소를 오버라이드"하는 것입니다. 예를 들면 아래와 같습니다.

 

launch(CoroutineName("child")) {
    println("My context is $coroutineContext}")        
}

관례에 따라, 우리는 일반적으로는 Job을 launch의 파라메터로 넘기지는 않습니다. 이렇게 하면 둘 사이의 부모-자식관계가 깨지기 때문입니다. (NonCancellable job 을 써서 명시적으로 부모-자식 관계를 깨는 것을 의도한 경우는 제외). 

 

launch 내의 block이 그 리시버로서 CoroutineScope를 정의했다는 것에 주목하세요.

fun CoroutineScope.launch(
// ...
block: suspend CoroutineScope.() -> Unit
): Job

모든 코루틴 빌더가 따르는 관례에 따라, 해당 스코프의 coroutineContext 속성은 그 코루틴의 블록 내에서 실행되는 context와 동일합니다.

 

fun main() = runBlocking<Unit> {
    launch { scopeCheck(this) }
}

suspend fun scopeCheck(scope: CoroutineScope) {
    println(scope.coroutineContext === coroutineContext)
}

결과 값:

true

 

이 방법으로, 만약 우리가 확인되지 않은 coroutineContext를 코드 내에서 발견한다면 그것이 원래 있었던 top-level property coroutineContext인지, 아니면 해당 스코프의 coroutineContext 인지 (scope’s property) 헷갈릴 필요가 없습니다. 이 둘은 설계상 동일하기 때문입니다.

 

IntelliJ IDEA는 편리하게도 코루틴 빌더 내의 다른 컨텍스트에 대해 this: CoroutineScope 라는 힌트를 표시해주기 때문에, 일반적인 코드 블록과 한 눈에 구분할 수 있습니다. 게다가, 이 새로운 CoroutineScope는 언제나 새로운 Job을 그 컨텍스트 내에 가지고 있습니다. 그래서 launch {...} 같은 코드를 소스코드를 명시적인 리시버 없이 호출하는 것을 보았다면, 어떤 스코프에서 그 코드가 호출되었는지를 확인하고 싶다면 "this: CoroutineScope" 라고 표시되어 있는 외부 블록을 찾으면 됩니다.

 

context 와 scope 가 "실질적으로 같은" 것이기 때문에, 억지로 하려고 한다면 GlobalScope를 쓰지 않고 현재 coroutineContext를 현재의 CoroutineScope에 파라메터로 전달하는 식으로 아래와 같이 실행할 수는 있긴 합니다.

suspend fun doNotDoThis() {
    CoroutineScope(coroutineContext).launch {
        println("I'm confused")
    }
}

이렇게 하지 마세요! 이렇게 하면 코루틴이 불투명하고 암시적으로, 외부의 job을 캡쳐하게 되는데 function의 signature에는 아무런 표시도 안됩니다. 코루틴이라는 것은 당신의 다른 작업과 동시에 실행되는 한 조각의 작업으로서, 반드시 명시적으로 실행되어야 합니다.

 

만약 함수가 리턴한 다음에도 계속 실행되는 코루틴을 실행하고 싶다면, CoroutineScope의 확장함수를 만들거나, scope: CoroutineScope를 파라메터로 넘겨서 의도를 명확하게 명시하세요. 이 함수를 suspend 로 만들면 안됩니다.

 

fun CoroutineScope.doThis() {
    launch { println("I'm fine") }
}

fun doThatIn(scope: CoroutineScope) {
    scope.launch { println("I'm fine, too") }
}

suspend 함수는 반면, 설계 상 논블로킹이고, 현재 실행중인 작업과 어떠한 사이드이팩트도 발생시키지 않습니다. suspend 함수는, 자기의 모든 작업을 끝낸 뒤 호출자에게 결과 값을 리턴하기 전에는 계속 대기할 수 있고, 그래야만 합니다.