서론
SupervisorJob은 코루틴을 만드는 코루틴 빌더이고, supervisorScope은 코루틴 스코프를 만드는 스코프 빌더이다. 그래서 사실 이 둘을 같은 비교 선상에 두기엔 애매하다고 생각한다. 이 글에서 알아보고 싶은 건 SupervisorJob을 직접 사용할 때와 supervisorScope 사용할 때 무엇이 달라지는지를 확인하고 싶었다.
필자는 아래 환경에서 코드 확인 및 테스트를 아래 버전에서 하고 있다.
- jvm 17
- kotlin 1.8.10
- coroutines-core 1.7.3
SupervisorJob
- 해당 job의 자식들은 서로 독립적으로 실패할 수 있음
(= 자식의 실패 또는 취소가 발생한 경우, 다른 자식에게 영향이 없음) - 부모 job이 존재하는 경우, 부모가 취소되면 현재 job이 취소됨 (이 과정에서 자식 job도 함께 취소됨)
SupervisorJob가 취소 전파를 막는 방법
SupervisorJob의 구현체를 살펴보면 childCancelled()만 false로 override 하는 것을 살펴볼 수 있다.
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
override fun childCancelled(cause: Throwable): Boolean = false
}
childCancelled()는 뭐에 쓰이는 값일까? 따라가 보니 JobSupport 클래스 내부 코드에 도착했다.
internal class ChildHandleNode(
@JvmField val childJob: ChildJob
) : JobCancellingNode(), ChildHandle {
override val parent: Job get() = job
override fun invoke(cause: Throwable?) = childJob.parentCancelled(job)
override fun childCancelled(cause: Throwable): Boolean = job.childCancelled(cause)
}
해당 메서드를 호출하는 위치를 보니 JobSupport#cancelParent에 도착했다. 메서드 주석을 살펴보니, 해당 메서드의 호출 결과에 따라 부모에게 취소를 전파할지를 결정한다. 만약 false라면 부모가 예외를 처리하지 않아도 된다.
private fun cancelParent(cause: Throwable): Boolean {
// Is scoped coroutine -- don't propagate, will be rethrown
if (isScopedCoroutine) return true
/* CancellationException is considered "normal" and parent usually is not cancelled when child produces it.
* This allow parent to cancel its children (normally) without being cancelled itself, unless
* child crashes and produce some other exception during its completion.
*/
val isCancellation = cause is CancellationException
val parent = parentHandle
// No parent -- ignore CE, report other exceptions.
if (parent === null || parent === NonDisposableHandle) {
return isCancellation
}
// Notify parent but don't forget to check cancellation
return parent.childCancelled(cause) || isCancellation
}
supervisorScope
- SupervisorJob을 사용하여 CoroutineScope를 생성
- 자식 코루틴의 실패가 해당 스코프의 실패를 유발하지 않고, 다른 자식들에게 영향을 주지 않음
- 블록에서 예외 발생한 경우, supervisor job 실패 및 모든 자식들이 취소
- 현재 코루틴이 취소된 경우, supervisor job과 모든 자식들이 취소
supervisorScope가 취소 전파를 막는 방법
코틀린 공식 문서에 의하면 supervisorScope도 SupervisorJob을 활용한다고 되어있다. 실제로 그런지 코드로 확인해 보자.
supervisorScope 메서드의 주석을 살펴보면 해당 블록을 통해 생성된 스코프는 외부에서 coroutineContext를 상속하지만 컨텍스트의 Job을 SupervisorJob으로 재정의한다고 명시되어 있다. coroutineScope와 supervisorScope의 코드를 비교해 보면 이해하기 더 쉬울 것 같아 두 코드를 가져와보았다. coroutineScope에서는 ScopeCoroutine을 활용하는데, supervisorScope에서는 SupervisorCoroutine을 활용한다.
public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return suspendCoroutineUninterceptedOrReturn { uCont ->
val coroutine = ScopeCoroutine(uCont.context, uCont) // 이 부분이 다르다!
coroutine.startUndispatchedOrReturn(coroutine, block)
}
}
public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return suspendCoroutineUninterceptedOrReturn { uCont ->
val coroutine = SupervisorCoroutine(uCont.context, uCont) // 이 부분이 다르다!
coroutine.startUndispatchedOrReturn(coroutine, block)
}
}
Job 역시 coroutineContext의 일부임을 잊지 말고 coroutineContext를 추척해보자. supervisorScope 코드를 보니 상위 context를 활용해 SupervisorCoroutine를 생성하여 로직을 수행한다. 그럼 이번에는 SupervisorCoroutine에 대해 살펴보자.
private class SupervisorCoroutine<in T>(
context: CoroutineContext,
uCont: Continuation<T>
) : ScopeCoroutine<T>(context, uCont) {
override fun childCancelled(cause: Throwable): Boolean = false
}
SupervisorCoroutine은 coroutineScope가 활용하는 ScopeCoroutine과 다른 것들은 모두 동일해 보인다. 다만 childCancelled만 false로 override 하는 것을 살펴볼 수 있다. supervisorScope의 코드 상에서 SupervisorJobImpl 등을 직접 선언하여 활용하는 것은 찾지 못했으나, SupervisorJobImpl과 동일하게 childCancelled()가 override 된 것을 찾아냈다.
SupervisorJob과 supervisorScope 예제
SupervisorJob과 supervisorScope 모두 얼핏 보기엔 코루틴이 취소되어도 다른 코루틴까지는 영향을 주지 않는다~ 정도로 이해된다. 그러면 SupervisorJob을 쓰나 supervisorScope를 쓰나 동일한가? 어떤 차이를 가져올까? 예제 코드를 통해 살펴보도록 하자.
아래는 SupervisorJob을 활용한 코드다.
fun main() {
supervisorJobTest()
}
private fun supervisorJobTest() = runBlocking {
println("supervisorJobTest start ${Thread.currentThread().name}")
// SupervisorJob 생성
val job = SupervisorJob()
// 코루틴 빌더 호출 시 SupervisorJob 사용
launch(job) {
delay(1000)
println("launch Error 1 ${Thread.currentThread().name}")
throw Error("Error 1")
}
// 코루틴 빌더 호출 시 SupervisorJob 사용
launch(job) {
delay(2000)
println("launch Error 2 ${Thread.currentThread().name}")
throw Error("Error 2")
}
job.join() // 완료할 때까지 대기하기 위해 join을 호출했다.
println("supervisorJobTest end ${Thread.currentThread().name}")
}
이번에는 supervisorScope를 사용한 예제를 실행시켜 보자.
fun main() {
supervisorScopeTest()
}
private fun supervisorScopeTest() = runBlocking {
supervisorScope { // supervisorScope 활용
println("supervisorScopeTest start ${Thread.currentThread().name}")
launch {
delay(1000)
println("launch Error 1 ${Thread.currentThread().name}")
throw Error("Error 1")
}
launch {
delay(2000)
println("launch Error 2 ${Thread.currentThread().name}")
throw Error("Error 2")
}
println("supervisorScopeTest end ${Thread.currentThread().name}")
}
}
SupervisorJob 예제의 애플리케이션이 종료되지 않는 이유?
두 예제가 동일하게 동작할 것이라 생각했는데 예상과 다른 결과가 나타났다. 왜 SupervisorJob을 직접 사용하면 애플리케이션이 종료되지 않을까? SupervisorJob을 사용한 예제 코드를 간략화하여 다시 가져와보았다. 생성자를 통해 직접 잡을 생성하고, join()을 통해 완료를 기다리고자 했다.
fun main() {
supervisorJobTest()
}
private fun supervisorJobTest() = runBlocking {
val job = SupervisorJob()
launch(job) {
// 예외 발생 코드
}
job.join() // 완료할 때까지 대기하기 위해 join을 호출했다.
}
하지만 join()은 잡이 완료될 때까지 일시 중단할 뿐, 잡을 완료 시키진 않는다. 즉, 위 코드에서는 SupervisorJob이 완료/취소될 일이 없다는 뜻이다. 만약 Job()을 사용했다면, 자식 코루틴의 예외를 전파하며 잡이 취소되었을 것이다. 하지만 SupervisorJob()은 예외를 전파시키지 않는다. cancel()이나 complete() 등을 직접 호출해야만 해당 잡이 취소/완료 처리가 된다.
private fun supervisorJobTest() = runBlocking {
val job = SupervisorJob()
// ...
job.cancelAndJoin()
}
supervisorScope는 어떻게 잡을 완료 시키나요?
그렇다면 SupervisorJob을 사용한다는 supervisorScope는 언제 어떻게 잡을 완료시키는 걸까? 우리가 coroutineScope, supervisorScope 등을 선언해서 코루틴 스코프를 사용하는 경우를 생각해 보자. 우리는 스코프를 완료될 때, 스코프 내의 코루틴들이 완료되어야만 스코프가 완료된다는 사실을 알고 있다.
이 부분에 대해 자세히 공부해 보려면 코루틴 스코프의 동작 방식을 알아보면 좋을 것 같다.
정리
위 과정을 통해 SupervisorJob을 직접 선언해서 사용할 때와 coroutineScope를 통해 사용할 때를 비교해 보았다. 동작 측면에서는 거의 동일해 보이지만, SupervisorJob 예제에서는 잡을 직접 다루기 때문에 발생하는 부가적인 문제들이 보였다.
사실 SupervisorJob을 직접 선언해서 사용할 일은 거의 없어 보인다. 코루틴과 Job의 관리 측면에서 coroutineScope를 사용하는 것이 훨씬 쉽고 안전하다. SupervisorJob 활용 예제가 궁금하다면 CoroutineScope를 커스텀할 때 사용해 보자. (예외를 전파하지 않는 스코프를 만들고 싶다면 SupervisorJob()을 사용한다.)
CoroutineScope(SupervisorJob())
참고
- 코틀린 공식 문서 - SupervisorJob
- 코틀린 공식 문서 - supervisorScope
- 코틀린 코루틴 / 마르친 모스카와 / 인사이트
'Develop > Reactor+Coroutines' 카테고리의 다른 글
데드락을 유도하는 코루틴 사용법 (0) | 2025.01.11 |
---|---|
Spring MVC + Coroutines 에 대한 고민 (2) | 2024.09.30 |
[Coroutines] 코루틴과 공유 상태 (2) | 2024.07.31 |
[Coroutines] 코루틴 디스패처 (0) | 2024.07.08 |
[Coroutines] 코루틴 스코프 함수 (0) | 2024.06.29 |