(Kotlin/코틀린) 코루틴 Dispatcher 심화 - IO, Main, Default, Unconfined 완전 정리

개요


1. Dispatcher란

Dispatcher는 CoroutineContext의 구성 요소로, 코루틴을 어떤 스레드에서 실행할지 결정하는 스케줄러입니다.

// Dispatcher 지정 방법
viewModelScope.launch(Dispatchers.IO) { /* IO 스레드 */ }
viewModelScope.launch(Dispatchers.Default) { /* CPU 스레드 */ }

withContext(Dispatchers.IO) { /* IO 스레드로 전환 */ }
withContext(Dispatchers.Main) { /* 메인 스레드로 전환 */ }

Dispatcher와 스레드 풀 관계

Dispatchers.Main     → 메인 스레드 1개
Dispatchers.Default  → CPU 코어 수만큼 (최소 2개)
Dispatchers.IO       → 최대 64개 (또는 CPU 코어 수 중 큰 값)
Dispatchers.Unconfined → 스레드 고정 없음

2. Dispatchers.IO

네트워크 요청, 파일 읽기/쓰기, 데이터베이스 쿼리처럼 I/O 대기가 발생하는 작업에 사용합니다.

내부 구조

Dispatchers.IO
├── 스레드 수: 최대 64개 (또는 Runtime.availableProcessors() 중 큰 값)
├── 특징: I/O 대기 중 스레드를 다른 코루틴에 재활용
└── Default와 스레드 풀 공유 (별도 풀 아님)
// IO — 네트워크, DB, 파일
suspend fun fetchUser(userId: Long): User {
    return withContext(Dispatchers.IO) {
        userApi.getUser(userId)   // 네트워크 대기 → 스레드를 다른 코루틴에 양보
    }
}

suspend fun readFile(path: String): String {
    return withContext(Dispatchers.IO) {
        File(path).readText()     // 파일 I/O 대기
    }
}

suspend fun queryDatabase(userId: Long): UserEntity? {
    return withContext(Dispatchers.IO) {
        userDao.getUser(userId)   // DB 쿼리 대기
    }
}

왜 IO 스레드가 많은가

IO 작업 특성:
- CPU는 거의 사용 안 함
- 대부분의 시간을 "대기" 상태로 보냄
    → 스레드를 많이 만들어도 CPU 경쟁 없음
    → 64개 스레드가 동시에 네트워크 대기 가능

Default 작업 특성:
- CPU를 지속적으로 사용
    → 스레드가 코어 수보다 많으면 컨텍스트 스위칭 비용만 늘어남
    → CPU 코어 수 = 최적 스레드 수

3. Dispatchers.Main

메인(UI) 스레드에서 코루틴을 실행합니다. Android에서는 뷰 업데이트, 애니메이션 제어 등 UI 작업에 사용합니다.

// Main — UI 업데이트
viewModelScope.launch(Dispatchers.Main) {
    binding.tvName.text = "홍길동"
    binding.progressBar.isVisible = false
}

Main vs Main.immediate

// Dispatchers.Main
// — 현재 스레드가 메인이어도 dispatch(큐에 넣기) 후 실행
viewModelScope.launch(Dispatchers.Main) {
    println("메인에서 실행")   // 큐를 거쳐서 실행 (약간의 지연 가능)
}

// Dispatchers.Main.immediate
// — 이미 메인 스레드이면 즉시 실행 (dispatch 생략)
viewModelScope.launch(Dispatchers.Main.immediate) {
    println("즉시 실행")   // 메인 스레드에서 호출 시 즉시
}
  Main Main.immediate
메인 스레드에서 호출 큐에 넣고 실행 즉시 실행
다른 스레드에서 호출 큐에 넣고 실행 큐에 넣고 실행
viewModelScope 기본값   ✅

viewModelScope의 기본 Dispatcher가 Main.immediate인 이유는 불필요한 지연 없이 즉시 UI를 업데이트하기 위해서입니다.

실전 — IO 후 Main으로 전환

@HiltViewModel
class ProductViewModel @Inject constructor(
    private val repository: ProductRepository
) : ViewModel() {

    fun loadProduct(id: Long) {
        // viewModelScope 기본 = Main.immediate
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }   // Main

            val product = withContext(Dispatchers.IO) {      // IO로 전환
                repository.getProduct(id)                    // 네트워크
            }                                               // 다시 Main

            _uiState.update { it.copy(product = product, isLoading = false) }  // Main
        }
    }
}

4. Dispatchers.Default

CPU 집중 연산에 사용합니다. 이미지 처리, JSON 파싱, 정렬, 암호화 등 계산량이 많은 작업에 적합합니다.

내부 구조

