코루틴 관련 포스팅은 아래의 포스팅들을 같이 순차적으로 참고하시면 좋습니다. 클릭하면 해당 포스팅으로 이동합니다.
1. 코루틴 기본 개념
2. 코루틴 빌더
3. Job과 코루틴의 라이프 사이클
4. 코루틴 컨텍스트와 스코프
5. Flow 기본 개념
6. Flow 핵심 API 정복하기
7. Flow 제어하기 (예외, 완료 등)
이전 포스팅에서 코루틴의 라이프 사이클과 관련해서 여러 내용들을 다루었다. 이에 이어서 코루틴이 실행에 필요한 요소를 저장하는 코루틴 컨텍스트를 `CoroutineDispatcher` 라는 컨텍스트에 집중해서 알아볼 것이다. 그리고 전체 코루틴의 라이프사이클을 쉽게 관리할 수 있도록 만드는 코루틴 스코프에 대해서 알아보고, 이 코루틴 스코프를 생성하는 스코프 빌더(혹은 scope function)에 대해 알아보자.
코루틴 컨텍스트
코루틴은 언제나 `CoroutineContext`로 표현되는 특정 컨텍스트 안에서 실행이 된다. 이 컨텍스트에는 다양한 요소를 설정할 수 있으며, 이전에 `CoroutineStart.LAZY`를 컨텍스트에 포함시켰을 때 즉시 실행되지 않았던 것처럼 `CoroutineContext`의 요소에 따라 코루틴의 실행이 달라지게 된다.
이전에 다뤘던 `Job` 역시 대표적인 컨텍스트 요소 중 하나이다. 또 다른 대표적인 요소로 `CoroutineDispatcher`가 있는데 `Job`은 이전에 상세하게 다뤘기 때문에 이번에는 `CoroutineDispatcher`를 살펴보도록 하자.
CoroutineDispatcher
`CoroutineDispatcher`는 코루틴이 실행되는 스레드를 결정하는 역할을 한다. dispatch 라는 단어 자체가 '보내다' 라는 의미가 있는데, 실행할 스레드로 보낸다는 의미에서 이렇게 네이밍을 한 것 같다.
public actual object Dispatchers {
@JvmStatic
public actual val Default: CoroutineDispatcher = DefaultScheduler
@JvmStatic
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
@JvmStatic
public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined
@JvmStatic
public val IO: CoroutineDispatcher = DefaultIoScheduler
}
코루틴 표준 라이브러리는 `CoroutineDispatcher`의 표준 구현을 `Dispatchers`라는 `object`에 정리해 놓았다. 주로 이 오브젝트의 멤버를 사용한다.
launch(Dispatchers.Default) {
log("run Default thread")
}
`CoroutineDispatcher`는 앞서 말한데로 `CoroutineContext`의 일종이기 때문에 위와 같이 `launch`, `async`과 같은 빌더로 코루틴을 생성할 때 인자로 넘겨줄 수 있다. 만약 인자로 넘겨주지 않는다면 해당 코루틴이 실행되는 코루틴 스코프에서 상속된다.
이제 각 표준 구현체들을 알아보자.
이름 | 설명 |
`Default` | 공유 백그라운드 스레드의 공통 스레드 풀을 사용한다. CPU 리소스를 집약적으로 소비하는 코루틴에 적합하다. |
`IO` | 온디맨드로 생성된 스레드의 공유 스레드 풀을 사용한다. IO 집약적인 작업(파일 IO, 소켓 IO 차단 등)을 수행하는 코루틴에 적합하다. |
`Unconfined` | 일시 중단 함수가 실행중인 모든 스레드를 사용한다. 코루틴의 스레드를 제한하지 않는다. |
`Main` | Main, UI 스레드를 사용한다. UI 기반 활동을 수행하는 코루틴에 특수하게 적용되도록 설계됐다. |
각각 설명 그대로이다. 근데 `Unconfined`는 부연 설명이 필요하다.
Unconfined Dispatcher
`Unconfined`는 스레드를 제한하지 않는다. 즉, 하나의 코루틴이 여러 스레드에서 실행될 수 있다.
우선 첫 중단 지점까지는 해당 코루틴을 호출한 스레드에서 실행된다. 이후 재개될 때는 중단 함수에 의해 결정된 스레드에서 코루틴을 재개한다. 말이 어려운데, 예제를 하나 살펴보자.
fun main(): Unit = runBlocking {
launch(Dispatchers.Unconfined) {
log("Unconfined")
delay(500)
log("Unconfined")
}
launch {
log("runBlocking")
delay(1000)
log("runBlocking")
}
}
`runBlocking` 코루틴에서 두 개의 코루틴을 만들었다. 하나는 `Dispatcher`를 `Unconfined`로 설정했고, 각각 0.5초, 1초 지연 후 재개되도록 했다. 한번 결과를 보자
[main @coroutine#2] Unconfined
[main @coroutine#3] runBlocking
[kotlinx.coroutines.DefaultExecutor @coroutine#2] Unconfined
[main @coroutine#3] runBlocking
`Unconfined`로 설정한 코루틴만 스레드가 달라졌다. 재개되는 시점에 다른 스레드에서 실행이 된 것이다. DefaultExecutor로 표시된 스레드는 해당 코루틴의 `delay` 함수가 사용하던 스레드이다. 위에서 중단 함수에 의해 결정된 스레드라는 말이 이것을 의미했던 것이다.
`Unconfined`는 주로 CPU를 소비하는 작업과 공유 데이터(UI와 같은)를 갱신하는 작업에 적합하다고 한다. 예를 들어, UI작업을 위해 Main 스레드에서 해당 코루틴이 실행되었다가 추후에 작업해야 할 일은 UI 작업과 상관 없는 일일 경우에 사용할 수 있을 것이다.
설계 의도 자체도 이런 경우에서 오는 코너 케이스나 사이드 이펙트를 방지하기 위해서 설계되었다고 한다. 예를 들어 UI 작업을 막 하다가 이후에 실행이 필요 없는 경우에도 Main 스레드에서 실행되서 다른 코루틴이 실행되지 못한다던지, 이후 작업에 의해 UI가 변경되지 않아야 하는데 계속 Main 에서 작동해서 UI의 데이터를 변경해버린다던지 등의 상황을 방지하기 위함인 듯 싶다. 따라서 일반적인 코드에서는 사용하지 않는 것을 권장한다.
newSingleThreadContext
코루틴을 실행하기 위해 새로운 스레드를 생성하는 디스패처를 제공한다. 이 디스패처는 스레드로 코루틴을 보내기 위해 Java의 `Executor`을 근본으로 두고 있다. 따라서 이 컨텍스트는 해당 스레드를 더이상 사용하지 않을 때 사용자가 직접 닫아주어야 한다. 그러기 위해서 제자리에서 호출하는 것을 권장하지 않는다. 한번 예시를 보자.
fun main() = runBlocking {
launch(newSingleThreadContext("custom-thread")) {
log("launch!!")
}
}
`newSingleThreadContext` 함수는 반환값이 `CouroutineDispatcher`를 상속하는 클래스이기 때문에 위와 같이 작성할 수 있다. 그런데, `launch` 블록이 끝나고 더 이상 custom-thread를 사용하지 않는 경우 자원을 직접 해제해야 한다. 보통 공유 스레드 풀을 사용하는 위에서 봤던 디스패처와는 다르다.
따라서 위와 같이 `CoroutineContext` 인자에 직접 제자리 호출 하게 된다면 자원을 해제하기 위해 `close`를 호출하지 못한다.
fun main() = runBlocking {
val singleThreadContext = newSingleThreadContext("custom-thread")
launch(singleThreadContext) {
log("launch!!")
}
singleThreadContext.close()
}
위와 같이 변수로 할당한 후 사용이 종료된 경우 직접 해제하는 것을 권장한다.
코루틴 스코프
이전에 코루틴이 Structured Concurrency 패러다임을 지키는 동시성 프로그래밍을 추구한다는 것을 알 수 있었다. 코루틴은 계층간의 예외 전파, 컨텍스트의 상속, 취소를 동기화 함으로써 이를 가능하게 했다. 이것이 가능했던 이유는 코루틴이 이를 캡슐화하여 `CoroutineScope`를 통해 제공하기 때문이다.
`CoroutineScope`는 코루틴이 생성되는 영역이며, 코루틴의 라이프사이클을 관리한다. 모든 코루틴은 `CoroutineScope`에서 생성되고 종료까지 관리된다.(`launch`, `async`과 같은 새로운 코루틴을 만드는 빌더 함수가 모두 `CoroutineScope`의 확장함수로 선언되어 있었던 것도 이러한 이유 때문이다.) 이를 통해 보다 쉽게 Structured Concurrency 패러다임을 지키는 동시성 프로그래밍을 가능하게 만들었다.
생각해보자. 코루틴은 `Job`을 통해서 제어할 수 있기 때문에, 예외가 발생했을 경우나 코루틴을 취소하는 경우 일일이 연관된 코루틴의 `Job`을 직접 `cancel`하여 코루틴 간의 상태를 동기화 시킬 수도 있을 것이다. 하지만 이렇게 수동으로 관리해야 할 코루틴이 100개가 넘게 있고 엮여 있다면 개발자가 엄청 번거롭고 미처 처리하지 못한 코루틴이 있어 스레드 누수가 발생할 수도 있다. 하지만 코루틴은 이를 캡슐화하여 이에 대한 추상을 `CoroutineScope`로써 제공하기에 번거롭지 않고 안전하게 프로그래밍이 가능하다.
위에서 살펴본 코루틴 컨텍스트 역시 코루틴 스코프를 통해 관리된다. 따라서 동일 스코프에서 생성된 코루틴은 컨텍스트를 상속받을 수 있게 된다.
이제 스코프를 생성할 수 있는 주요 스코프 빌더(혹은 scope function)에 대해 알아보자.
coroutineScope
`runBlocking`과 비슷하게 코루틴 스코프를 생성하고 즉시 코루틴을 실행한다. 자식 코루틴이 완료될 때 까지 기다린다. 하지만 `coroutineScope`는 일반 함수인 `runBlocking`과 달리 일시중단 함수이다.
이전에 `runBlocking`에 대해서 다뤘을 때, 코루틴과 일반 동기 코드를 이어주는 브릿지 역할을 한다고 말한 적이 있다. 이는 `runBlocking`이 내부의 모든 코루틴이 완료될 때 까지 사용중인 스레드를 차단하기 때문이라고 말했다. 따라서 `runBlocking`에서 사용중인 스레드는 다른 작업이 사용하지 못했었다.
하지만 `coroutineScope`는 스레드를 차단하지 않는다. 잠시 중단할 뿐이다. 따라서 `coroutineScope`는 일시 중단 함수로 선언되어 있다.
public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R
`coroutineScope`의 선언이다. 블록만 파라미터로 받고 있다. 선언에서 해당 함수의 의도를 알 수 있는데, 단순히 여러 코루틴을 하나의 영역으로 묶어주기 위해 설계되었음을 알 수 있다. 따로 코루틴 컨텍스트를 파라미터로 받지 않기 때문이다.
`coroutineScope`은 일시 중단 함수이기 때문에, 이 함수를 호출하기 위해서는 어쨌든 외부에 다른 코루틴 영역이 존재해야만 사용이 가능하다. 따라서 컨텍스트를 파라미터로 받지 않고 상속받을 수 있다. 따라서 이 함수의 의도 자체가 새로운 영역을 생성하여 이후에 만들 새로운 코루틴을 묶어주기 위함이기 때문에, 따로 컨텍스트를 파라미터로 받지 않고 상속받는다.
하지만 `Job`은 상속받지 않고 오버라이딩 한다. 새로운 영역을 만들어 하위의 코루틴을 외부 영역과는 상관 없이 제어하기 위한 용도로 사용하기 때문이다.
supervisorScope
`coroutineScope`와 동일한 특성을 가지는 빌더이다. 차이점은 `Job`을 새로운 `SupervisorJob`으로 오버라이딩 한다는 점이다.
이전에 async을 다룬 포스팅에서 예외를 전파하지 않도록 하려면 `SupervisorJob`을 사용하라고 했었다. 이 `SupervisorJob`은 이름 그대로 코루틴을 직접 감독하는 경우 사용한다.
지금까지 알고 있던 코루틴의 `Job`은, Structured Concurrency 패러다임에 따라 계층간 예외를 전파시켜 그 상태를 동기화 했다. 하지만 `SupervisorJob`은 개발자가 직접 코루틴을 감독하도록 한다. 부모의 예외, 자식의 예외가 같은 영역의 다른 코루틴으로 전파되지 않는다. 따라서 각 코루틴마다 직접 예외를 처리하도록 설계할 수 있고, 발생한 예외에 대해 간섭받지 않아 주로 UI 작업에서 특정 작업이 전체 UI 작업을 중단시키는 것을 방지하는 등의 경우에 사용한다고 한다.
결론적으로 새로운 코루틴 영역을 만드는데, `Job`을 `SupervisorJob`으로 오버라이딩 하고 싶다면 이 스코프 빌더를 사용하면 된다.
withContext
지금까지 봤던 스코프 빌더의 범용적인 버전이다. 선언부터 살펴보자.
suspend fun <T> withContext(context: CoroutineContext, block: suspend CoroutineScope.() -> T): T
보면 위 두 개의 빌더와는 달리, 컨텍스트를 필수 파라미터로 받는다.
`withContext`는 이름 그대로 특정 컨텍스트를 지정하여 새로운 스코프를 생성할 때 사용한다. 이 외에 블록 내부의 작업이 완료될 때까지 중단되는 성질은 다른 빌더와 동일하다. 지금까지 봤던 빌더는 `Job`만을 오버라이딩 했지만, 이 빌더는 컨텍스트 자체를 외부 스코프와 다르게 지정할 수 있기 때문에 범용적이다.
withTimeout
일반적으로 코루틴을 중지시키는 경우, 해당 작업에 대해 부여된 시간이 있고 해당 작업이 이 시간을 초과하는 경우 중지를 시키는 경우가 많을 것이다. `withTimeout`은 이러한 작업을 보다 쉽게 수행하기 위해 만들어진 스코프 빌더이다.
fun main() = runBlocking {
val num = withTimeout(1000) {
delay(1100)
10
}
log(num)
}
위 코드처럼 시간을 ms 단위로 해서 `Long` 타입으로 인자를 넣어주거나, `Duration` 타입으로 넣어주어도 된다. 인자로 넘겨진 시간 내에 해당 코루틴 블록이 완료되지 않으면 예외를 던진다.
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1000 ms
던져지는 예외는 `TimeoutCancellationException`인데, 이는 `CancellationException`의 하위 클래스이다. 근데 이전에`CancellationException`은 분명 예외를 던지지 않고 코루틴을 취소한다고 했었는데 여기서는 던져진다. 이는 `TimeoutCancellationException`만 특별하게 예외를 던진다. 따라서 예외를 던지지 않고 그냥 단순히 취소를 시키고 싶은 경우에는 `try ~ catch`로 예외를 처리하는 추가적인 작업이 필요하다.
하지만 이 과정 역시 번거롭기 때문에, 단순 취소의 경우에는 `withTimeoutOrNull`을 사용하는 것이 더 좋을 것이다. `withTimeout`은 블록 내부 실행 값을 null이 불가능한 타입으로 반환하는 반면, 이 함수는 시간이 초과된 경우 예외를 던지지 않고 null을 반환한다. 따라서 외부에서 실행 결과가 null인 경우에 단순히 중단되었다고 판단하고 이후 작업을 이어나가면 될 것이다.