Blog/Kotlin

코루틴(Coroutine) vs 쓰레드(Thread)

코루틴과 쓰레드의 차이를 공부해 봅시다

5분 읽기
KotlinCS

코루틴(Coroutine) vs 쓰레드(Thread)


1. 기존 방식: 쓰레드(Thread)와 그 한계

1) 쓰레드란?

우리가 작성한 코드가 실제로 실행되는 흐름의 단위입니다. JVM에서 main 함수를 실행하면 메인 스레드가 하나 생성되고, 이 스레드가 우리가 작성한 코드를 순차적으로 처리합니다.

문제는 **단일 스레드(Single Thread)**만 사용하는 경우입니다.

  • DB 조회, 네트워크 요청 같이 오래 걸리는 작업이 하나 실행되고 있으면
  • 그 작업이 끝날 때까지 다음 작업은 전혀 실행되지 못합니다.

즉, 스레드는 한 시점에 한 가지 작업만 처리할 수 있고, 그 작업이 오래 걸리면 전체 프로그램이 같이 느려지는 구조입니다.


2) 멀티 스레드와 스레드 풀 (Executor Framework)

이 문제를 완화하기 위해 등장한 것이 **멀티 스레드(Multi Thread)**입니다.

  • 여러 개의 스레드를 만들어서
  • 각 스레드가 서로 다른 작업을 동시에 처리하도록 하는 방식입니다.

하지만 new Thread { ... }로 스레드를 직접 생성하는 방식에는 큰 문제가 있습니다.

  • 스레드 하나를 만드는 데도 메모리·생성 비용이 크고
  • 생성·종료·예외 처리까지 수명 주기를 직접 관리해야 하며
  • 스레드 수가 많아질수록 컨텍스트 스위칭 비용도 커집니다.

그래서 등장한 것이 스레드 풀(Thread Pool) + Executor 프레임워크입니다.

  • 미리 일정 개수의 스레드를 만들어 두고(풀)
  • 작업이 들어올 때마다 노는 스레드가 그 작업을 가져가 처리합니다.
  • 스레드를 재사용하기 때문에, 매번 새로 만들고 버리는 것보다 훨씬 효율적입니다.

3) 여전히 남는 문제: Blocking(블로킹)

스레드 풀을 써도 해결되지 않는 치명적인 문제가 하나 있습니다. 바로 **스레드 블로킹(Thread Blocking)**입니다.

스레드 블로킹: 스레드가 어떤 작업의 결과를 기다리느라 그동안 아무 일도 하지 못하고 멈춰 있는 상태

대표적인 상황은 이렇습니다.

  • 작업 1을 수행하다가 작업 2의 결과가 필요해짐
  • 작업 2를 다른 스레드에서 실행시키고, 작업 1은 결과를 기다림
  • 이때 작업 1을 수행하던 스레드는 결과가 올 때까지 멍하니 기다립니다.

문제는, 스레드는 굉장히 비싼 자원이라는 점입니다.

  • 스레드가 블로킹되어 있는 동안에도
    • 스레드 스택 메모리, 문맥 정보, OS 리소스는 그대로 잡혀 있고
    • 다른 작업을 위해 재활용될 수도 없습니다.

그래서:

  • 스레드가 많이 필요해지는 시점(동시 요청/동시 작업 증가)에는
  • 스레드 풀의 스레드가 모두 블로킹 상태에 빠져
  • “스레드는 잔뜩 떠 있는데, 할 수 있는 일은 없는” 황당한 상황이 발생하기도 합니다.

이 지점에서, “스레드 자체를 더 잘 쓰는 게 아니라, 아예 다른 단위를 쓸 수 없을까?” 하는 고민이 나오고, 그 해답 중 하나가 바로 **코루틴(Coroutine)**입니다.


2. 코루틴(Coroutine)이란?

코루틴은 이런 스레드 기반 모델의 한계를 보완하기 위해 등장한 개념입니다.

1) 경량 스레드 (Light-weight Thread)

코루틴은 흔히 **“경량 스레드(Light-weight Thread)”**라고 불립니다.

핵심 특징은 딱 하나로 정리할 수 있습니다.

코루틴은 실행 도중 “기다려야 할 순간”이 오면, 쓰레드의 사용 권한을 잠시 양보할 수 있다.

즉, 코루틴은 스레드에 고정된 존재가 아니라, 필요할 때 스레드에 붙었다가, 필요 없을 때는 스레드를 반납하고 잠시 중단될 수 있는 실행 단위입니다.


2) Blocking vs Suspension (블로킹 vs 중단)

쓰레드와 코루틴의 가장 중요한 차이는 바로 **“기다리는 방식”**입니다.

  • 쓰레드(Blocking)
    • I/O 작업(DB, 네트워크 등)을 호출하면
    • 그 작업이 끝날 때까지 스레드가 점유된 채 그대로 멈춰 있습니다.
    • 다른 코드를 실행할 수 없습니다.
  • 코루틴(Suspension)
    • 기다려야 할 일이 생기면
    • **스레드 사용 권한을 반납(yield)**하고, 자신은 중단(Suspend) 상태로 들어갑니다.
    • 이때 그 스레드는 즉시 다른 코루틴을 실행하는 데 사용될 수 있습니다.
    • 기다리던 작업이 끝나면, 코루틴은 다시 스케줄링되어 스레드를 배정받고 중단했던 지점부터 이어서 실행됩니다.

