[Coroutines] CoroutineDispatcher 살펴보기

hxeyexn·2025년 11월 15일

Kotlin Coroutines

목록 보기
4/5

목차

  • Intro
  • CoroutineDispatcher란?
  • CoroutineDispatcher는 어떻게 동작할까?
  • 제한된 디스패처와 무제한 디스패처
  • 제한된 디스패처 생성하기
  • 부모 코루틴의 CoroutineDispatcher 사용해 자식 코루틴 실행하기
  • 미리 정의된 CoroutineDispatcher
    • Dispatchers.IO
    • Dispatchers.Default
    • I/O 작업과 CPU 바운드 작업의 차이
    • Dispatchers.Default.limitedParallelism로 스레드 사용 제한하기
    • 공유 스레드풀을 사용하는 Dispatchers.IO와 Dispatchers.Default
    • Dispatchers.Main
  • Outro

선행 지식

  • 동시성과 병렬성

Intro

지난 시간에는 코틀틴 코루틴을 사용하는 이유에 대해 알아보았다.
오늘은 CoroutineDispatcher를 살펴보겠다. 전반적인 내용은 『코틀린 코루틴의 정석』이라는 책을 읽으며 이해한 내용을 바탕으로 작성하겠다. 이번 포스트는 정보를 전달하기보다는, 필자가 이해한 바를 정리해 기록하기 위한 목적이 크다.



CoroutineDispatcher란?

사전을 찾아보면 dispatcher는 다음과 같은 의미를 갖는다.

운행 관리원, 비상 차량 배치 담당자의 공통점은 배치하고 관리하는 주체라는 것이다. dispatch'보내다'라는 의미가 있다. 즉, CoroutineDispatcher는 코루틴을 스레드로 보내 실행시키는 주체이다.

CoroutineDispatcher는 코루틴을 스레드로 보내는 데 사용할 수 있는 스레드나 스레드 풀을 가지며, 코루틴을 실행 요청한 스레드에서 코루틴이 실행되도록 만들 수 있다.



CoroutineDispatcher는 어떻게 동작할까?

CoroutineDispatcher는 작업 대기열과 스레드 풀로 구성되어 있다.

  1. 작업 대기열: 객체가 실행돼야 하는 작업을 저장
  2. 스레드 풀: CoroutineDispatcher 객체가 사용할 수 있는 스레드의 집합

CoroutineDispatcher는 코루틴 실행 요청을 받으면, 먼저 그 코루틴을 작업 대기열에 올려놓는다. 그런 다음 CoroutineDispatcher는 자신이 사용할 수 있는 스레드가 있는지 확인한다. 사용할 수 있는 스레드가 존재한다면 작업 대기열에 올려 둔 코루틴을 해당 스레드로 보내 실행한다.

만약 모든 스레드가 이미 코루틴을 실행 중이라면, CoroutineDispatcher는 새로운 코루틴을 스레드에 바로 보낼 수 없어서 작업 대기열에 넣어둔다. 이후 스레드 풀의 스레드 중 여유가 생기면 그때 새로운 코루틴을 해당 스레드로 보낸다.



제한된 디스패처와 무제한 디스패처

CoroutineDispatcher에는 두 가지 종류가 있다.

  1. 제한된 디스패처: 사용할 수 있는 스레드나 스레드 풀이 제한된 디스패처
  2. 무제한 디스패처: 사용할 수 있는 스레드나 스레드 풀이 제한되지 않은 디스패처

제한된 디스패처

제한된 디스패처란 이름 그대로 사용할 수 있는 스레드나 스레드 풀이 제한된 디스패처이다.

일반적으로는 처리할 작업에 따라 CoroutineDispatcher마다 역할을 미리 정해 두고, 그 역할에 맞춰 실행을 요청하는 것이 더 효율적이다.

  • 입출력(I/O) 작업: 입출력 작업용 CoroutineDispatcher 객체에 실행을 요청
  • CPU 연산 작업: CPU 연산 작업용 CoroutineDispatcher 객체에 실행을 요청

무제한 디스패처

제한된 디스패처와 달리, 이 디스패처는 사용하는 스레드나 스레드 풀이 제한되지 않는다. 하지만 스레드 수가 제한되지 않는다고 해서, 실행 요청된 코루틴이 아무 스레드에서나 무작위로 실행되는 것은 아니다.

무제한 디스패처는 실행 요청된 코루틴이 이전 코드가 실행되던 스레드에서 계속해서 실행되도록 한다. 그래서 매번 실행되는 스레드가 달라질 수 있고, 특정 스레드에 제한돼 있지 않다는 의미에서 '무제한 디스패처'라는 이름이 붙었다.