Dispatchers.Default
├── 스레드 수: Runtime.getRuntime().availableProcessors() (최소 2)
│   예) 8코어 CPU → 8개 스레드
├── 특징: CPU를 지속적으로 사용하는 작업에 최적화
└── IO와 스레드 풀 공유 (IO는 추가 스레드를 빌려 씀)
// Default — CPU 집중 작업
suspend fun parseProducts(json: String): List<Product> {
    return withContext(Dispatchers.Default) {
        Gson().fromJson(json, Array<Product>::class.java).toList()
    }
}

suspend fun sortProducts(products: List<Product>): List<Product> {
    return withContext(Dispatchers.Default) {
        products.sortedWith(
            compareByDescending<Product> { it.rating }.thenBy { it.name }
        )
    }
}

suspend fun encryptData(data: ByteArray, key: SecretKey): ByteArray {
    return withContext(Dispatchers.Default) {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.ENCRYPT_MODE, key)
        cipher.doFinal(data)
    }
}

suspend fun resizeBitmap(bitmap: Bitmap, maxWidth: Int): Bitmap {
    return withContext(Dispatchers.Default) {
        val ratio = maxWidth.toFloat() / bitmap.width
        val height = (bitmap.height * ratio).toInt()
        Bitmap.createScaledBitmap(bitmap, maxWidth, height, true)
    }
}

Default vs IO 선택 기준

CPU를 쉬지 않고 사용하는가?
    → YES: Dispatchers.Default
    → NO (대기 시간이 있는가?): Dispatchers.IO

예)
    JSON 파싱   → Default (CPU 연산)
    네트워크    → IO (네트워크 대기)
    이미지 처리 → Default (CPU 연산)
    파일 읽기   → IO (디스크 대기)
    DB 쿼리     → IO (DB 대기)
    암호화      → Default (CPU 연산)
    압축        → Default (CPU 연산)

5. Dispatchers.Unconfined

Unconfined는 특정 스레드에 고정되지 않는 특수 Dispatcher입니다.

동작 원리

// Unconfined — 처음엔 호출자 스레드, 중단 후엔 재개한 스레드에서 실행
viewModelScope.launch(Dispatchers.Unconfined) {
    println("1: ${Thread.currentThread().name}")   // main (호출자 스레드)
    delay(100)
    println("2: ${Thread.currentThread().name}")   // kotlinx.coroutines.DefaultExecutor
    // delay 이후 재개된 스레드에서 그대로 실행
}
호출 → [Main 스레드에서 시작]
          ↓ delay(중단)
       [재개 스레드에서 계속] ← 어떤 스레드인지 보장 없음

Unconfined 주의사항

// ❌ Unconfined를 프로덕션 코드에 사용하지 않는다
viewModelScope.launch(Dispatchers.Unconfined) {
    delay(100)
    binding.tvName.text = "완료"   // 어떤 스레드인지 모름 → 크래시 가능
}

// ✅ 테스트에서 제어용으로만 사용
@Test
fun `즉시 실행 테스트`() = runTest {
    val scope = CoroutineScope(Dispatchers.Unconfined)
    // 테스트에서 시간 제어 목적으로 활용
}
Dispatcher 실제 사용 테스트
Main ✅ UI 업데이트 TestCoroutineScheduler
IO ✅ 네트워크·DB UnconfinedTestDispatcher
Default ✅ CPU 연산 UnconfinedTestDispatcher
Unconfined ⚠️ 테스트 전용 ✅

6. limitedParallelism — IO 스레드 수 제한

Dispatchers.IO.limitedParallelism(n)으로 동시에 실행할 코루틴 수를 제한할 수 있습니다.

// 데이터베이스 연결이 최대 4개인 경우
val dbDispatcher = Dispatchers.IO.limitedParallelism(4)

suspend fun queryWithLimit(id: Long): Data {
    return withContext(dbDispatcher) {
        db.query(id)   // 최대 4개 코루틴만 동시에 DB 접근
    }
}

// API Rate Limit 대응 — 초당 최대 2개 요청
val rateLimitedDispatcher = Dispatchers.IO.limitedParallelism(2)

suspend fun fetchWithRateLimit(url: String): Response {
    return withContext(rateLimitedDispatcher) {
        httpClient.get(url)
    }
}

7. 커스텀 Dispatcher

ExecutorService로 직접 Dispatcher를 만들 수 있습니다.

// 단일 스레드 Dispatcher — 순서 보장이 필요할 때
val singleThreadDispatcher = Executors.newSingleThreadExecutor()
    .asCoroutineDispatcher()

// 특정 크기의 스레드 풀
val customDispatcher = Executors.newFixedThreadPool(4)
    .asCoroutineDispatcher()

// 사용 후 반드시 닫아야 함 (ExecutorService 해제)
singleThreadDispatcher.close()
customDispatcher.close()