정리하면:

하나의 스레드 위에서 여러 코루틴이 번갈아 가며 실행되기 때문에, 매우 적은 수의 스레드로도 많은 동시 작업을 처리할 수 있다.


3. 코드로 보는 차이: Blocking vs Suspension

1) 기본 동작 비교 – “기다리는 방식이 다르다”

❌ [Thread] 방식 – Thread.sleep

스레드가 sleep을 호출하는 순간, 해당 스레드는 자원을 점유한 채 1초 동안 아무 것도 하지 않습니다.

text
import kotlin.concurrent.thread

fun main() {
    println("작업 시작")

    // 스레드 생성
    thread {
        println("[${Thread.currentThread().name}] 스레드: 1초간 잠듭니다...")
        // 1. Blocking: 스레드가 1초 동안 멈춥니다. 다른 일을 할 수 없습니다.
        Thread.sleep(1000L)
        println("[${Thread.currentThread().name}] 스레드: 기상!")
    }

    println("메인 종료")
}

✅ [Coroutine] 방식 – delay

코루틴이 delay를 만나면, 스레드 사용 권한을 잠시 내려놓고(Suspend) 비켜줍니다. 그 사이 같은 스레드에서는 다른 코루틴이 실행될 수 있습니다.

text
import kotlinx.coroutines.*

fun main() = runBlocking {
    println("작업 시작")

    // 코루틴 생성 (launch)
    launch {
        println("[${Thread.currentThread().name}] 코루틴: 1초간 양보합니다...")
        // 1. Suspension: 잠시 중단하고 스레드 권한을 양보합니다 (Non-blocking)
        delay(1000L)
        println("[${Thread.currentThread().name}] 코루틴: 다시 돌아왔습니다!")
    }

    println("메인 종료")
}

2) 성능/효율 비교 – “10만 개를 동시에 띄워본다면?”

코루틴이 왜 **“경량 스레드”**인지 가장 직관적으로 보여주는 예입니다.

❌ [Thread] – 10만 개 생성 도전

스레드는 생성 비용도 크고, 스택 메모리도 많이 차지합니다. 아래처럼 10만 개를 만들어 보면, 대부분의 환경에서:

  • 엄청 느려지거나
  • OutOfMemoryError가 발생하며 프로그램이 뻗어버릴 수 있습니다.
text
import kotlin.concurrent.thread

fun main() {
    // 10만 개의 스레드를 생성 시도 -> OOM 또는 심각한 성능 저하 가능
    repeat(100_000) {
        thread {
            Thread.sleep(1000L)
            print(".")
        }
    }
}

✅ [Coroutine] – 10만 개 생성 도전

코루틴은 객체 하나를 생성하는 수준으로 가볍습니다. 10만 개를 만들어도 충분히 감당 가능합니다.

text
import kotlinx.coroutines.*

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()

    // 10만 개의 코루틴 생성 -> 가볍게 소화 가능
    repeat(100_000) {
        launch {
            delay(1000L)
            print(".")
        }
    }

    val endTime = System.currentTimeMillis()
    println("\n소요 시간: ${endTime - startTime}ms")
}

실제로 돌려보면, 10만 개의 코루틴이 1초 delay를 거치고도 1~2초 수준 안에서 전체가 끝나는 걸 볼 수 있습니다. (쓰레드로는 상상하기 어려운 규모죠.)


4. 그래서, 언제 코루틴을 써야 할까?

정리하면, 코루틴이 특히 강력한 영역은 “기다림이 많은 작업”, 즉 I/O 바운드 작업입니다.

작업 종류쓰레드 기반코루틴 기반
I/O 작업 (네트워크, DB 등)스레드 블로킹 → 비효율스레드 재사용 → 유리
CPU 작업 (계산, 인코딩 등)코어 수에 따라 비슷대체로 비슷

1) I/O 바운드 작업

  • 네트워크, DB, 파일 IO처럼 “대기 시간”이 긴 작업
  • 쓰레드 기반:
    • 응답을 기다리는 동안 스레드가 그냥 놀고 있음
  • 코루틴 기반:
    • 기다리는 동안 스레드 사용 권한을 반납하고
    • 같은 스레드로 다른 코루틴 작업을 계속 처리할 수 있음
  • 결과:
    • 같은 스레드 수로도 훨씬 많은 동시작업을 소화 가능

2) CPU 바운드 작업

  • 이미지/영상 인코딩, 대용량 데이터 변환처럼 CPU를 꽉 채우는 작업은
    • 코루틴이든, 쓰레드든
    • 어차피 CPU 코어 수에 의해 성능이 결정되기 때문에
  • “코루틴이라서 무조건 더 빠르다”라고 보긴 어렵고, 주로 코드 구조·취소/에러 처리·구조적 동시성 측면에서 장점이 있습니다.