본문 바로가기
안드로이드/코틀린

Kotlin - Dispatcher

by 나이아카 2025. 4. 1.
반응형

 매번 coroutine을 사용할 때 마다 Dispatcher를 어디에 두고 쓰는 것이 좋은가 고민할 때가 있었는데요.(뭐, Retrofit을 쓰는데 Main을 쓴다던가 UI를 Default에서 그린다던가 하는 개념은 아닙니다...) 그 중 자주 사용하면서 고민이 제일 많이 되는 부분은 아무래도 그 쓰임새가 조금 불명확한 Dispatcher.Default 였습니다. 그래서 언젠가 한 번 개념 정리를 다시하고 가야겠다 생각했었는데, 얼마전 면접에서 이 부분을 물어보더라구요. 이제 연차도 좀 쌓이고 해서 이런 부분에 대해서 어떻게 사용하고 있는지는 설명할 수 있는데 아직도 개념적인 부분을 매끄럽게 설명하려니 말문이 막혀서 한 번 정리해보고자 합니다.


Dispatcher란?

 Dispatcher란 코루틴을 어떤 방식으로 사용할 것인지를 개발자들이 알기 쉽게 정의해둔 것인데요. 코루틴이 어떤 Thread pool에서 작업을 할 지 결정하는 역할을 한다고 보시면 되겠습니다. 물론 추상화 계층으로 구성되어 있어 실제 배분 작업은 이 Dispatcher를 통해 내부적으로 이루어지게 됩니다.

 코틀린에서 Dispatchers class로 정의한 코드를 보면, Default, Main, Unconfined, IO 총 4개로 구성되어 있습니다. 하나를 예로 들자면 안드로이드의 경우, UI 작업을 진행하기 위해서는 OS내에 하나뿐인 Main Thread를 이용해서 작업을 진행해야 한다고 정의되어 있습니다. 이를 내부적인 구조는 모르지만(정확히는 말하자면 내부적인 구조를 모르더라도), 개발자들이 Main에서 사용하기 위해서는 Dispatchers.Main을 scope의 파라미터로 전달하면 이미 정의되어 있는 여러 로직들을 타고 Main Thread에서 함수를 실행시켜 주게 되는 것입니다.

 이처럼 Dispatcher는 각각의 기능을 각자 위치에 맞는 Thread에 전달해주는 역할을 담당한다고 볼 수 있습니다. 이를 코틀린에서는 이해하기 쉽게 Dispatchers로 정의해둔 것이구요. Coroutine -> Dispatcher -> Thread 형태로 로직이 전달된다고 생각하면 편할 것 같습니다. 물론 실제로 Dispatcher라는 것이 Thread에게 로직을 전달하면서 직접 ThreadPool 을 담당하는 것은 아닙니다. 실제 쓰레드는 스케쥴러라는 친구가 따로 관리하고 있습니다. 이때 Dispatcher는 실제 스레드를 어떻게 관리할 것인지에 대한 고민을 추상화시켜놓은 것으로 정의에 가깝다고 생각하면 편할 것 같습니다.

 

 정의된 Dispatcher들 중 먼저 위에서 예시로 설명했던 Main부터 살펴보겠습니다. 

@JvmStatic
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher

 DIspatchers에 정의된 Main을 보시면 위와 같이 정의되어 있습니다. 아래 처럼 내부적으로 코드를 더 들어가보면 주석이 많은데, 중요한 문구는 어플리케이션의 Main이나 UI쓰레드에 한정된다는 것입니다. 이는 안드로이드의 Main Thread를 구성하는 부분을 컨트롤한다고 이해하면 될 것 같습니다.

/**
 * Base class for special [CoroutineDispatcher] which is confined to application "Main" or "UI" thread
 * and used for any UI-based activities. Instance of `MainDispatcher` can be obtained by [Dispatchers.Main].
 *
 * Platform may or may not provide instance of `MainDispatcher`, see documentation to [Dispatchers.Main]
 */

 

 그 다음은 IO입니다. IO는 이름부터 Input/Output인지라 읽기/쓰기 작업에 최적화되어 있는 Dispatcher라는 것을 알 수 있습니다. 그래서 주로 Network 통신이나 Room과 같은 LocalDB 처리와 같은 기능들을 구현합니다. 특히 안드로이드는 정책상 Network 통신은 Main Thread에서 이루어지면 Exception을 발생시키기 때문에 coroutine scope을 통한 Dispatcher를 사용할 경우 무조건 특정 Dispatcher를 선언해줘야 하므로 자연스럽게 IO를 사용하게 됩니다.

