[Kotlin] 코틀린 코루틴의 정석 (3) - CoroutineDispatcher

도톨이·2025년 9월 8일

Kotlin

목록 보기
10/10

지난 시간에 이어 코루틴의 정석이라는 책을 읽으며 코루틴을 학습하려고 한다.
(구매처 : 교보문고 사이트 )

Coroutine Dispatcher

코틀린에서 코루틴을 이해하려면 코루틴 디스패처(Coroutine Dispatcher) 개념을 알아야 한다.
코루틴 자체는 스레드 위에서 동작하기 때문에 코루틴이 실제 어느 스레드에서 실행될지를 결정하는 주체가 필요하다. 바로 이 역할을 하는 것이 디스패처이다.

  • 코루틴 디스패처 : 코루틴을 스레드로 보내 실행시키는 관리자 역할

1. 코루틴 디스패처란?

  • 코루틴을 스레드로 보내 실행시키는 관리자
  • 사용할 수 있는 스레드 혹은 스레드풀(Thread Pool)을 내부적으로 가진다
  • 코루틴이 실행 요청되면, 디스패처는 이를 작업 대기열(Queue) 에 적재하고 실행 가능한 스레드가 있으면 바로 배정한다

동작 예시

  1. 스레드풀이 2개 스레드를 가진 디스패처가 있다고 하자.
  2. 코루틴1 실행 요청 → 스레드1에서 실행 시작
  3. 코루틴2 실행 요청 → 스레드2에서 실행 시작
  4. 코루틴3 실행 요청 → 스레드가 꽉 차 있으므로 대기열에 저장
  5. 스레드1이 끝나면 → 코루틴3이 스레드1로 배정되어 실행

👉 쉽게 말해 “코루틴 작업 스케줄러” 역할을 한다.

스레드 vs 코루틴 참고
지난 블로그에도 나와있지만 가볍게 설명하자면

  • 스레드 : 운영체제가 직접 관리하는 실행 단위. 생성/해제 비용 큼
  • 코루틴 : 라이브러리 레벨에서 관리되는 경량 실행 단위. 하나의 스레드 위에서 수천개까지도 실행 가능. 코루틴이 아무리 가벼워도 결국 실행은 스레드가 한다.

2. 제한된 디스패처 vs 무제한 디스패처

디스패처는 크게 두 가지로 나뉜다.

  • 제한된 디스패처

    • 사용할 수 있는 스레드가 정해져 있음
    • 대부분의 경우 이 방식을 사용
    • 예: Dispatchers.IO(입출력 전용), Dispatchers.Default(CPU 연산 전용)
  • 무제한 디스패처

    • 스레드 제한 없이 필요할 때마다 새 스레드를 만듦
    • 하지만 스레드 생성 비용이 크기 때문에 거의 사용하지 않음

3. 단일 스레드 디스패처

스레드 하나만 사용하는 디스패처를 만들 수도 있다.
이는 newSingleThreadContext 함수를 사용해 생성한다.

val dispatcher: CoroutineDispatcher = newSingleThreadContext(name = "SingleThread")
  • 내부적으로 작업 대기열 + 스레드 하나로 구성된 스레드풀이 만들어진다
  • 스레드 이름은 인자로 준 "SingleThread"가 된다

4. 멀티 스레드 디스패처

여러 개 스레드를 동시에 사용하는 디스패처는 newFixedThreadPoolContext 함수로 만들 수 있다.

val multiThreadDispatcher: CoroutineDispatcher = 
    newFixedThreadPoolContext(nThreads = 2, name = "MultiThread")
  • 스레드 이름은 MultiThread-1, MultiThread-2 식으로 붙는다
  • 내부적으로 작업 대기열을 관리하면서 스레드가 비면 코루틴을 배정한다
  • 참고: newSingleThreadContext 역시 newFixedThreadPoolContext(1, name)의 구현체다

5. newFixedThreadPoolContext 내부 구현

코루틴 라이브러리 1.7.2 기준 newFixedThreadPoolContext는 아래처럼 구현되어 있다.
내부적으로 Executor 프레임워크를 활용해 스레드풀을 만들고 이를 CoroutineDispatcher로 래핑한다.

@DelicateCoroutinesApi
public actual fun newFixedThreadPoolContext(
    nThreads: Int, 
    name: String
): ExecutorCoroutineDispatcher {
    require(nThreads >= 1) { "Expected at least one thread, but $nThreads specified" }
    val threadNo = AtomicInteger()

    // Executor 프레임워크로 스레드풀 생성
    val executor = Executors.newScheduledThreadPool(nThreads) { runnable ->
        val t = Thread(
            runnable,
            if (nThreads == 1) name else name + '-' + threadNo.incrementAndGet()
        )
        t.isDaemon = true
        t
    }
    return executor.asCoroutineDispatcher()
}

6. 코루틴 실행 방법 – launch 함수

디스패처를 지정해 코루틴을 실행하려면 launch 함수의 context 인자로 넘기면 된다.

단일 스레드 디스패처 예시

import kotlinx.coroutines.*

fun main() = runBlocking {
    val dispatcher = newSingleThreadContext("SingleThread")

    launch(dispatcher) {
        println("Running on thread: ${Thread.currentThread().name}")
    }
}

실행 결과:

Running on thread: SingleThread

멀티 스레드 디스패처 예시

