본문 바로가기

[Coroutine] 1. 스레드와 코루틴

@Marchbreeze2025. 5. 19. 05:10
<코틀린 코루틴의 정석(조세영)> 도서의 "1장. 스레드 기반 작업의 한계와 코루틴의 등장"을 탐고해서 작성된 글입니다.

 


요약

스레드는 프로세스 내에서 독립적인 실행 흐름 단위입니다.

코루틴은 경량화된 비동기 실행 단위로, 스레드 블로킹 없이 일시 중단과 재개가 가능합니다.

 

차이점은 다음과 같습니다.

  1. 생성 비용 : 스레드는 무거워 생성 시 수백 ms가 소요되지만, 코루틴은 수십 μs 내외로 매우 가볍습니다.
  2. 블로킹 : 스레드는 I/O 대기 시 블로킹되지만, 코루틴은 일시 중단만 하여 스레드를 반환합니다.
  3. 관리 편의성 : 코루틴은 구조화된 동시성으로 생명주기와 예외를 쉽게 관리할 수 있습니다.

Thread 클래스 혹은 Executor 프레임워크로 멀티 스레드 프로그래밍을 구현할 수 있지만, 스레드 생성 비용 또는 스레드 블로킹 문제를 해결해야 합니다.


 

1. 단일 스레드 프로그래밍

(1) JVM 프로세스

JVM(Java Virtual Machine) 프로세스는 기본적으로 하나의 스레드에서 main() 함수를 실행합니다.

  1. main() 함수가 실행되면
  2. JVM이 프로세스를 시작하고
  3. 메인 스레드를 생성한 후
  4. main() 내부의 코드를 실행합니다.

이후 모든 사용자 스레드가 종료되면 JVM도 종료됩니다.

메인 스레드는 그중 하나이지만, 메인 스레드가 예외로 강제 종료되면 프로세스도 함께 종료됩니다.

 

 

(2) 사용자 스레드

사용자 스레드 (User Thread)

  • 기본적으로 애플리케이션이 실행될 때 생성되는 일반적인 스레드입니다.
  • 메인 프로그램이 종료되더라도, 자신이 맡은 작업이 끝날 때까지 실행을 유지합니다.
  • 우선도가 높은 스레드여서, 사용자 스레드가 하나라도 실행 중이라면 JVM은 종료되지 않습니다.

데몬 스레드 (Daemon Thread)

  • 백그라운드에서 실행되는 스레드로, 주로 사용자 스레드가 필요로 하는 서비스를 제공하기 위해 존재합니다.
  • 우선도가 낮은 스레드여서, 모든 사용자 스레드가 종료되면 자동으로 종료됩니다.
  • ex. 가비지 컬렉터, JVM 메모리 관리, 백그라운드 작업

 

 

(3) 단일 스레드의 한계

I/O나 네트워크 요청처럼 블로킹이 발생하면, 다른 작업을 동시에 수행할 수 없어 전체 애플리케이션이 멈춥니다.

ex, 메인 스레드에서 긴 네트워크 요청을 처리하는 도중 사용자 입력이 먹통이 되는 현상이 발생합니다.

 

 


2. 멀티 스레드 프로그래밍

여러 작업을 병렬로 처리하기 위해, JVM은 추가 스레드를 생성할 수 있습니다.

 

(1) Thread 클래스

Thread 클래스의 run 함수를 오버라이드해서 스레드를 생성할 수 있습니다.

class ExampleThread : Thread() {
  override fun run() {
    println("[${Thread.currentThread().name}] 새로운 스레드 시작")
    Thread.sleep(2000L) // 2초 대기
    println("[${Thread.currentThread().name}] 새로운 스레드 종료")
  }
}

fun main() {
  println("[${Thread.currentThread().name}] 메인 스레드 시작")
  ExampleThread().start()
  Thread.sleep(1000L) // 1초 대기
  println("[${Thread.currentThread().name}] 메인 스레드 종료")
}

