깨알 개념/Kotlin

[Kotlin] Coroutines - CoroutineScope, CoroutineContext

interfacer_han 2024. 2. 10. 15:31

본 게시글의 Coroutine 개념은 Android 내에서 사용되는 것을 전제로 작성되었다.
 

#1 CoroutineScope

...
import kotlinx.coroutines.*

class MainActivity: AppCompatActivity() {
    ...

    val btnDownloadSampleData = findViewById(R.id.btnDownloadSampleData)
    
    btnDownloadSampleData.setOnClickListener {
        // 코루틴의 영역
        CoroutineScope(Dispatchers.IO).launch {
            sampleFunction()
        }
    }
    
    ...
}

코루틴은 기본적으로 어떤 영역(Scope) 내에서만 실행된다 (이 링크의 #3 참조). 따라서, 코루틴이 아닌 코드와 쉽게 구별할 수 있고, 관리하기도 쉽다. 그 관리란 예를 들어, 코루틴의 동작을 인지하거나, 코루틴을 취소하거나, 코루틴 내에서 발생되는 예외(Exception) 처리 등이 있다. 이러한 영역(Scope)을 제공하는 것이 CoroutineScope 인터페이스다.
 
(여담으로, CoroutineScope와 비슷한 GlobalScope라는 것도 있다. 이 인터페이스는 앱의 모든 부분에서 사용 가능한 전역(Global) 스코프(Scope)다. 안드로이드에서 GlobalScope를 쓸 일은 거의 없다고 한다.)
 

#2 CoroutineContext

// 1. CoroutineDispatcher 지정
val dispatcher = Dispatchers.IO

// 2. CoroutineExceptionHandler
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
    println("$exception captured in $coroutineContext")
}

// 3. CoroutineName
val coroutineName = CoroutineName("MyCoroutine")

// 4. Job
val myJob = Job()

// CoroutineContext 요소들을 조합 (+연산자 오버로딩)
val coroutineContext = dispatcher + exceptionHandler + coroutineName + job

// 코루틴 실행
CoroutineScope(coroutineContext).launch {
    sampleFunction()
}

CoroutineScope 인터페이스는 CoroutineContext를 인자로 받는다. CoroutineContext는 코루틴의 실행 환경을 설정한다. 어느 스레드에서 실행될 지, 코루틴 코드에서 예외가 발생되면 어떻게 할 지, 코루틴의 이름을 부여한다면 뭘로 할 지, 코루틴 코드의 레퍼런스 변수는 무엇으로 할 지 등이다. 코루틴 내부 코드는 코드대로 Coroutine builder 내에서 잘 짜면 되고, 그 내부 코드의 외적인 부분은 ConroutineContext에서 다루면 되는 것이다.
 
CoroutineContext는 최대 4개 요소를 +연산자로 이은 조합(연산자 오버로딩)으로 이루어질 수 있다. 그리고 각각 기본값이 있기 때문에 생략이 가능하다. 문법 상 4 요소 중 적어도 하나는 명시해야 하지만, 전부 생략하고 싶으면 CoroutineContext 자리에 EmptyCoroutineContext을 넣으면 된다. 그 4가지 요소는 다음과 같다.
 

#3 CoroutineContext - CoroutineDispatcher

CoroutineDispatcher는 해당 코루틴이 실행될 스레드를 결정한다. Dispatcher의 사전적 의미는 "(열차·버스·비행기 등이 정시 출발하도록 관리하는) 운행 관리원"이다. Dispatcher는 기본적으로 4가지 종류가 있고, 안드로이드 개발에서 실질적으로 사용되는 것은 Dispatchers.Unconfined를 제외한 나머지 3가지다. 또,  이 4개의 기본 Dispatcher 외에도 Custom Dispatcher를 만들 수도 있다고 한다. 예를 들어, Room이나 Retrofit 라이브러리 제작사는 자체적인 Custom Dispatcher를 사용한다.

 

오해하지 말아야 하는 것은, 코루틴은 Dispatchers 종류 별로 하나씩 있는 스레드를 사용하는 게 아니라, 스레드의 종류 별로 있는 스레드'풀'에서  스레드 하나를 할당받아 수행된다는 점이다 (유일한 반례로, 안드로이드에 추가로 존재하는 Dispathcers.Main는 스레드풀이 아닌 단일 스레드다). 
 

#3-1 Dispatchers.Main

CoroutineScope(Dispatchers.Main).launch {
    println("Hello, ${Thread.currentThread().name}")
}

// 출력 결과: Hello, main

UI 스레드라고도 불린다. 안드로이드에서 UI를 조작할 수 있는 유일한 스레드이기 때문이다. 예를 들어, Dispatchers.Main이 아닌 스레드에서 예를 들어 Toast 메시지를 표시하려고 하면 에러가 난다. 또, Dispatchers.IO 및 Dispatchers.Default처럼 스레드풀에서 스레드를 꺼내오지 않고, 단일 스레드를 사용하는 유일한 스레드이기도 하다.
 

#3-2 Dispatchers.IO

CoroutineScope(Dispatchers.IO).launch {
    println("Hello, ${Thread.currentThread().name}")
}

// 출력 결과: Hello, DefaultDispatcher-worker-1

Background 스레드로, 이 Dispatcher가 요청될 때마다 동적으로 스레드풀에 있는 스레드가 할당된다. 로컬 데이터베이스, 네트워크 통신, 파일 입출력에 주로 사용된다. Dispatchers.IO와 Dispatchers.Default는 같은 스레드 풀(Thread pool)을 공유한다. ${Thread.currentThread().name}이 IODispatcher-worker-1이 아닌 이유다. 물론 스레드 풀만 같고, 작동 방식은 서로 다르다.
 

