CoroutineScope

CoroutineScope 는 Coroutine 의 Scope 를 정의하는 용도의 타입이며 interface 로 선언되어 있습니다.

structured concurrency 라고 해서 중첩된 Coroutine Scope 를 가질 수도 있는데 여기에 대해서는 이 문서가 아닌 별도의 문서에서 정리하겠습니다.

CoroutineScope 를 만들때에는 보통 Coroutine Builder 라고 불리는 함수를 사용하는데 대부분 CoroutineScope interface 에 확장함수로 추가된 함수이며 withContext(), launch(), async() 등이 있습니다. withContext(), launch(), async() 확장함수의 정의는 Builders.common.kt (opens in a new tab) 에 정의되어 있습니다.


CoroutineScope 와 CoroutineContext, CoroutineDispatcher

CoroutineDispatcher 역시도 CoroutineContext 의 일종입니다. CoroutineDispatcher 의 정의를 살펴보면 아래와 같습니다.

abstract class CoroutineDispatcher : AbstractCoroutineContextElement, ContinuationInterceptor

AbstractCoroutineContextElement 라는 것은 CoroutineContext.Element 를 상속하고 있는 일종의 CoroutineContext 타입의 객체입니다.

CoroutineScope 는 자기 자신의 Scope 를 만들 때 CoroutineContext 를 소유한 채로 생성합니다. 실행 시에 필요한 Continuation 등과 같은 문맥을 보관하고 있어야 중단했다가 다시 재개할 수 있기 때문입니다. 이렇게 문맥을 소유해야 하기에 CoroutineScope 는 아래와 같은 방식으로 CoroutineContext 를 소유한 채로 CoroutineScope 가 생성됩니다.

e.g. 코루틴 스코프 빌더 함수 withContext

suspend fun <T> withContext(context: CoroutineContext, block: suspend CoroutineScope.() -> T): T(source)

코루틴 스코프를 생성하는 대표적인 빌더 함수인 withContext() 를 살펴보면 위에서 이야기했듯 첫번째 인자로는 CoroutineContext 를 받습니다. 그리고 마지막 인자는 람다인데, 조금만 더 자세하게 생각해보면 CoroutineScope 라는 interface 에 대한 익명의 객체를 생성해서 람다 바디로 넘겨준다는 사실을 알 수 있습니다.

withContext() 의 경우에는 연산의 결과를 넘겨주는 코루틴 스코프 생성 용도의 빌더함수입니다. 비슷한 함수로는 coroutineScope()가 있습니다.

UML - CoroutineScope 와 CoroutineContext 의 연관관계

CoroutineScope 는 내부에 CoroutineContext 를 잡고 움직입니다. 당연하게도, 스코프를 관리하는 데에 있어서 Context(문맥)이 필요하기 때문입니다. 그리고 CorutineScope 는 interface 이기에 직접 객체 생성이 불가능한데, 실제로는 구체타입인 ContextScope 객체로 생성하며, ContextScope 객체를 생성하는 함수는 CoroutineScope.kt 내에 CoroutineScope(context) 라는 함수에서 수행합니다.

코틀린은 특이하게도 interface 안에서 생성자처럼 보일 수 있는 함수들을 선언하고 특정 기본 구현체를 생성하는 객체 생성 함수들을 interface 내에서 제공하도록 구현하는게 관례처럼 되어 있는 라이브러리들이 많습니다.

이렇게 ContextScope 객체를 만들 때에는 Job 인스턴스가 필요합니다. 당연하게도 코루틴을 구동시키려면 Job 이 필요하겠죠. 이 때 Job 인스턴스는 Job.kt 파일 내의 Job(CoroutineContext) 함수를 통해 생성합니다. 실제로 내부적으로는 콜 스택을 한 번 더 타서 JobSupport.kt 내의 JobImpl(parent) 라는 함수를 통해 실질적인 객체를 생성합니다.

