CoroutineScope란?

홍성덕·2024년 10월 15일
1

Coroutines

목록 보기
11/14

CorotuineScope 객체는 자신의 범위 내에서 생성된 코루틴들에게 실행 환경을 제공하고, 이들의 실행 범위를 관리하는 역할을 한다.

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

CoroutineScope 인터페이스는 코루틴의 실행 환경인 CoroutineContext를 가진 단순한 인터페이스이다.

CoroutineScope 생성

class CustomCoroutineScope : CoroutineScope {
    override val coroutineContext: CoroutineContext = Job() +
            newSingleThreadContext("CustomScopeThread")
}

fun main() {
    val coroutineScope = CustomCoroutineScope() // CustomCoroutineScope 인스턴스화
    coroutineScope.launch {
        delay(100L) // 100밀리초 대기
        println("[${Thread.currentThread().name}] 코루틴 실행 완료")
    }
    Thread.sleep(1000L) // 코드 종료 방지
}

// 실행결과
// [CustomScopeThread @coroutine#1] 코루틴 실행 완료

그래서 위 예시처럼 CoroutineScope의 구현 클래스를 정의하여 객체를 생성해서 사용할 수 있다. Thread.sleep(1000L)coroutineScope.launch로 실행한 코루틴이 완료되기 전에 코드가 종료하는 것을 방지하는 코드이다. 만약 Thread.sleep(1000L) 코드가 제거된다면, 코루틴이 완료되기 전에 메인 스레드가 종료되어 "코루틴 실행 완료"가 출력되지 않을 것이다.

이렇게 직접 구현 클래스를 정의하여 CoroutineScope 객체를 생성하는 방법도 있지만, 보통 CoroutineScope 함수를 사용해서 생성한다.

fun main() {
  val coroutineScope = CoroutineScope(Dispatchers.IO)
  coroutineScope.launch {
    delay(100L) // 100밀리초 대기
    println("[${Thread.currentThread().name}] 코루틴 실행 완료")
  }
  Thread.sleep(1000L)
}

// 실행결과
// [DefaultDispatcher-worker-1 @coroutine#1] 코루틴 실행 완료

위와 같이 CoroutineScope 함수에 CoroutineContext를 인자로 전달하여 주어진 CoroutineContext 정보를 가진 CoroutineScope 객체를 생성할 수 있다.

CoroutineScope 함수가 정의된 곳을 살펴보자.

public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job())

CoroutineScope 함수는 전달된 CoroutineContext에 Job 객체가 포함되어 있지 않으면 새로운 Job 객체를 생성한다. 따라서 아까 예시에서의 CoroutineScope(Dispatchers.IO) 호출은 Dispatchers.IO와 새로운 Job 객체로 구성된 CoroutineContext를 가진 CoroutineScope 객체를 생성하는 것이다.

// Job.kt
public fun Job(parent: Job? = null): CompletableJob = JobImpl(parent)

// JobSupport.kt
internal open class JobImpl(parent: Job?) : JobSupport(true), CompletableJob { ... }

새롭게 생성되는 Job 객체는 Job 함수를 호출하여 생성되며, Job 함수는 active 상태의 Job 객체를 생성한다. 내부적으로는 JobImpl 객체를 반환한다. 그리고 parent가 null로 지정되기 때문에 Job 객체는 루트 Job이 된다.

일반적으로 Job 객체는 launch, async 같은 코루틴 빌더 함수를 통해 생성되어, 해당 코루틴을 제어하는 데 사용된다. 이러한 코루틴 빌더 함수는 내부적으로 AbstractCoroutine 클래스를 상속받은 코루틴 객체들이 Job으로 업캐스팅되어 반환되는 것이다. 그래서 이러한 상황에서 Job 객체는 코루틴 그 자체를 의미하는 것이라고도 볼 수 있다.
하지만 JobImpl은 코루틴 클래스를 상속받지 않는다. 따라서 Job 객체가 꼭 코루틴을 의미하는 것이라고 볼 수는 없다.

CoroutineScope 함수에 Job 객체가 추가되는 이유

그렇다면 왜 Job 객체를 강제로 생성하여 추가하는 것일까? 다른 글에서도 이에 대한 내용을 작성했었는데, Job 객체가 부모-자식 관계를 정의하는 구조화에 사용되기 때문이다.

fun main() {
    val coroutineScope = NoJobCoroutineScope(Dispatchers.IO)

    val job = coroutineScope.launch {
        delay(100L)
        println("코루틴 완료")
    }
    println("job.parent : ${job.parent}")

    coroutineScope.cancel()
}

fun NoJobCoroutineScope(context: CoroutineContext): CoroutineScope = ContextScope(context)

class ContextScope(context: CoroutineContext) : CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    // CoroutineScope is used intentionally for user-friendly representation
    override fun toString(): String = "CoroutineScope(coroutineContext=$coroutineContext)"
}

// 실행결과
// job.parent : null
// Exception 발생 : Scope cannot be cancelled because it does not have a job

만약 위 예시처럼 CoroutineScope 함수를 호출할 때 내부적으로 Job 객체가 추가되지 않는다고 가정해보자. 그러면 launch를 통해 생성되는 코루틴은 부모 코루틴이 없는 독립적인 코루틴이 되어 구조화가 깨져버린다.
그리고 코루틴 스코프를 취소해야 하는데 Job 객체가 없어서 취소가 불가능하다는 Exception이 발생한다. 만약 Exception이 발생하지 않는다고 가정해도 coroutineScope.launch를 통해 생성된 코루틴은 coroutineScope의 Job을 부모로 가지지 않기 때문에 취소되지 않고 그대로 실행될 것이다.