// 실전 — Hilt로 주입
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {

    @IoDispatcher
    @Provides
    fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

    @DefaultDispatcher
    @Provides
    fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default

    @MainDispatcher
    @Provides
    fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
}

// Qualifier 정의
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher

@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class MainDispatcher

// Repository에서 주입받아 사용 → 테스트 시 교체 용이
class UserRepositoryImpl @Inject constructor(
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : UserRepository {
    override suspend fun getUser(id: Long): User {
        return withContext(ioDispatcher) {
            api.getUser(id)
        }
    }
}

8. 테스트에서 Dispatcher 교체

Hilt로 Dispatcher를 주입하면 테스트 시 UnconfinedTestDispatcher 로 교체해 즉시 실행할 수 있습니다.

class UserRepositoryTest {

    private val testDispatcher = UnconfinedTestDispatcher()

    @Test
    fun `getUser 성공 시 User 반환`() = runTest(testDispatcher) {
        // UnconfinedTestDispatcher로 교체 → delay 없이 즉시 실행
        val repository = UserRepositoryImpl(
            ioDispatcher = testDispatcher,   // 테스트용 Dispatcher 주입
            userApi = FakeUserApi()
        )

        val result = repository.getUser(1L)

        assertEquals("홍길동", result.name)
    }
}

9. 전체 비교표

Dispatcher 스레드 수 용도 금지 사항
Main 1 (UI 스레드) UI 업데이트, LiveData 발행 블로킹 작업
Main.immediate 1 (UI 스레드) viewModelScope 기본값 블로킹 작업
IO 최대 64개 네트워크, DB, 파일 CPU 집중 작업
Default CPU 코어 수 JSON 파싱, 이미지 처리, 정렬 블로킹 IO
Unconfined 고정 없음 테스트 전용 프로덕션 UI 작업

10. 실전 적용 — 전체 패턴

// Repository — IO 강제
class SearchRepositoryImpl @Inject constructor(
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
    @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
    private val searchApi: SearchApi
) : SearchRepository {

    override suspend fun search(query: String): List<SearchResult> {
        // 네트워크 요청 — IO
        val rawResponse = withContext(ioDispatcher) {
            searchApi.search(query)
        }
        // JSON 파싱 + 정렬 — Default
        return withContext(defaultDispatcher) {
            rawResponse.items
                .map { it.toDomain() }
                .sortedByDescending { it.score }
        }
    }
}

// ViewModel — Dispatcher 신경 안 써도 됨
@HiltViewModel
class SearchViewModel @Inject constructor(
    private val searchRepository: SearchRepository
) : ViewModel() {

    fun search(query: String) {
        viewModelScope.launch {   // Main.immediate
            _uiState.update { it.copy(isLoading = true) }
            val results = searchRepository.search(query)   // 내부에서 IO+Default 처리
            _uiState.update { it.copy(isLoading = false, results = results) }
        }
    }
}

11. 주의사항

❌ Main 스레드에서 블로킹 작업

// ❌ Main 스레드에서 네트워크 → NetworkOnMainThreadException
viewModelScope.launch {   // Main
    val user = userApi.getUser(1L)   // 블로킹 → 크래시
}

// ✅ IO 스레드로 전환
viewModelScope.launch {
    val user = withContext(Dispatchers.IO) { userApi.getUser(1L) }
}

❌ CPU 작업에 IO Dispatcher 사용

// ❌ IO Dispatcher로 CPU 집중 작업 → 스레드 낭비
withContext(Dispatchers.IO) {
    repeat(1_000_000) { /* 계산 */ }   // IO 스레드 점유
}

// ✅ Default 사용
withContext(Dispatchers.Default) {
    repeat(1_000_000) { /* 계산 */ }
}

❌ Dispatcher 없이 Room suspend 함수 호출

// ✅ Room suspend 함수는 자체적으로 IO 처리 — withContext 불필요
@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :id")
    suspend fun getUser(id: Long): UserEntity?   // Room이 IO 보장
}

// ❌ 중복 withContext — 불필요하지만 동작에 문제는 없음
suspend fun getUser(id: Long) = withContext(Dispatchers.IO) {
    userDao.getUser(id)   // 이미 IO에서 실행됨
}

12. 정리

Dispatcher 한 줄 요약
Main UI 스레드 — 뷰 접근은 여기서만
Main.immediate Main과 같지만 이미 메인이면 즉시 실행
IO 대기가 많은 작업 — 최대 64개 스레드
Default CPU 연산 — 코어 수만큼 스레드
Unconfined 테스트 전용 — 프로덕션 금지
limitedParallelism IO 스레드 수 제한 — Rate Limit 대응

참고



Related Posts