/*
[main] 메인 스레드 시작
[Thread-0] 새로운 스레드 시작
[Thread-0] 새로운 스레드 종료
[main] 메인 스레드 종료
*/
  • 두 스레드(메인 + ExampleThread)가 병렬로 동작합니다.
  • 두 스레드가 모두 사용자 스레드이므로, ExampleThread가 종료될 때까지 JVM은 살아 있습니다.

 

Thread 클래스를 새로 생성하지 않아도, 제공되는 함수를 사용할 수 있습니다.

fun main() {
  thread(isDaemon = false) {
    Thread.sleep(2000L)
  }
  Thread.sleep(1000L) 
}

 

 

(2) 스레드 직접 생성의 한계

1. 스레드 생성 비용이 큽니다.

Thread 클래스를 상속한 클래스를 인스턴스화해 실행할 때마다 매번 새로운 스레드가 생성되므로, 성능적으로 좋지 않습니다.

 

2. 오류 위험이 존재합니다.

스레드 생성과 관리에 대한 책임이 개발자에게 있고 직접 관리하기 번거로워, 복잡도가 커질수록 오류와 메모리 누수 위험이 높아집니다.

 

 

(3) Executor Service

Executor 프레임워크

스레드풀에 속한 스레드의 생성과 관리 및 작업 분배에 대한 책임을 Executor 프레임워크가 담당합니다.

 

스레드풀 (Thread Pool)

스레드의 집합으로, Thread 클래스로 직접 스레드를 생성하지 않고도 작업을 스레드 풀에서 실행할 수 있습니다.

스레드 풀을 활용해 재사용 가능한 스레드를 관리하면, 직접 생성·소멸시키는 오버헤드를 줄일 수 있습니다.

 

fun main() {
  // 1. 스레드풀을 관리하는 객체 반환 & 스레드 최대 개수 설정
  val executorService: ExecutorService = Executors.newFixedThreadPool(2)

  // 2. 3개의 작업(1,2,3) 스레드풀에 제출
  repeat(3) { index ->
    executorService.submit {
      println("[${Thread.currentThread().name}] 작업${index + 1} 시작")
      Thread.sleep(1000L)
      println("[${Thread.currentThread().name}] 작업${index + 1} 완료")
    }
  }

  // 3. 서비스 종료
  executorService.shutdown()
}

/*
[pool-1-thread-1][지난 시간: 4ms] 작업1 시작
[pool-1-thread-2][지난 시간: 4ms] 작업2 시작
[pool-1-thread-1][지난 시간: 1009ms] 작업1 완료
[pool-1-thread-2][지난 시간: 1011ms] 작업2 완료
[pool-1-thread-1][지난 시간: 1012ms] 작업3 시작
[pool-1-thread-1][지난 시간: 2016ms] 작업3 완료
*/
  • Fixed Thread Pool(2개) : 동시에 최대 2개의 스레드가 병렬 실행되고, 나머지 작업은 대기열에 쌓입니다.
  • 작업 완료 시, 다음 대기 중인 작업이 즉시 실행되어 효율적인 처리 흐름을 만듭니다.
  • ExecutorService 객체의 구성 : 스레드풀 2개 & 작업 대기열 (할당받은 작업을 적재하여 대기시킵니다.)

 

 

(4) 스레드 블로킹 문제

스레드 블로킹이란, 스레드가 아무것도 하지 못하고 사용될 수 없는 상태에 있는 상황입니다.

다음과 같은 상황에서 발생할 수 있습니다.

  • 여러 스레드가 동기화 블록에 동시에 접근하는 경우 (하나의 스레드만 동기화 블록에 접근이 허용)
  • 뮤텍스나 세마포어로 인해 공유되는 자원에 접근할 수 있는 스레드가 제한되는 경우
  • ExecutorService 객체에 제출한 작업에서 결과값이 반환될 때까지 블로킹되는 경우
