Java 멀티스레드와 Kotlin 코루틴의 핵심 차이는 스케줄링 주체입니다. OS가 강제로 전환하느냐, 코드가 자발적으로 양보하느냐.

OS 레벨 병렬처리

OS 레벨
├── 멀티 프로세스 (독립 메모리)
└── 멀티 스레드 (공유 메모리, OS 스케줄링)

언어 레벨
└── 코루틴 (공유 메모리, 언어 스케줄링)

핵심 차이: 누가 스케줄링하느냐

Java 멀티스레드

  • OS가 CPU 클럭 단위로 강제 스위칭 → 선점형(Preemptive)
  • 스레드 전환 비용이 큼 (스택, 레지스터 저장/복원)
  • 스레드 1개 = OS 스레드 1개 (~1MB)

Kotlin 코루틴

  • suspend 포인트에서 자발적으로 스레드 반납 → 협력형(Cooperative)
  • 전환 비용이 매우 작음 (힙에 상태 저장, ~수KB)
  • 스레드 몇 개 위에서 코루틴 수천 개 동작 가능

계층 구조

코루틴은 스레드를 대체하는 게 아니라 스레드 위에서 동작합니다.

코루틴 (언어가 스케줄링)
    ↓
스레드 (OS가 스케줄링)
    ↓
CPU

코루틴 동작 방식

suspend는 “여기서 중단할 수 있다"는 표시입니다. 실제로 스레드를 반납하려면 내부 I/O가 non-blocking이어야 합니다.

non-blocking I/O인 경우

suspend fun process() {
    val result = r2dbcClient.query()  // non-blocking I/O → 스레드 반납
    // 완료되면 다시 이어서 실행
}
  • I/O 대기 중 스레드를 반납하면 다른 코루틴이 그 스레드를 사용합니다
  • OS 입장에서는 스레드가 쉬는 시간 없이 계속 일하는 것처럼 보입니다

blocking I/O인 경우

suspend fun process() {
    val result = jdbcTemplate.query()  // blocking I/O → 스레드 블록킹
}
  • suspend로 감싸도 내부가 blocking이면 스레드를 반납하지 못합니다
  • 이 경우 Dispatchers.IO에서 실행하여 블록킹 전용 스레드풀을 사용합니다
suspend fun process() {
    val result = withContext(Dispatchers.IO) {
        jdbcTemplate.query()  // blocking I/O를 IO 스레드풀에서 실행
    }
}

Dispatchers.IO는 스레드를 반납하는 게 아니라, 블록킹 전용 스레드풀을 크게 잡아서 처리하는 방식입니다. 코루틴의 경량 스레드 장점을 살리려면 non-blocking I/O(R2DBC, Ktor client 등)를 사용해야 합니다.

스레드풀 설정 기준

작업 유형 기준 이유
CPU bound 코어 수 코어 수 이상 늘려도 동시 실행 불가
I/O bound Dispatchers.IO 대기 중 CPU를 안 쓰므로 코어 수보다 많이 허용

Dispatchers.IO 기본값은 max(64, CPU 코어 수)입니다.

스레드풀을 과도하게 늘리면:

  • 컨텍스트 스위칭 비용 증가
  • 메모리 낭비
  • 코루틴의 장점이 사라짐

다른 언어의 비슷한 개념

언어 개념
Python asyncio
Go goroutine
Java 21 Virtual Thread

모두 “적은 OS 스레드로 높은 동시성"이 목표입니다.