fun main() = runBlocking {
    val multiThreadDispatcher = newFixedThreadPoolContext(2, "MultiThread")

    launch(multiThreadDispatcher) {
        println("[${Thread.currentThread().name}] coroutine#1 실행")
    }
    launch(multiThreadDispatcher) {
        println("[${Thread.currentThread().name}] coroutine#2 실행")
    }
}

실행 결과(예시):

[MultiThread-1 @coroutine#2] coroutine#1 실행
[MultiThread-2 @coroutine#3] coroutine#2 실행

7. 부모 코루틴의 디스패처를 자식 코루틴이 물려받기

코루틴은 구조화를 지원하기 때문에, 부모 코루틴 내부에서 자식 코루틴을 실행할 수 있다.
이때 자식 코루틴에 별도로 디스패처를 지정하지 않으면 부모 코루틴의 디스패처를 그대로 상속받는다.

fun main() = runBlocking {
    val dispatcher = newFixedThreadPoolContext(2, "MultiThread")

    launch(dispatcher) {
        println("[${Thread.currentThread().name}] 부모 코루틴 실행")

        launch {
            println("[${Thread.currentThread().name}] 자식 코루틴 실행1")
        }
        launch {
            println("[${Thread.currentThread().name}] 자식 코루틴 실행2")
        }
    }
}

실행 결과:

[MultiThread-1 @coroutine#2] 부모 코루틴 실행
[MultiThread-2 @coroutine#3] 자식 코루틴 실행1
[MultiThread-2 @coroutine#4] 자식 코루틴 실행2

👉 부모와 자식이 같은 디스패처 객체를 공유하기 때문에, 스레드풀 내에서 스레드가 적절히 배정된다.


8. 미리 정의된 CoroutineDispatcher

실제로 앱 개발에서는 스레드풀을 직접 만들기보다는, 코틀린에서 기본 제공하는 미리 정의된 디스패처를 사용하는 경우가 대부분이다.

  • Dispatchers.IO → 네트워크, DB, 파일 읽기/쓰기 등 입출력 작업 전용
  • Dispatchers.Default → 대규모 계산, 데이터 처리 등 CPU 연산 작업 전용
  • Dispatchers.MainUI 업데이트용, 안드로이드/JavaFX/Swing의 메인 스레드

Dispatchers.IO

대규모 네트워크 요청, DB I/O 등에 최적화된 디스패처이다.

fun main() = runBlocking {
    launch(Dispatchers.IO) {
        println("[${Thread.currentThread().name}] IO 작업 실행")
    }
}

출력:

[DefaultDispatcher-worker-1 @coroutine#2] IO 작업 실행

👉 Dispatchers.IO는 내부적으로 JVM 프로세서 개수와 64 중 큰 값을 기준으로 스레드를 할당하여, 동시에 많은 IO 작업을 처리할 수 있다.


Dispatchers.Default

fun main() = runBlocking {
    launch(Dispatchers.Default) {
        println("[${Thread.currentThread().name}] CPU 연산 실행")
    }
}

출력:

[DefaultDispatcher-worker-2 @coroutine#2] CPU 연산 실행

👉 CPU 바운드 작업에 최적화된 디스패처로, 내부적으로 공유 스레드풀을 사용한다.
필요하면 limitedParallelism()을 사용해 일부 스레드만 제한적으로 사용하여 CPU 과부하를 막을 수 있다.


Dispatchers.Main

안드로이드나 데스크탑 UI 프로그래밍에서는 Dispatchers.Main이 기본으로 사용된다.
이는 UI 스레드(메인 스레드)에서 코루틴을 실행시켜 버튼 클릭, 화면 업데이트 같은 작업을 안전하게 수행할 수 있도록 해준다.

무거운 작업은 withContext(Dispatchers.IO)로 다른 Dispatcher에서 처리한 뒤,
UI 업데이트만 Main에서 실행하는 패턴이 안전하다.

suspend fun loadData() {
    val data = withContext(Dispatchers.IO) { api.getData() }
    withContext(Dispatchers.Main) { updateUI(data) }
}

Dispatcher 선택 기준

실제로 안드로이드 앱이나 서버 애플리케이션을 개발할 때는 어떤 Dispatcher를 선택해야 할까?

  1. 네트워크 요청, 파일/DB IO 작업 → Dispatchers.IO

    • 예: Retrofit으로 API 호출, Room DB 접근, 파일 읽기/쓰기
    • IO 작업은 오래 걸리지만 CPU는 많이 쓰지 않으므로, IO 전용 스레드풀에서 처리하는 것이 적합
  2. 복잡한 계산, 데이터 변환, 알고리즘 처리 → Dispatchers.Default

    • 예: 대용량 데이터 파싱, 이미지 필터 적용, 통계 계산
    • CPU 성능을 많이 소모하는 연산은 Default가 적합
  3. UI 업데이트 → Dispatchers.Main

    • 예: 안드로이드 화면에 텍스트 갱신, 버튼 클릭 처리, Compose 상태 변경
    • UI 관련 작업은 반드시 메인 스레드에서 실행해야 안전

👉 정리하면:

  • IO → Dispatchers.IO
  • CPU 연산 → Dispatchers.Default
  • UI → Dispatchers.Main

“작업 성격에 맞는 디스패처를 골라 쓰는 것”이 핵심이다.
만약 잘못된 디스패처를 선택하면, UI가 멈추거나, 리소스가 불필요하게 낭비될 수 있다.


profile
Kotlin, Flutter, AI | Computer Science

0개의 댓글