The CoroutineDispatcher that is designed for offloading blocking IO tasks to a shared pool of threads.
Additional threads in this pool are created and are shutdown on demand. The number of threads used by tasks in this dispatcher is limited by the value of "kotlinx. coroutines. io. parallelism" (IO_PARALLELISM_PROPERTY_NAME) system property. It defaults to the limit of 64 threads or the number of cores (whichever is larger).

Elasticity for limited parallelism
Dispatchers.IO has a unique property of elasticity: its views obtained with CoroutineDispatcher. limitedParallelism are not restricted by the Dispatchers. IO parallelism. Conceptually, there is a dispatcher backed by an unlimited pool of threads, and both Dispatchers. IO and views of Dispatchers. IO are actually views of that dispatcher. In practice this means that, despite not abiding by Dispatchers. IO's parallelism restrictions, its views share threads and resources with it.

 제가 영어를 잘하는 편이 아니기 때문에 해석은 추가로 달지 않겠습니다(오해를 살 수도 있으니 원문을 보는 것이 더 정확할 것 같습니다). 그래도 설명 없이 넘어갈 수 없으니 대략적으로 요약해서 얘기하자면, IO 디스패처의 쓰레드풀에서는 필요에 따라 여러 Thread가 생성되고 종료될 수 있음을 명시했습니다. 그 수의 제한은 max(64로 명시된 시스템 값, 코어 수)라고 하네요. 

 

 또 다른 Dispatcher는 Default입니다. default라는 이름이니 저는 사실 scope의 Dispatcher를 선언하지 않으면 이 Dispatcher로 도달하는 줄 알았습니다만, 그건 아니었습니다.

 

Default

Default

kotlinlang.org

  default는 딱히 특별한 주석이 달린 부분이 없어 공식 문서로 대체합니다. default에 대한 설정 중 스레드 수에 대한 설명이 있는데요. Default의 경우 'max = CPU의 코어 수' 라고 하네요. 보통 물리적으로 많은 연산이나 복잡한 작업이 필요할 때 Default Dispatcher를 이용해 작업하는 것이 좋다고 소개되고 있습니다.

 IO와 Default가 각기 다른 방식으로 사용하라고 추천받는다고 하여도 실제 내부적으로 Thread를 분리하는 로직이 있는 것은 아니며, Default에서 Network 통신을 한다고 해도 Exception을 발생시키는 것 또한 아닙니다. 그저 기본적으로 IO가 사용하는 Thread의 개수가 훨씬 많습니다(특정 조건에서는 동일해질 수도 있습니다만, 보통의 경우에는). 이는 Network나 DB 통신의 경우 Thread가 점유된 상태로 대기하는 시간이 많기 때문에 내부적으로 Context Switching이 일어나도 그에 따른 낭비가 덜하고, 1개의 CPU가 여러개의 Thread를 사용하면 오히려 대기중인 일을 멈춰두고 그 리소스로 다른 일을 할 수 있게 되어 더 효율적으로 작업이 가능하니 IO의 경우에는 코어수보다 기본적으로 많은 Max 값이 제공되는 것입니다. 

 그에 반해 Default는 Main Thread에서 무겁거나 복잡하여 오랜 시간이 걸리는 연산을 진행하면서 발생하는 UI의 프레임 드랍 현상등 UX를 저해하는 현상의 발생을 방지하기 위해 다른 Thread에서의 처리를 권장하는 것이며 그렇기 때문에 하나의 일을 하나의 Thread가 한 번에 처리해야 자원의 낭비가 제일 덜하기 때문에 Max값이 CPU의 코어 수로 지정되어 있습니다.

 

 마지막으로는 Unconfined입니다. 저는 이 부분을 거의 사용하지 않고 있는데요. 코드에서는 아래와 같이 주석이 작성되어 있습니다.