이렇게 생성한 Job 객체는 이미 CoroutineScope 내에 존재하는 coroutineContext 객체에 병합해둡니다. 여기까지가 CoroutineScope 객체가 생성하는 동안 내부적으로 벌어지는 일 입니다.


필요한 내용만 요약해보면 이렇습니다.

  • CoroutineScope 내에는 CoroutineContext 정보가 필요한데, 따라서 CoroutineContext 타입에는 항상 멤버 필드 coroutineContext 가 있다.
  • CoroutineScope 는 interface 이기에 구체타입으로 객체를 생성해야 하는데, 내부 구현상으로 기본적으로는 ContextScope 객체를 이용해 CoroutineScope 타입이 생성된다.
  • ContextScope 라는 구체 타입의 CoroutineScope 가 생성될 때 Job 객체를 주입받아서 이것을 멤버 필드인 coroutineContext 에 초기화 한다. Job 객체 역시 상위 타입이 CoroutineContext 이기에 coroutineContext 에 저장하는 것이 가능하다.

CoroutineScope 빌더 함수들

코틀린의 kotlinx-coroutines-core 에서는 launch, async, withContext, runBlocking 등과 같은 함수들을 보통 코루틴 빌더 함수라고 부릅니다. 복잡한 CoroutineContext 생성 및 기타 설정들을 직접 작성하지 않고 라이브러리에 내장된 빌더 성격의 함수를 사용하도록 유도한 것입니다.

잘알려진 코루틴 빌더 함수들은 아래와 같습니다.

  • withContext : 코루틴 컨텍스트를 변경하는 함수입니다. 다른 스레드에서 코드를 실행하거나, 특정 디스패처에 작업을 보내는 것이 가능합니다.
  • delay : 일정 시간 동안 코루틴을 일시 중단하는 함수입니다. 주로 테스트나 간단한 딜레이를 구현할 때 사용됩니다.
  • async: 비동기 작업의 결과를 반환하는 코루틴을 생성합니다. 생성된 코루틴은 Deferred 객체를 반환하며, 이를 통해 결과를 가져올 수 있습니다.
  • await: Deferred 객체의 결과를 기다리는 함수로, async 함수로 생성된 Deferred 객체의 결과를 가져올 때 사용됩니다.
  • launch: 백그라운드에서 비동기적으로 새로운 코루틴을 실행하는 함수로, 반환 값이 없는 Fire-and-forget 스타일의 코루틴을 생성합니다.
  • runBlocking: 새로운 블록 내에서 코루틴을 실행하는 함수로, 주로 메인 함수나 테스트 코드에서 사용되며, 코루틴을 기다리는 동안 블로킹을 유지합니다.
  • coroutineScope: 지정된 블록 내에서 새로운 코루틴 스코프를 생성하는 함수로, 지정된 블록 내의 코루틴이 완료될 때까지 대기합니다.
  • select: 여러 개의 중단 가능한 조건을 동시에 처리할 수 있는 함수로, 먼저 발생하는 이벤트를 처리하고 나머지는 무시합니다.
  • supervisorScope: 자식 코루틴이 실패하더라도 부모 코루틴이 중단되지 않도록 하는 슈퍼바이저 스코프를 생성하는 함수로, 자식 코루틴의 실패를 격리합니다.

이전문서인 suspend 함수의 개념을 다룬 문서에서 이야기했듯, CoroutineScope 빌더 함수들은 마지막 인자가 람다입니다. 코틀린에서는 마지막 인자가 람다일 경우 함수 인자를 생략하고 launch{...} 처럼 표현 가능합니다.

그래서 대부분의 launch, async 같은 함수들이 함수 인자도 없이 {...} 을 사용하는 것입니다.

Coroutine 스코프 생성

