본문 바로가기

끄적끄적

[끄적끄적] supervisorScope를 주의해서 사용하자

지난번 글 에서 코루틴의 실패전이를 방지하기 위해서 supervisorScope를 사용했다고 적었었는데요.

이번 글 에서는 supervisorScope를 사용했을때, 실제로 발생했던 latency 저하와 예상하지 못한 동작에 대해서 정리해보고자 합니다.

https://dlwnsdud205.tistory.com/375

 

[끄적끄적] 코루틴 실패전이 트러블 슈팅 (feat. 구조화된 동시성)

최근 개발을 하던도중 코루틴동작 방식을 잘못 이해하고 사용해서 문제가 발생했었는데요.까먹지 않고자 정리를 해보았습니다.문제상황비동기보다는 멀티스레드를 편하게 사용하려고 코루틴

dlwnsdud205.tistory.com

 

 

제가 성능저하를 경험했던 상황은 아래와 같은데요.
모종의 이유로 동시에 2개의 API를 호출하고, 먼저 성공한 API 하나만을 가져와 사용해야 했습니다.

이때, 늦게 끝나거나 실패한 API의 응답은 무시해야했으며, 실패상태 전이를 위해서 supervisorScope를 사용하고 있었습니다.

 

예시상황을 간소화해서 코드로 작성하면 아래와 같습니다.

suspend fun <T : Any> selectWithFallback(async1: Deferred<T>, async2: Deferred<T>): T {
    return runCatching {
        select {
            async1.onAwait { it }
            async2.onAwait { it }
        }
    }.getOrElse {
        if (async1.isCancelled) {
            return@getOrElse async2.await()
        }
        async1.await()
    }
}

"예시 상황" {
    runBlocking {
        supervisorScope {
            val async1 = async {
                // api call 1
            }

            val async2 = async {
                // api call 2
            }

            selectWithFallback(async1, async2)
        }
    }
}

 

위 코드는 async1, async2중 하나가 먼저 끝나면 해당 값을 반환하고, 먼저 끝난것이 예외를 응답하면 늦게 끝나는 API로 fallback 하는 로직 입니다.

 

이때, 만약 async1의 로직이 1초 async2의 로직이 10초가 소요되면 runBlocking 코루틴은 몇초가 지나서 끝나게 될까요?

"실제로는 10초가 소요된다" {
    runBlocking {
        supervisorScope {
            val async1 = async {
                delay(1.seconds.inWholeMilliseconds)
            }

            val async2 = async {
                delay(10.seconds.inWholeMilliseconds)
            }

            selectWithFallback(async1, async2)
        }
    }
}

 

1초만에 종료될것 이라는 예상과 달리 실제로는 아래 사진처럼 10초가 소요됩니다.

테스트 결과

 

원인은, 코루틴의 supervisorScope는 내부적으로 supervisorScope내의 모든 자식이 끝날때까지 대기하며 이는, async.await으로 응답값을 호출하지 않아도 마찬가지 입니다.

 

그렇다면, 코드를 아래와 같이 변경하면 몇초가 소요될까요?

val async1 = CoroutineScope(Dispatchers.Default).async {
    delay(1.seconds.inWholeMilliseconds)
}

val async2 = CoroutineScope(Dispatchers.Default).async {
    delay(10.seconds.inWholeMilliseconds)
}

runBlocking {
    selectWithFallback(async1, async2)
}

 

비록 job이 분리되었지만, supervisorScope를 제거하면서 예상과 같이 1초만에 코루틴이 종료되는것을 볼 수 있습니다.

 

구조화된 동시성을 지키면서 실패전이를 막아야하는 상황에는 supervisorScope가 유용했습니다.

하지만, 먼저 끝나는 코루틴을 선택하고 나머지 코루틴은 대기하지 않아야 할때는 supervisorScope대신 Job분리를 통해 실패전이를 방지하는 방향을 선택했습니다.