Synchronous and Asynchronous runs: run, runCatching, runBlocking and runInterruptible in Kotlin
혹시 runBlocking이라는 함수를 많이 쓰나? 필자는 앱을 구현할 때도 딱히 써본적이 없는데 해당 문서에서 여러 run 관련된 함수를 쓰고 있어 번역을 해봤다.
그래서 자세한 내용은 위 문서를 확인해보길 바란다.
runBlocking
은 몇 가지 주요 제한과 권장 사항을 강조한다:
같이 run의 함수들을 알아보고 runBlocking
에서 복잡한 시나리오에서 무슨 일이 일어나는지 이해하려고 노력해보자
run
과 runCatching
은 동기적이며, runBlocking
과 runInterruptible
은 비동기적이다. run
과 runCatching
은 표준 Kotlin 라이브러리의 일부이며, 모든 지원되는 플랫폼에서 사용할 수 있다. runBlocking
과 runInterruptible
은 코루틴의 일부다.
예제를 통해 이해해보자. 다음과 같은 클래스가 있다:
data class Event(
val id: UUID,
val value: Int,
val message: String?,
var badModifyablePropertyForDemoPurposes = "Some string"
)
run
은 스코프 함수다(하지만 객체 없이도 실행 가능).
이는 객체에서 호출할 수 있으며 코드 블록이 객체의 속성 및 메서드에 직접 액세스할 수 있다. 이때 this를 사용하지 않아도 되고 사용해도 된다. 또한 run은 결과를 반환할 수 있으며 이 결과는 다음 코드에서도 사용할 수 있다.
val event = Event(
id = UUID.randomUUID(),
value = 10,
message = null
)
val isEven = event
.run {
value % 2 == 0
}
println("Is Event.value even? $isEven.")
Is Event.value even? true.
run
원래 객체를 수정할 수도 있다.
val event = Event(
id = UUID.randomUUID(),
value = 10,
message = null,
badModifyablePropertyForDemoPurposes = "Some string"
)
event.run {
badModifyablePropertyForDemoPurposes = "Hello"
}
Event(..., badModifyablePropertyForDemoPurposes=Hello)
그럼 run
과 apply
를 어떻게 구분할까? 그 차이는 주로 반환 값에 있다.
run은 유연하다. 호출된 객체의 유형뿐만 아니라 모든 유형을 반환할 수 있다. 반면에 apply는 항상 객체 자체를 반환하며, 이는 객체 구성을 연결하는 데 용이하다.
또한, 앞서 언급한대로
run
은 객체와 독립적으로 작동할 수 있다. 이는 항상 객체를 필요로 하는 apply와 대조적이다.
val event = Event(
id = UUID.randomUUID(),
value = 10,
message = null
)
event.message?.let {
println("The message is $it")
} ?: run {
println("The message is null")
}
그리고 가장 많이 쓰이는 run의 쓰임은 위의 예시코드처럼 event.message가 null인 경우의 대안으로 사용된다.
run은 다른 스코프 함수들과 결합되어 일관된 코드 아키텍처를 유지하려는 부분에서 편리하다. 안전성을 위해 run 블록 내의 코드가 예외를 던지기 어렵도록 하는 것이 이상적이다. 그러나 예외 처리가 필요한 상황에서는 runCatching이 더 나은 선택이다.
이것은 run
의 변형이다. runCatching
은 말 그대로 try...catch
블록이지만 중요한 차이가 있다. 이는 블록 실행 결과를 Result
객체로 캡슐화한다. 이 캡슐화는 코드를 더 읽기 쉽게 만들 뿐만 아니라 안전한 데이터 검색에 좋다. 추가로 runCatching
블록 실행의 결과를 비교할 수 있는 장점이 있다.
data class Event(
val id: UUID,
val value: Int,
val message: String?,
var badModifyablePropertyForDemoPurposes: String
)
val event = Event(
id = UUID.randomUUID(),
value = 10,
message = null,
badModifyablePropertyForDemoPurposes = "Some string"
)
val result = event.runCatching {
value / 0
}.onFailure {
println("We failed to divide by zero. Throwable: $it")
}.onSuccess {
println("Devision result is $it")
}
println("Returned value is $result")
18:01:58.722 I We failed to divide by zero. Throwable: java.lang.ArithmeticException: divide by zero
18:01:58.723 I Returned value is: Failure(java.lang.ArithmeticException: divide by zero)
따라서 runCatching
을 사용하는 것은 여러 이점이 있다. 블록 실행 결과는 체인 형식으로 소비되거나 변수로 반환되어 나중에 처리될 수 있다. 예를 들어 Flow에서 방출(emit)될 수 있다.
Result 클래스는 값 보유에 대한 작업을 수행하는 데 많은 유용한 메서드와 속성을 제공한다. 더 흥미로운 점은 이 메서드를 확장하고 예외 처리에 더 정교한 로직을 추가할 수 있다.
비동기 runBlocking
, runInterruptable
및 동기식 run
,runCatching
사이의 유일한 공통점은 코드 블록을 실행할 수 있는 능력인데, 그러나 runBlocking
및 runInterruptible
은 기능과 사용 사례 측면에서만이 아니라 이름과 run 및 runCatching과도 상당히 다르다.
다음 예제를 보자.
class EventGenerator {
/**
* Simulates a stream of data from a backend.
*/
val coldFlow = flow {
val id = UUID.randomUUID().toString()
// Simulate a stream of data from a backend
generateSequence(0) { it + 1 }.forEach { value ->
delay(1000)
println("Emitting $value")
emit(value)
}
}
}
이 클래스는 일시 중단 지점(delay
)이 있는 무한한 cold flow의 단일 인스턴스를 제공한다. 이 Flow는 일시 중단 및 취소 가능하며 코루틴 규칙 및 제어를 따른다.
또한 이는 끝나지 않는 비동기 플로우를 나타내는데, 이를 통해 비동기 및 병렬 실행 문제를 더 잘 이해할 수 있을 것이다.
다시 한 번 이 함수의 강조하는 주요 사용 사례는 다음과 같다:
왜 이러한 경우에만 사용해야하는 걸까?
왜 이 함수를 피해야 하는지에 대한 의문은 StackOverflow 등에서 보면 알게 된다.
현재 스레드를 차단하지만 하지만 우리는 우리만의 스레드를 생성할 수 있고 그것은 다른 코드에 영향을 미치지 않을 것이다.
예제를 보자.
private fun runFlows() {
thread {
runCollection()
}
}
private fun runCollection() {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator
.coldFlow
.take(2)
.collect {
println("Collection in runCollections #1: $it")
}
}
CoroutineScope(Dispatchers.Default).launch {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator.coldFlow.collect {
println("Collection in runCollections #2: $it")
}
}
}
}
이 예제에서는 일부러 코루틴 내부에서 runBlocking
을 호출했다. 문서에서 이러한 실천을 권장하지 않음에도 불구하고, 이렇게 하면 IDE, 빌드 로그 또는 실행 중에 어떤 경고나 오류도 트리거하지 않는다.
이것은 해당 함수를 어떻게 사용하고 판별하지는 개발자에게 완전히 달려있음을 의미한다.
runBlocking
의 직접 사용은 비교적 쉽게 발견하고 수정할 수 있다. 그러나 라이브러리나 다른 모듈에서 함수 호출 내부에 숨겨진 runBlocking
이 있는 시나리오에선 좋지 않다.
행동은 동일하지만 디버깅이 악몽이 되는 상황이 될 것입니다.
The code prints
18:24:28.091 I Emitting 0
18:24:28.096 I Collection in runCollections #1: 0
18:24:29.099 I Emitting 1
18:24:29.099 I Collection in runCollections #1: 1
18:24:30.102 I Emitting 2
18:24:30.102 I Collection in runCollections #1: 2
18:24:31.103 I Emitting 3
18:24:31.103 I Collection in runCollections #1: 3
18:24:32.105 I Emitting 4
18:24:32.105 I Collection in runCollections #1: 4
위 로그에서 "Collection in runCollections #2"가 보이지 않는 것을 확인할 수 있다.
그 이유는 플로우가 무한하고 종료되지 않기 때문이다. 스레드는 영원히 락이 걸린 상태로 남게 된다.
현실에서는 긴 네트워크 또는 데이터베이스 작업이 있을 수 있다. 이를 runBlocking
에서 실행하면 앱 성능 또는 라이브러리 성능에 심각한 영향을 미친다. 라이브러리에서 디버그를 시도해보라.
Flow가 유한하다면 코루틴에서 컬렉션이 시작되겠지만 일반 비동기 코드에서는 다음 작업이 기다리지 않아야 한다. 이는 잠재적인 성능 저하다.
비동기 코드의 나머지가 시작되기 전에 어떤 작업을 수행해야 하는 경우를 제외하고는, 이는 문서에서 언급된 대로 외부 라이브러리 처리일 수 있다.
수정해보자
private fun runFlows() {
thread(name = "Manual Thread") {
runCollection()
}
}
private fun runCollection() {
val coroutine1 = CoroutineScope(Dispatchers.Default).launch {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator
.coldFlow
.collect {
println("Collection in runCollections #1: $it")
}
}
}
val coroutine2 = CoroutineScope(Dispatchers.Default).launch {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator.coldFlow.collect {
println("Collection in runCollections #2: $it")
}
}
}
}
21:33:38.848 I Emitting 0
21:33:38.851 I Collection in runCollections #1: 0
21:33:38.867 I Emitting 0
21:33:38.867 I Collection in runCollections #2: 0
21:33:39.852 I Collection in runCollections #1: 1
21:33:39.876 I Collection in runCollections #2: 1
21:33:40.854 I Emitting 2
21:33:40.854 I Collection in runCollections #1: 2
21:33:40.879 I Emitting 2
21:33:40.879 I Collection in runCollections #2: 2
로그를 보면 모든 것이 정상적으로 보인다. 두 코루틴이 모두 실행 중이기 때문이다.
이는 CoroutineScope(Dispatchers.Default).launch
가 코루틴에 대한 스레드를 선택하므로, runBlocking
에 의해 스레드가 락 걸리는 부정적인 영향을 완화한다.
이 스레드 관리는 차단된 코루틴 문제를 완화하며, runBlocking
이 코루틴 컨텍스트 내에서 사용될 때에도 더 부드러운 실행을 보장한다.
1. runFlows
+- thread
+- Thread[Manual Thread,5,main]
2. runFlows
+- thread
+- runCollections
+- coroutine1
+- Thread[DefaultDispatcher-worker-3,5,main]
3. runFlows
+- thread
+- runCollections
+- coroutine1
+- Thread[DefaultDispatcher-worker-2,5,main]
모든 것이 작동하는 것으로 보인다:
응용 프로그램이 충돌하지 않고 성능도 보통이다. 그러나 이 접근 방식은 실용성에 대한 의문을 제기한다.
여기서 앱은 코루틴을 생성하고, 이어서 스레드를 생성한 다음, 다시 runBlocking
을 호출하여 또 다른 코루틴을 생성하며, 일반적인 코루틴 사용으로 정확히 동일한 동작을 얻는다.
이 방법은 효율적이고 예측 가능한 코드의 핵심 원칙에 반한다. 논리적 흐름을 방해하고 응용 프로그램의 성능 및 동작에 대한 장기적인 영향을 예측하기 어렵게 만든다. 코드에서 이러한 패턴을 발견하면 가능한 한 빨리 코드를 수정하는 것이 좋다.
이제 viewModel을 사용한 더 현실적인 시나리오를 살펴보자.
class MainViewModel : ViewModel() {
fun runFlows() {
thread(
name = "Manual Thread",
) {
println("Thread: ${Thread.currentThread()}")
runCollection()
}
}
private suspend fun collect(action: (Int) -> Unit) {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator
.coldFlow
.collect {
action(it)
}
}
}
private fun runCollection() {
viewModelScope.launch {
collect {
println("Collection in runCollections #1: $it: ${Thread.currentThread()}")
}
}
viewModelScope.launch {
collect {
println("Collection in runCollections #2: $it: ${Thread.currentThread()}")
}
}
}
}
00:40:44.332 I Emitting 0
00:40:44.334 I Collection in runCollections #1: 0: Thread[main,5,main]
00:40:45.336 I Emitting 1
00:40:45.336 I Collection in runCollections #1: 1: Thread[main,5,main]
00:40:46.337 I Emitting 2
00:40:46.338 I Collection in runCollections #1: 2: Thread[main,5,main]
주의해야 할 점은 생성된 스레드가 아무것도 제공하지 않는다는 것이다. 그것은 단순히 스레드를 생성하며 비동기 작업에 전혀 영향을 미치지 않는다.
viewModelScope
는 최종적으로 메인 스레드로 이어지는 메인 디스패처에 바인딩되어 있다.
(물론 디스패처의 세부 사항과 Main과 Main.immediate 간의 차이에 대한 깊은 내용은 이 글에서 다루지 않습니다.)
만약 collect()
구현에서 runBlocking
이 제거되면 runFlows()
호출이 출력됩니다.
01:05:48.180 I Emitting 0
01:05:48.181 I Collection in runCollections #1: 0: Thread[main,5,main]
01:05:48.181 I Emitting 0
01:05:48.181 I Collection in runCollections #2: 0: Thread[main,5,main]
01:05:49.182 I Emitting 1
01:05:49.182 I Collection in runCollections #1: 1: Thread[main,5,main]
01:05:49.183 I Emitting 1
01:05:49.183 I Collection in runCollections #2: 1: Thread[main,5,main]
이것은 일반적으로 비동기 작업에서 기대되는 것이다. 예상대로다.
그러나 viewModelScope
가 어떤 것에 바인딩되어 있는지 명심하지 않으면 명확하지 않을 수 있다.
스레드를 collect()
함수로 이동한다.
private suspend fun collect(action: (Int) -> Unit) {
thread(
name = "Manual Thread",
) {
runBlocking {
val eventGenerator = EventGenerator()
eventGenerator
.coldFlow
.collect {
action(it)
}
}
}
}
역시나 비슷한 결과가 나온다.
01:08:51.944 I Emitting 0
01:08:51.944 I Emitting 0
01:08:51.946 I Collection in runCollections #2: 0: Thread[Manual Thread,5,main]
01:08:51.947 I Collection in runCollections #1: 0: Thread[Manual Thread,5,main]
01:08:52.948 I Emitting 1
01:08:52.948 I Emitting 1
01:08:52.948 I Collection in runCollections #1: 1: Thread[Manual Thread,5,main]
01:08:52.948 I Collection in runCollections #2: 1: Thread[Manual Thread,5,main]
그러나 분명히 이러한 구성에 대해 무엇이 일어나고 있는지 명확히 이해해야 한다.
runBlocking
을 사용하면 비동기 작업의 트랙킹이 힘들고, 일시 중단 및 코루틴 전환을 자동으로 관리하는 코루틴의 강력한 기능을 잃게 된다.
만약 여러분이 Java 및 Android 스레드에 대한 전문 지식이 없거나 어떤 이유로든 코루틴 구현이 여러분의 요구 사항에 맞지 않다면 이는 최선의 방법이 아닐 것이다.
다른 경우에서는 runBlocking
의 사용을 문서에서 정의한 대로 제한하는 것이 좋다. 적어도 모바일 앱 개발에서는 주로 테스트에 사용되어야 한다.
최종으로, runBlocking
의 대안이 아니다!
문서에 따르면 코드 블록은 인터럽트 가능한 방식으로 호출다. 이 함수는 스레드를 생성하지 않으며 매개변수로 제공한 디스패처를 따른다.
viewModel에 새로운 메서드들을 추가해봤다.
fun runInterruptible() {
viewModelScope.launch {
println("Start")
kotlin.runCatching {
withTimeout(100) {
runInterruptible(Dispatchers.IO) {
interruptibleBlockingCall()
}
}
}.onFailure {
println("Caught exception: $it")
}
println("End")
}
}
private fun interruptibleBlockingCall() {
Thread.sleep(3000)
}
11:06:29.259 I Start
11:06:30.431 I Caught exception: kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 100 ms
11:06:30.431 I End
연결된 호출을 주목해보자.
runCatching
(try...catch일 수 있음) 그리고 그 다음에 withTimeout
이 있다. 이 print가 된 환경은 Kotlin 1.9.20을 사용하고 있으며 withTimeout
이 예외를 throw하지만 로그에서 확인되지 않는다. try...catch
또는 runCatching
을 추가하면 예외를 검색할 수 있다. 그렇지 않으면 코루틴이 무언가 문제가 있더라도 조용히 작동이 중지된다.
이 동작의 이유를 찾지 못했고 트래커에도 보고서가 없다면
try...catch
또는withTimeoutOrNull
을 사용하는걸 명심하자
이 함수는 단순한 함수인 것으로 알겠지만, 결국은 매우 신비한 함수가 되었다. 이것을 작동시키려면 실행 중인 코드가 무엇을 하는지 명확하게 이해하고 구현해야 한다. 평소처럼이지만 여기에서는 부분 중 어떤 부분이 취소 가능하지 않거나 스레드 취소를 처리하지 않는지 알아야 한다. 이것은 tricky하다.
이 문서를 정리하면서 run이란 prefix 네이밍이 붙은 함수들을 정리할 수 있는 계기가 되었다. 사실 interrup~ 는 처음보는데 추후에 쓸 일이 있어도 조심해야겠다는 생각이 들었다!
이는 영문서를 번역한거에 불과하니 원문을 읽어보는 것도 추천한다.