코루틴 스코프는 launch, await , coroutineScope 등과 같은 코루틴 빌더를 이용해서 생성할 수도 있겠지만 단순하게 CoroutineScope.kt 내에 정의된 CoroutineScope 라는 함수를 이용해서도 생성할 수 있습니다.

package io.chagchagchag.demo.kotlin_coroutine.coroutine_scope
 
import io.chagchagchag.demo.kotlin_coroutine.helper.logger
import kotlinx.coroutines.CoroutineScope
import kotlin.coroutines.EmptyCoroutineContext
 
fun main(){
  val log = logger()
 
  val coroutineScope = CoroutineScope(EmptyCoroutineContext)
 
  log.info("방금 생성한 코루틴 스코프 = $coroutineScope")
  log.info("코루틴 스코프 클래스 = ${coroutineScope.javaClass.simpleName}")
}
 

출력결과

08:20:37.108 [main] INFO io...helper.LoggingObject -- 방금 생성한 코루틴 스코프 = CoroutineScope(coroutineContext=JobImpl{Active}@6615435c)
08:20:37.113 [main] INFO io...helper.LoggingObject -- 코루틴 스코프 클래스 = ContextScope

Process finished with exit code 0

launch

launch{...} 는 CoroutineScope 를 반환합니다. 그리고 Java 의 CompletableFuture 처럼 join() 함수를 사용할 수 있습니다.

package io.chagchagchag.demo.kotlin_coroutine.coroutine_scope
 
import io.chagchagchag.demo.kotlin_coroutine.helper.logger
import kotlinx.coroutines.*
import kotlin.coroutines.EmptyCoroutineContext
 
fun main(){
  val log = logger()
 
  runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    log.info("scope 에 생성된 Job = ${scope.coroutineContext[Job]}")
 
    log.info("launchedJob 을 시작하겠습니다.")
    
    val launchedJob = scope.launch {
      delay(1000)
      val currentContext = this.coroutineContext
      val scopeClassName = this.javaClass.simpleName
      val currentJob = this.coroutineContext[Job]
      log.info("this.coroutineContext = $currentContext")
      log.info("class name = $scopeClassName")
      log.info("현재 Job 의 부모(parent) = ${currentJob?.parent}")
    }
 
    launchedJob.join()
    log.info("launchedJob 을 종료합니다.")
  }
 
}
 

launch 를 통해 CoroutineScope 를 만들고 이 CoroutineScope 가 중지했다가 재개하는 함수의 예제입니다. 부가적으로 CoroutineScope 내에는 무엇이 있는지 확인하기 위해 아래 요소들을 출력해봅니다.

  • this.coroutineContext
  • this.javaClass.simpleName
  • this.coroutineContext[Job]

출력결과

08:42:26.917 [main] INFO io...helper.LoggingObject -- scope 에 생성된 Job = JobImpl{Active}@6f1fba17
08:42:26.956 [main] INFO io...helper.LoggingObject -- launchedJob 을 시작하겠습니다.
08:42:27.973 [DefaultDispatcher-worker-1] INFO io...helper.LoggingObject -- this.coroutineContext = [StandaloneCoroutine{Active}@41f230d0, Dispatchers.Default]
08:42:27.973 [DefaultDispatcher-worker-1] INFO io...helper.LoggingObject -- class name = StandaloneCoroutine
08:42:27.973 [DefaultDispatcher-worker-1] INFO io...helper.LoggingObject -- 현재 Job 의 부모(parent) = JobImpl{Active}@6f1fba17
08:42:27.974 [main] INFO io...helper.LoggingObject -- launchedJob 을 종료합니다.

async

참고 :


async{...}Deferred (opens in a new tab) 객체를 반환합니다. 이 Deferred 객체는 Job interface 를 extends 하는 interface 입니다. 그리고 async{...} 빌더의 내부를 살펴보면 아래와 같이 LazyDeferredCoroutine 또는 DeferredCoroutine 이라는 구체 타입의 인스턴스를 리턴합니다.