코루틴에게 실행 환경을 제공하는 CoroutineScope

@OptIn(ExperimentalStdlibApi::class)
fun main() {
  val newScope = CoroutineScope(CoroutineName("MyCoroutine") + Dispatchers.IO)
  newScope.launch(CoroutineName("LaunchCoroutine")) {
    println(this.coroutineContext[CoroutineName])
    println(this.coroutineContext[CoroutineDispatcher])
    val launchJob = this.coroutineContext[Job]
    val newScopeJob = newScope.coroutineContext[Job]
    println("launchJob?.parent === newScopeJob >> ${launchJob?.parent === newScopeJob}")
  }
  Thread.sleep(1000L)
}

// 실행결과
// CoroutineName(LaunchCoroutine)
// Dispatchers.IO
// launchJob?.parent === newScopeJob >> true

우리가 보통 사용하는 launch, async 코루틴 빌더 함수는 CoroutineScope의 확장 함수로 선언되어 있다. 그래서 수신 객체인 CoroutineScope로부터 CoroutineContext 객체를 제공받는다. 그래서 위의 코드를 보면 수신 객체인 this를 통해 코루틴 컨텍스트에 접근 가능한 것을 볼 수 있다.

그리고 launchJob과 newScopeJob이 부모-자식 관계인 것을 확인 가능하다. 여기서 launchJob은 코루틴이 아니라 실제로는 CoroutineScope 함수를 통해 추가된 JobImpl 객체이지만, 부모 코루틴이 자식 코루틴으로 실행 환경을 상속하는 방식과 동일한 방식으로 실행 환경을 상속한다. 그래서 CoroutineName은 LaunchCoroutine으로 변경되었지만, CoroutineDispatcher는 newScope의 Dispatchers.IO를 그대로 상속받은 것을 알 수 있다.

CoroutineScope에 속한 코루틴의 범위

CoroutineScope 객체는 기본적으로 특정 범위의 코루틴들을 제어하는 역할을 한다. CoroutineScope 객체를 사용해 실행되는 모든 코루틴이 CoroutineScope의 범위에 포함된다.

위 예시에서는 runBlocking 함수의 람다식의 수신 객체로 제공되는 CoroutineScope로 (launch 함수를 통해) 생성한 모든 코루틴이 CoroutineScope 객체의 범위에 포함된다.

위 예시에서는 Coroutine1 람다식의 수신 객체로 제공되는 CoroutineScope로 생성한 모든 코루틴이 CoroutineScope 객체의 범위에 포함된다.

위 예시에서는 runBlocking 함수의 람다식의 수신 객체로 제공되는 CoroutineScope로 생성한 코루틴도 존재하지만 CoroutineScope(Dispatchers.IO) 객체로 생성된 Coroutine4는 runBlocking의 CoroutineScope의 범위에 포함되지 않는다. 이렇게 새로운 CoroutineScope 객체를 생성하여 기존 CoroutineScope의 범위를 벗어나게 만들 수도 있다. 이렇게 하면 기존의 계층 구조를 따르지 않는 새로운 Job 객체가 생성되어 새로운 계층 구조를 만들게 된다.

하지만 이렇게 새로운 계층 구조가 만들어지면 코루틴의 구조화가 깨지는 것이고, 비동기 작업을 안전하게 진행할 수 없기 때문에 최대한 지양해야 한다.

CoroutineScope 취소

fun main() = runBlocking<Unit> {
    launch(CoroutineName("Coroutine1")) {
        launch(CoroutineName("Coroutine3")) {
            delay(100L)
            println("[${Thread.currentThread().name}] 코루틴 실행 완료")
        }
        launch(CoroutineName("Coroutine4")) {
            delay(100L)
            println("[${Thread.currentThread().name}] 코루틴 실행 완료")
        }
        this.cancel() // Coroutine1의 CoroutineScope에 cancel 요청
    }

    launch(CoroutineName("Coroutine2")) {
        delay(100L)
        println("[${Thread.currentThread().name}] 코루틴 실행 완료")
    }
}

// 실행결과
// [main @Coroutine2#3] 코루틴 실행 완료

CoroutineScope의 확장함수로 cancel() 함수가 존재하는데 이를 통해 CoroutineScope 객체에 대해 취소를 요청 가능하다. 예시에서 CoroutineScope 객체의 범위에 속한 Coroutine1, Coroutine3, Coroutine4는 취소되고 범위에 속하지 않은 Corotuine2 코루틴만 끝까지 실행된다.

public fun CoroutineScope.cancel(cause: CancellationException? = null) {
    val job = coroutineContext[Job] ?: error("Scope cannot be cancelled because it does not have a job: $this")
    job.cancel(cause)
}

CoroutineScope의 cancel() 함수를 보면, 내부적으로 Job 객체에 접근하여 취소를 요청하는 것을 알 수 있다. 그래서 Job이 부모-자식 관계를 정의하는데 사용되기 때문에, 해당 Job객체가 취소가 요청되면 자식 코루틴에 취소가 전파된다.

CoroutineScope의 활성화 상태

public val CoroutineScope.isActive: Boolean
    get() = coroutineContext[Job]?.isActive ?: true

isActive 확장 프로퍼티를 통해 CoroutineScope 객체가 현재 활성화되어 있는지 확인할 수 있다. cancel() 함수와 마찬가지로, 내부적으로 Job 객체의 isActive 프로퍼티를 확인하여 활성화 상태를 확인한다.


참고자료

profile
안드로이드 주니어 개발자

0개의 댓글

관련 채용 정보