A coroutine dispatcher that is not confined to any specific thread.

 위 주석은 Unconfined 클래스의 주석인데요. 간단하게 특정한 스레드에 귀속되지 않는 dispatcher라는 의미로, Main의 경우 Main Thread에서, default와 IO도 특정 Thread에서 실행하면 계속해서 그 Thread에서 실행이 되는 반면, Unconfined는 현재 실행중인 Thread에서 먼저 코드를 실행하다가 첫 중단점이 온 이후 다른 Thread에서 재개하게 되면 그 Thread에서 로직을 이어가게 된다는 것을 의미합니다.

 좀 더 풀어서 얘기하자면, Main Thread에서 Unconfined 내의 블럭이 실행되었다면 처음에는 Main Thread에서 그 블럭은 계속 진행이 될 것입니다. 그러나 중간부분에 suspend 함수나 delay와 같이 잠시 Thread을 blocking 하는 함수가 존재한다면 아마 코루틴 블럭은 잠시 수행이 중단될 것입니다. 이때 자연스럽게 Unconfined 블럭은 그 함수를 호출한 Dispatcher를 따르게 된다는 것입니다(만약 suspend fun이 Network 통신을 진행하는 함수라면 자연스럽게 IO를 호출했을 것이고, 이게 끝나 다시 Unconfined 블럭이 재개되면 IO Thread에서 하단 블럭이 이어지게 된다는 것을 의미합니다).

 이 Dispatcher는 블럭 내에서 자체적으로 ContextSwitching이 일어나지 않기 때문에, 관련한 리소스의 소모를 줄일 수 있다는 장점이 있습니다. 하지만 이 블럭이 어떤 Thread에서 실행될 지 보장할 수 없기에 편하게 사용하기는 어려울 것으로 보입니다.

 

Dispatcher 적용

 Dispatcher는 주로 launch의 파라미터로 들어가게 되는데요. 이 launch는 CoroutineScope, GlobalScope, runBlocking, viewModelScope, lifecycleScope 등에서 사용할 수 있습니다. 안드로이드에서는 대부분 viewModelScope, lifecycleScope을 사용하며, suspend나 withContext덕에 runBlocking은 사용할 일이 거의 없습니다(괜한 오류를 방지하기 위해 오히려 사용을 지양하는 것으로 알고 있습니다). 그러나 코프링이나 다른 코틀린 모듈의 경우 프레임워크 종속적인 두 scope은 사용할 수 없으니 제외하고, 나머지는 상황에 따라 사용하게 됩니다(외에도 여러가지 상세한 적용이 가능한 것들이 있습니다만, Dispatcher를 다루는 것이 목적이라 깊게 접근하지는 않겠습니다.).

viewModelScope.launch(Dispatchers.Default) {
    // 로직
}

viewLifecycleOwner.lifecycleScope.launch {
    // 로직
}

CoroutineScope(Dispatchers.IO).launch {
    // 로직
}

 코드에서는 위와 같이 간단하게 launch 블럭과 Dispatchers를 통해 원하는 Dispatcher와 coroutine을 호출할 수 있습니다. 특정 Thread를 호출하기 위해 자바에서 해야 했던 여러가지 일들에 비하면 굉장히 편하게 생성할 수 있습니다(요새는 자바도 굉장히 편하게 할 수 있는 것 같지만, 안드로이드의 자바는 아직...).


 Dispatcher만 부분적으로 설명하려고 하니 제 머리속에서는 이해가 되는데 이 글 자체로서의 완벽한 이해는 어렵다고 생각이 되네요. 가능한 쉽게 풀어쓰려고 했지만, 쓰레드를 모르면 이 부분들이 와닿는 게 없을 것 같기도 하고... 물론 이 블로그까지 도달하신 분들은 이미 다른 블로그의 글들을 많이 봐서 괜찮지 않을까 싶긴 합니다...

 Dispatcher가 안드로이드 개발자로서 거의 당연하게 사용하는 부분인데 내부 구조나 이런 것들을 크게 생각하지 않고 사용하는 경우가 많은 것 같습니다. 사실 Dispatcher보다는 코루틴을 몰라서 문제가 되는 경우가 대부분이긴 하지만... 

댓글