Builders.common.kt (opens in a new tab)

public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T> {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyDeferredCoroutine(newContext, block) else
        DeferredCoroutine<T>(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

이 중 DeferredCoroutine 의 상속관계를 찾아서 위로 올라가면 차례로 AbstractCoroutine 클래스, CoroutineScope 클래스를 만나게 됩니다.

즉, async{...} 가 반환하는 Deferred 객체는 CoroutineScope 로 일반화해서 처리 가능하다는 의미입니다.

아래는 launch{...} 라는 코루틴 빌더의 예제입니다.

package io.chagchagchag.demo.kotlin_coroutine.coroutine_scope
 
import io.chagchagchag.demo.kotlin_coroutine.helper.logger
import kotlinx.coroutines.*
import kotlin.coroutines.EmptyCoroutineContext
 
fun main(){
  val log = logger()
 
  runBlocking {
    val scope = CoroutineScope(EmptyCoroutineContext)
    log.info("현재 Job = ${scope.coroutineContext[Job]}")
 
    log.info("시작")
    val deferred = scope.async {
      delay(1000)
      val currentContext = this.coroutineContext
      val currentContextClassName = this.javaClass.simpleName
      val whatIsParent = this.coroutineContext[Job]?.parent
 
      log.info("현재 코루틴 컨텍스트 = $currentContext")
      log.info("현재 코루틴 컨텍스트의 클래스명 = $currentContextClassName")
      log.info("현재 Job 의 부모 = $whatIsParent")
 
      25000
    }
 
    log.info("코루틴이 반환한 값 = ${deferred.await()}")
    log.info("끝")
  }
}

마치 javascript 의 async, await 을 사용하는 것과 비슷한 코드입니다.

출력결과를 확인해보면 async 가 생성한 코루틴 컨텍스트는 DeferredCoroutine 입니다. 자신을 생성한 부모 스코프를 참조할 수 있는지 확인하기 위해 this.coroutineContext[Job]?.parent 을 출력해보면 정확하게 출력이 됩니다.

코루틴 컨텍스트는 자신의 부모스코프가 무엇인지 알수 있다는 사실을 알 수 있습니다. 이것은 자신이 호출된 바로 전 단계의 스코프를 참조할 수 있다는 의미이기 때문에, 조금 복잡한 호출구조의 비동기 프로그래밍을 할 때 중요한 개념이 됩니다.

중요한 점은 async(), await() 을 하는 동안 프로그램이 블로킹 상태가 아니라는 점입니다. await() 을 하는 동안 코틀린은 내부적으로 다른 코루틴을 조율하면서 실행시키고 있고 async 내부의 작업이 끝나는 순간 await() 을 하는 작업을 완료하게 됩니다.


출력결과

09:03:48.916 [main] INFO io...helper.LoggingObject -- 현재 Job = JobImpl{Active}@6f1fba17
09:03:48.925 [main] INFO io...helper.LoggingObject -- 시작
09:03:49.971 [DefaultDispatcher-worker-1] INFO io...helper.LoggingObject -- 현재 코루틴 컨텍스트 = [DeferredCoroutine{Active}@1b2786cb, Dispatchers.Default]
09:03:49.971 [DefaultDispatcher-worker-1] INFO io...helper.LoggingObject -- 현재 코루틴 컨텍스트의 클래스명 = DeferredCoroutine
09:03:49.971 [DefaultDispatcher-worker-1] INFO io...helper.LoggingObject -- 현재 Job 의 부모 = JobImpl{Active}@6f1fba17
09:03:49.974 [main] INFO io...helper.LoggingObject -- 코루틴이 반환한 값 = 25000
09:03:49.975 [main] INFO io...helper.LoggingObject -- 끝

Process finished with exit code 0

async 코루틴 빌더 내부 살펴보기

TODO : 다른 작업이 더 우선순위가 높다고 판단해 잠시 스킵