제한된 디스패처 생성하기

단일 스레드 디스패처와 멀티 스레드 디스패처를 생성해 코루틴을 실행해 보자.
newSingleThreadContextnewFixedThreadPoolContext를 이용하면 디스패처를 쉽게 생성할 수 있다.

newSingleThreadContextnewFixedThreadPoolContext 함수로 CoroutineDispatcher 객체를 생성하면, 섬세하게 다뤄야 하는 API라고 경고가 출력된다. 이에 대한 자세한 설명은 뒤에서 다룰 예정이니 지금은 크게 신경 쓰지 않아도 된다.

단일 스레드 디스패처

fun main() = runBlocking<Unit> {  
	// 사용할 수 있는 스레드가 하나인 CoroutineDispatcher
    val dispatcher: CoroutineDispatcher = newSingleThreadContext(name = "SingleThread")  
      
    launch(context = dispatcher) {  
        println("[${Thread.currentThread().name}] 실행")  
    }  
}
[SingleThread @coroutine#2] 실행

멀티 스레드 디스패처

fun main() = runBlocking<Unit> {  
    val multiThreadDispatcher: CoroutineDispatcher = newFixedThreadPoolContext(  
        nThreads = 2,   
        name = "MultiThread"  
    )  
  
    launch(context = multiThreadDispatcher) {  
        println("[${Thread.currentThread().name}] 실행")  
    }  
    launch(context = multiThreadDispatcher) {  
        println("[${Thread.currentThread().name}] 실행")  
    }  
}
[MultiThread-1 @coroutine#2] 실행
[MultiThread-2 @coroutine#3] 실행

newFixedThreadPoolContext 함수로 만들어진 CoroutineDispatcher의 모습은 newSingleThreadPoolContext 함수를 호출해 만들어진 CoroutineDispatcher와 매우 비슷하다. 그 이유는 newSingleThreadPoolContext가 내부적으로 newFixedThreadPoolContext를 사용하도록 구현돼 있기 때문이다.

public fun newSingleThreadContext(name: String): CloseableCoroutineDispatcher =  
    newFixedThreadPoolContext(1, name)


부모 코루틴의 CoroutineDispatcher 사용해 자식 코루틴 실행하기

코루틴은 구조화를 통해 코루틴 내부에서 새로운 코루틴을 실행할 수 있다.
이때 바깥쪽 코루틴을 '부모 코루틴', 내부에서 생성되는 코루틴을 '자식 코루틴'이라고 한다. 구조화코루틴을 계층적으로 구성하는 것뿐만 아니라, 부모 코루틴의 실행 환경을 자식 코루틴에 전달하는 역할도 한다.

따라서 자식 코루틴에 별도의 CoroutineDispatcher 객체가 설정되어 있지 않으면, 부모 코루틴의 CoroutineDispatcher를 그대로 사용하게 된다.

fun main() = runBlocking<Unit> {  
    val multiThreadDispatcher: CoroutineDispatcher = newFixedThreadPoolContext(  
        nThreads = 2,  
        name = "MultiThread"  
    )  
  
    launch(context = multiThreadDispatcher) { // 부모 코루틴  
        println("[${Thread.currentThread().name}] 부모 코루틴 실행")  
        launch { // 자식 코루틴  
            println("[${Thread.currentThread().name}] 자식 코루틴 실행")  
        }  
        launch { // 자식 코루틴  
            println("[${Thread.currentThread().name}] 자식 코루틴 실행")  
        }  
    }  
}
[MultiThread-1 @coroutine#2] 부모 코루틴 실행
[MultiThread-2 @coroutine#3] 자식 코루틴 실행
[MultiThread-1 @coroutine#4] 자식 코루틴 실행

실행 결과를 보면 부모 코루틴과 자식 코루틴이 MultiThread-1과 MultiThread-2를 공용으로 사용하는 것을 확인할 수 있다. 이는 부모와 자식 코루틴이 동일한 CoroutineDispatcher 객체를 사용하기 때문이다.



미리 정의된 CoroutineDispatcher

앞서 newSingleThreadContextnewFixedThreadPoolContext 함수로 CoroutineDispatcher 객체를 생성하면, 섬세하게 다뤄야 하는 API라고 경고가 출력됐다. 이후부터는 편의상 두 함수를 통틀어 newFixedThreadPoolContext라고 부르겠다.

이런 경고를 나타나는 이유가 뭘까?
사용자가 newFixedThreadPoolContext 함수를 사용해 CoroutineDispatcher 객체를 생성하는 것이 비효율적일 가능성이 높기 때문이다.

newFixedThreadPoolContext 함수를 사용하면 특정 CoroutineDispatcher 전용 스레드풀이 새로 생성되는데, 스레드풀에 속한 스레드의 수가 너무 적거나 많아 비효율적으로 동작할 수 있다.

또한 여러 개발자가 함께 개발하는 환경에서는 특정 용도로 이미 존재하는 CoroutineDispatcher 객체의 존재를 모른 채 다시 객체를 생성하게 되어 메모리와 리소스를 낭비할 위험도 있다.

이러한 문제들을 방지하기 위해, 미리 정의된 CoroutineDispatcher 목록을 제공해 개발자가 직접 CoroutineDispatcher 객체를 생성하는 문제를 방지한다. 우리가 익숙하게 사용하는 Dispatchers.IO, Dispatchers.Default, Dispatchers.Main이 바로 미리 정의된 CoroutineDispatcher의 목록에 속한다.

목록

  • Dispatchers.IO: 네트워크 요청이나 파일 입출력 등의 입출력(I/O) 작업을 위한 CoroutineDispatcher
  • Dispatchers.Default: CPU를 많이 사용하는 연산 작업을 위한 CoroutineDispatcher
  • Dispatchers.Main: 메인 스레드를 사용하기 위한 CoroutineDispatcher

Dispatchers.Unconfined는 제한된 디스패처가 아니기 때문에 이번 포스트에선 다루지 않겠다.


IO bound와 CPU bound 작업을 처리할 때 사용하는 디스패처가 바로 Dispatchers.IODispatchers.Default이다.

프로세스의 인생은 IO burst와 CPU burst의 연속이라고 해도 과언이 아니다.
여기서 'burst'란 짧은 시간 동안 특정 작업이 집중적으로 일어나는 구간을 의미한다.

IO burst는 프로세스가 IO 작업을 요청하고 그 결과를 기다리는 시간이고, CPU burst는 프로세스가 CPU에서 연속적으로 실행되는 시간이다.

따라서 CPU bound 프로세스는 CPU burst가 많은 프로세스이며, IO bound 프로세스는 IO burst가 많은 프로세스라고 할 수 있다.


Dispatchers.IO

애플리케이션에서는 HTTP 요청과 같은 네트워크 통신이나 DB 작업 등 여러 입출력 작업을 동시에 수행한다. 이러한 요청을 병렬로 처리하려면 많은 스레드가 필요한데, 그 역할을 해주는 것이 바로 Dispatchers.IO이다.

공식 문서에 따르면, Dispatchers.IO가 사용할 수 있는 최대 스레드 수JVM에서 사용할 수 있는 코어 수와 64 중 더 큰 값으로 설정한다고 한다.

Kotlin 공식 문서 Dispatchers/IO
It defaults to the limit of 64 threads or the number of cores (whichever is larger).

fun main() = runBlocking<Unit> {  
    launch(Dispatchers.IO) {   
        println("[${Thread.currentThread().name}] 코루틴 실행")  
    }  
}
[DefaultDispatcher-worker-1 @coroutine#2] 코루틴 실행

Dispatchers.Default

대용량 데이터를 처리해야 하는 작업처럼 CPU 연산이 집중적으로 필요한 작업을 CPU bound 작업이라고 한다. 대표적으로 동영상 편집 프로그램이나 머신러닝 프로그램과 같은 작업이 이에 해당한다. Dispatchers.Default는 CPU 바운드 작업이 필요할 때 사용하는 CoroutineDispatcher이다.

fun main() = runBlocking<Unit> {  
    launch(Dispatchers.Default) {   
        println("[${Thread.currentThread().name}] 코루틴 실행")  
    }  
}
[DefaultDispatcher-worker-1 @coroutine#2] 코루틴 실행

Kotlin 공식 문서 Dispatchers/IO
By default, the maximum number of threads used by this dispatcher is equal to the number of CPU cores, but is at least two.

기본적으로 Dispatchers.Default가 사용하는 최대 스레드 수는 CPU 코어 수와 동일하지만, 최소한 두 개는 확보된다.


I/O 작업과 CPU 바운드 작업의 차이

I/O 작업과 CPU 바운드 작업의 가장 큰 차이는 작업이 실행되는 동안 스레드를 지속적으로 사용하느냐의 여부이다.

일반적으로 I/O 작업은 요청을 보낸 뒤 결과를 기다리는 동안 스레드를 사용하지 않는다. 따라서 코루틴으로 입출력 작업을 수행하면, 대기 시간 동안 해당 스레드에서 다른 I/O 작업을 동시에 실행할 수 있어서 효율적이다.

반면 CPU 바운드 작업은 실행되는 동안 스레드를 계속 사용한다. 코루틴으로 실행하더라도 스레드가 지속적으로 사용되기 때문에 스레드 기반 방식과 비교했을 때 처리 속도에서 큰 차이가 없다.

이처럼 Dispatchers.Default로 무겁고 오래 걸리는 연산을 처리하면 특정 연산이 Dispatchers.Default에 있는 모든 스레드를 차지할 수 있다. 이런 경우 다른 연산들은 스레드가 비워질 때까지 기다려야 해서 바로 실행되지 못한다.

이를 방지하기 위해 코루틴 라이브러리는 limitedParallelism 함수를 제공한다. 이 함수를 사용하면 Dispatchers.Default의 일부 스레드만 사용해 특정 연산을 실행할 수 있다.


Dispatchers.Default.limitedParallelism로 스레드 사용 제한하기

limitedParallelism의 사용법은 다음과 같다. 아래는Dispatchers.Default.limitedParallelism(2)를 사용해 Dispatchers.Default의 여러 스레드 중 2개만 활용하여 10개의 코루틴을 실행하는 코드이다.

fun main() = runBlocking<Unit> {  
    launch(Dispatchers.Default.limitedParallelism(2)) {  
        repeat(10) {  
            launch {  
                println("[${Thread.currentThread().name}] 코루틴 실행")  
            }  
        }  
    }  
}
[DefaultDispatcher-worker-2 @coroutine#3] 코루틴 실행
[DefaultDispatcher-worker-1 @coroutine#4] 코루틴 실행
[DefaultDispatcher-worker-2 @coroutine#5] 코루틴 실행
...
[DefaultDispatcher-worker-1 @coroutine#10] 코루틴 실행
[DefaultDispatcher-worker-2 @coroutine#11] 코루틴 실행
[DefaultDispatcher-worker-2 @coroutine#12] 코루틴 실행

공유 스레드풀을 사용하는 Dispatchers.IO와 Dispatchers.Default

Dispatchers.IO 실행 결과

[DefaultDispatcher-worker-1 @coroutine#2] 코루틴 실행

Dispatchers.Default 실행 결과

[DefaultDispatcher-worker-1 @coroutine#2] 코루틴 실행

앞서 Dispatchers.IODispatchers.Default로 코루틴을 실행한 결과, 스레드 이름의 접두사가 모두 DefaultDispatcher-worker로 동일한 것을 확인할 수 있다. 이 스레드는 코루틴 라이브러리의 공유 스레드 풀에 속하며, 코루틴 라이브러리는 스레드 생성과 관리를 위해 애플리케이션 레벨의 공유 스레드 풀과 관련 API를 제공한다. Dispatchers.IODispatchers.Default는 모두 이 API를 사용해 구현됐기 때문에 같은 스레드 풀을 사용하는 것이다. 물론, 스레드 풀내에서 Dispatchers.IODispatchers.Default가 사용하는 스레드는 구분된다.

newFixedThreadPoolContext로 생성된 디스패처는 자신만 사용할 수 있는 전용 스레드풀을 생성하는 반면, Dispatchers.IODispatchers.Default는 공유 스레드풀의 스레드를 사용한다는 점이 중요하다.


Dispatchers.Main

Dispatchers.IODispatchers.Default는 코루틴 라이브러리만 추가하면 사용할 수 있지만,Dispatchers.Main은 별도 라이브러리를 추가해야 사용할 수 있다. 이는 Dispatchers.Main이 안드로이드와 같은 UI 애플리케이션에서 메인 스레드를 위해 사용되는 CoroutineDispatcher이기 때문이다.



Outro

요약

  • CoroutineDispatcher는 코루틴을 스레드에서 실행하도록 관리하는 객체
  • 제한된 디스패처는 사용할 수 있는 스레드가 제한되며, 무제한 디스패처는 제한되지 않음
  • launch에서 context로 CoroutineDispatcher를 넘기면 해당 디스패처를 사용해 코루틴 실행
  • 자식 코루틴은 기본적으로 부모의 디스패처를 상속
  • 코루틴 라이브러리는 Dispatchers.IO, Dispatchers.Default, Dispatchers.Main을 제공
  • Dispatchers.IO는 네트워크, 파일 I/O 등 입출력 작업용
  • Dispatchers.Default는 CPU 연산 집중 작업용
  • Dispatchers.IO와 Dispatchers.Default는 공유 스레드풀 사용
  • Dispatchers.Main은 메인 스레드에서 실행되며, 별도 라이브러리 필요

다음 시간에는 코루틴 빌더와 Job에 대해 알아보겠다.



참고자료

profile
Android Developer

0개의 댓글