#3-3 Dispatchers.Default

CoroutineScope(Dispatchers.Default).launch {
    println("Hello, ${Thread.currentThread().name}")
}

// 출력 결과: Hello, DefaultDispatcher-worker-1

크기가 큰 List를 정렬하는 등 CPU 자원을 많이 먹는 작업에 사용되는 스레드다. Dispatchers.Default는 CoroutineDispatcher의 기본값이다. 그래서 CoroutineContext에서 CoroutineDispathcer를 생략하면 이 스레드가 사용된다.
 

#3-4 Dispatchers.Unconfined

CoroutineScope(Dispatchers.Unconfined).launch {
    println("Hello, ${Thread.currentThread().name}")
}

// 출력 결과: Hello, main

GlobalScope와 같이 사용되는 Dispatcher다. 이 Dispatcher는 CoroutineScope가 실행된 현재 스레드에서 실행된다. 그리고 이 스레드가 중지(Suspended)되었다가 재개(Resumed)되면, 마찬가지로 해당 Suspending function이 실행되는 스레드에서 다시 실행된다. 안드로이드 개발에서 Dispatchers.Unconfined를 사용하는 것은 권장되지 않는다. 위의 코드는 MainActivity에서 실행한 것이다. MainActivity는 main 스레드에서 실행되기에, Dispatchers.Unconfined 또한 main에서 실행되었다.
 

#3-5 Dispatcher 생략

CoroutineScope(EmptyCoroutineContext).launch {
    println("Hello, ${Thread.currentThread().name}")
}

// 출력 결과: Hello, DefaultDispatcher-worker-1

Dispatcher의 기본값인 Dispatchers.Default가 사용되었다.
 

#4 CoroutineContext - CoroutineExceptionHandler

#4-1 사용

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
    println("$exception captured in $coroutineContext")
}

CoroutineScope(exceptionHandler).launch {
    delay(1000) // 1초 대기
    val result = 1 / 0
    println("Result: $result")
}

/* 출력 결과
java.lang.ArithmeticException: divide by zero
captured in
[com.example.coroutinesbasics.MainActivity$onCreate$$inlined$CoroutineExceptionHandler$1@f0203a2, StandaloneCoroutine{Cancelling}@2431833, Dispatchers.Default]
*/

CoroutineExceptionHandler는 코루틴 영역(Scope) 내에서 포착된(Captured) 에러를 처리(Handle)한다.
 

#4-2 생략

CoroutineScope(EmptyCoroutineContext).launch {
    delay(1000) // 1초 대기
    val result = 1 / 0
    println("Result: $result")
}

/* 런타임 에러 발생
FATAL EXCEPTION: DefaultDispatcher-worker-1
Process: com.example.coroutinesbasics, PID: 5066
java.lang.ArithmeticException: divide by zero
    at com.example.coroutinesbasics.MainActivity$onCreate$3.invokeSuspend(MainActivity.kt:49)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
    at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
    Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@720b7ee, Dispatchers.Default]
*/

CoroutineExceptionHandler를 생략하면 당연히 그냥 에러가 나 버린다.
 

#5 CoroutineContext -  CoroutineName

#5-1 사용

val coroutineName = CoroutineName("MyCoroutine")

CoroutineScope(coroutineName).launch {
    println("Coroutine name is ${coroutineContext[CoroutineName.Key]}")
}

// 출력 결과: Coroutine name is CoroutineName(MyCoroutine)

CoroutineName은 코루틴에 이름을 부여할 수 있는 옵션이다. 디버깅할 때 유용하게 사용할 수 있다고 한다.
 

#5-2 생략

CoroutineScope(EmptyCoroutineContext).launch {
    println("Coroutine name is ${coroutineContext[CoroutineName.Key]}")
}

// 출력 결과: Coroutine name is null

CoroutineName의 기본값은 null이다.
 

#6 CoroutineContext -  Job

#6-1 사용

val countUpJob = Job()

CoroutineScope(countUpJob).launch {
    var count = 0
    while(true) {
        println(count++)
        delay(1000)
    }
}

cancelCountUpButton.setOnClickListener {
    countUpJob.cancel()
}

Job은 코루틴의 레퍼런스다. Job을 변수에 할당함으로써 익명 코루틴에 이름을 붙일 수 있다.
 

#6-2 간편한 사용

val countUpJob = CoroutineScope(Dispatchers.Default).launch {
    var count = 0
    while(true) {
        println(count++)
        delay(1000)
    }
}

cancelCountUpButton.setOnClickListener {
    countUpJob.cancel()
}

이렇게 해도 #6-1과 동일한 코드다. CoroutineScope.launch의 반환 타입이 Job이기 때문이다. CoroutineScope.async 및 CoroutineScope.produce도 유사한 방식으로 간편히 사용할 수 있다.
 

#6-3 생략

CoroutineScope(Dispatchers.IO).launch {
    sampleFunction()
}

// ↑ 서로 같은 코드 ↓

CoroutineScope(Dispatchers.IO + Job()).launch {
    sampleFunction()
}

Job()은 사실 절대로 생략당하지 않는다. CoroutineContext에서 Job()을 넣지않으면, 알아서 익명 Job()을 만들기 때문이다. Job()의 기본값은 익명 Job() 인스턴스다.
 

#7 요약

CoroutineContext로 코루틴의 실행 환경을 한 방에 정의하고 넘어간다. 덕분에, Coroutine builder에서 코드 내부 동작에 집중하기 수월해진다.