fun main() {
  val executorService: ExecutorService = Executors.newFixedThreadPool(2)
  
  // 언젠가 올지 모르는 값을 기다리는데 Future 객체를 사용
  val future: Future<String> = executorService.submit<String> {
    Thread.sleep(2000)
    return@submit "작업 1완료"
  }

  val result = future.get() // 결과 반환 시점까지 메인 스레드가 블로킹 됨
  println(result)
  executorService.shutdown()
}
  • 블로킹된 스레드는 다른 작업에 활용되지 못해 자원 낭비로 이어질 수 있으며, 메인 스레드의 경우 ANR 문제가 발생합니다.

 

간단한 작업에서는 콜백을 사용하거나 체이닝 함수를 사용해서 스레드 블로킹을 피할 수 있습니다.

fun main() {
  val executor = Executors.newFixedThreadPool(2)

  // CompletableFuture 생성 및 비동기 작업 실행
  val completableFuture = CompletableFuture.supplyAsync({
    Thread.sleep(1000L)
    return@supplyAsync "작업 1 완료"
  }, executor)

  // 비동기 작업 완료 후 결과 처리를 위한 체이닝 함수 등록
  completableFuture.thenAccept { result ->
    println("$result 처리") // 결과 처리 출력
  }

  // 비동기 작업 실행 도중 다른 작업 실행
  println("다른 작업 실행")

  executor.shutdown()
}
  • 그러나 작업이 복잡해지는 경우, 여러 문제점이 발생할 수 있습니다.

 

 


3. 코루틴

코루틴

코루틴은 경량 스레드로 스레드보다 훨씬 가볍고, 일시 중단(suspend) 후 재개가 가능한 비동기 작업 단위입니다.

작업 단위 코루틴을 통해 스레드 블로킹 문제를 해결할 수 있습니다.

 

작업 단위 코루틴

스레드에서 작업 실행 도중 일시 중단할 수 있는 작업 단위입니다.

작업 일시 중단 시, 더 이상 스레드 사용이 필요하지 않으므로 스레드의 사용 권한을 양보합니다.

일시 중단된 코루틴을 다시 재개하는 시점에, 다시 스레드에 할당돼 실행됩니다.

 

코루틴의 생성 및 실행

fun main() = runBlocking<Unit> { // this: CoroutineScope
  println("[${Thread.currentThread().name}] 실행")
  launch {
    println("[${Thread.currentThread().name}] 실행")
  }
  launch {
    println("[${Thread.currentThread().name}] 실행")
  }
}
/*
// 실행 결과:
[main @coroutine#1] 실행
[main @coroutine#2] 실행
[main @coroutine#3] 실행
*/
  • 코루틴은 스레드에 비해 생성과 전환 비용이 적게 들고 스레드에 자유롭게 뗐다 붙였다 할 수 있어, 작업을 생성하고 전환하는 데 필요한 리소스와 시간이 매우 줄어듭니다.
  • 1000개의 스레드 생성 및 실행 -> 약 500~2000ms
  • 1000개의 코루틴 생성 및 실행 -> 약 5~50ms

 

코루틴의 이름

fun main() = runBlocking(context = CoroutineName("Main")) {
  launch(context = CoroutineName("Worker1")) {
    println("[${Thread.currentThread().name}] Worker1 실행")
  }
  launch(context = CoroutineName("Worker2")) {
    println("[${Thread.currentThread().name}] Worker2 실행")
  }
}
/*
[main @Main#1] 실행
[main @Coroutine1#2] 실행
[main @Coroutine2#3] 실행
*/
  • 로그에 코루틴 이름을 붙이면 디버깅과 추적이 쉬워집니다.

 

이처럼 스레드와 코루틴의 차이를 이해하고, 적절한 경우에 코루틴을 선택하면 앱의 응답성과 안정성을 크게 개선할 수 있습니다.

Marchbreeze
Marchbreeze

안드로이드 개발자, 김상호입니다.

https://github.com/Marchbreeze

목차