코루틴(Coroutine)이란?

2025. 5. 15. 23:32Language/kotlin

코루틴은 비동기 프로그래밍을 위한 (1) 경량 스레드 개념으로 일반적인 함수와는 달리 (2)중간에 실행을 멈췄다가 다시 시작할 수 있는 함수를 의미합니다. 

 

chat gpt를 통해 '코루틴에 대해 설명해 줘'라는 질문을 작성하면 얻을 수 있는 답입니다. 저는 해당 답변에서 두 가지에 대한 추가 질문을 했습니다. 

  1. 경량 스레드란?
  2. 중간에 실행을 멈췄다가 다시 시작할 수 있는 함수의 동작 방식은?

먼저 경량 스레드는 (1)운영체제 수준의 스레드가 아니라, 애플리케이션 또는 (2) 런타임 수준에서 실행되는 단위를 의미합니다. 

 

간단한 코드를 통해서 일반적인 멀티 스레드 방식과 경량 스레드 방식(코루틴 사용)의 차이를 보겠습니다. 

먼저 일반적인 멀티 스레드 방식에서는 아래 코드를 실행하면 

  • Thread {... }. start()로 실제 OS 스레드를 생성하고
  • Thread.sleep(1000) 동안 해당 스레드는 블로킹(blocking) 되며
  • 쓰레드 10개 생성 → OS 스케줄러가 각각을 관리합니다.

 

fun main() {
    repeat(10) {
        Thread {
            Thread.sleep(1000)
            println("Thread $it finished on ${Thread.currentThread().name}")
        }.start()
    }
}

 

일반적인 멀티 스레드 방식은 os 스케줄러에 의해 스레드가 일정한 CPU 사용시간을 할당받고 사용시간이 끝나면 다른 스레드가 CPU를 재할당 받게 됩니다. 이 과정에서 스레드 간 '콘텍스트 스위칭(Context Switching)'이 발생하는데 다들 잘 아시다시피 콘텍스트 스위칭의 수를 줄일수록 시간과 비용을 절약할 수 있습니다. 

 

반대로 코루틴을 사용하는 경량 스레드 방식은 다음과 같은 프로세스를 거칩니다. 

 

  • launch { ... }는 코루틴을 생성하고 (OS 스레드가 아님).
  • delay(1000L)는 non-blocking 중단점 → 다른 코루틴이 실행될 수 있으며,
  • 내부적으로는 몇 개의 스레드 풀에서 수많은 코루틴을 스케줄링합니다.

 

import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(10) {
        launch {
            delay(1000L)
            println("Coroutine $it finished on ${Thread.currentThread().name}")
        }
    }
}

 

 

코루틴은 경량 스레드 개념이라고 했으며, 런타임 수준에서 실행되는 단위라고 했습니다. 런타임 수준에서 실행되는 단위라는 것은 1개의 스레드에 수많은 코루틴이 할당되어 (논리적 작업단위를 의미함) 실행된다는 것을 의미합니다. 

 

전통적인 멀티 스레드 방식은 OS가 작업단위를 스레드로 나누고 각 스레드는 독립된 콜스택과 리소스를 가지며 콘텍스트 스위칭이 발생해서 비용이 상대적으로 큽니다. 예를 들어 100개의 동시작업을 처리하려면 100개의 스레드가 필요하며 100개의 스레드는 OS 스케줄러에 의해 스케줄링되어 CPU를 일정시간 동안 점유해서 작업을 수행합니다. 

 

반면에 코루틴 방식은 실제 스레드를 점유하지않고 논리적 순서에 따라 실행됩니다. 그게 가능한 이유는 코루틴은 수행중 중단이 가능하며 그 상태를 보존하고 다시 스케줄링되어 이전에 중단됐던 지점부터 다시 작업을 수행할 수 있는 메커니즘으로 구현되었기 때문입니다. 

 

즉, 코루틴은 "동시에 실행"되는 것이 아니라, "중단과 재개"를 통해 분산되는 메커니즘으로 콘텍스트 스위칭의 비용을 최소화할 수 있는 비동기 프로그램 패러다임입니다. 


Kotlin에서 코루틴의 동작방식 이해하기

Kotiln에서 코루틴의 동작방식을 이해하기 위해  이전 예시 다시 가져왔습니다.  

import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(10) {
        launch {
            delay(1000L)
            println("Coroutine $it finished on ${Thread.currentThread().name}")
        }
    }
}

 

launch는 코루틴을 생성해서 실행하게 해주는 대표적인 코루틴 빌더 중 하나입니다. launch를 호출하게 되면 즉시 코루틴이 생성되고 (1) CoroutineScope 내에서 비동기적으로 실행됩니다. 내부적으로는 새로운 (2) CoroutineContext를 구성하고, (3) CoroutineDispatcher를 통해 적절한 스레드에서 실행되도록 스케줄링합니다.

 

CoroutineScope는 코루틴을 실행할 수 있는 Context를 제공합니다. CoroutineScope는 

  • Job(코루틴 생명주기 관리 - 취소, 완료 등)
  • 디스패처(CoroutineDispatcher)
  • ExceptionHandler

를 포함하고 있습니다. 코루틴을 시작하려면 반드시 CoroutineScope가 필요합니다. 

val scope = CoroutineScope(Dispatchers.Default)

scope.launch {
    println("코루틴 실행 중!")
}

 

위의 코드처럼 scope 안에서 코루틴이 실행되며 scope가 종료되면 scope 안에 모든 코루틴이 함께 취소됩니다. 

 

CoroutineScope는 내부적으로 CoroutineContext를 갖고 있습니다. CoroutineContext는 코루틴 실행에 필요한 환경 정보를 담는 컨테이너 역할을 하며 Job, CoroutineDispatcher, ExceptionHandler로 구성되어 있습니다. 

 

CoroutineDispatcher는 코루틴을 실제로 어떤 스레드에서 실행할지를 결정합니다. 일반적으로 CPU 작업용 스레드는 위의 예시에서 본 것처럼 Dispatcer.Default를 사용합니다. 

 

위의 코드에서 launch를 호출하게 되면 내부에서는 

  1. CoroutineScope에 디스패처(Dispatcher.Default)를 할당해서 넘깁니다. 
  2. launch 메서드를 호출하게 되면 그 즉시 새로운 코루틴이 생성되며
  3. 디스패처에 의해 해당 코루틴은 적절한 스레드에 할당되어 스케줄링을 위해 대기합니다. 
  4. 자기 차례가 되면 코루틴은 실행됩니다. 
  5. 이때 suspend 함수에 의해서 중단되면 디스패처는 해당 스레드를 반환하고 나중에 재개할 때 다시 적절한 스레드에 다시 코루틴을 할당해서 실행합니다. 

suspend 함수는 코루틴 안에서만 호출할 수 있는 특수한 함수입니다. 이때 suspend함수는 반드시 "중단 지점"을 포함하거나 다른 suspend함수를 호출해야만 합니다. 

 

suspend가 호출되는 시점은 launch 또는 async 등의 코루틴 빌더 안에서 호출됩니다. suspend는 단독으로 호출할 수 없고 코루틴 블록 내부에서만 실행가능합니다. 

launch {
    println("작업 시작")  
    delay(1000) // 여기서 중단됨
    println("작업 끝")   // 이후에 재개됨
}

 

코루틴은 delay(), withContext(), yield(), await(), channel.receive() 같은 함수들에 의해서 중단됩니다. 이 함수들은 내부에서 코루틴을 일시 중지(suspend)한 뒤 나중에 재개(resume)할 수 있도록 상태를 저장합니다. 이게 코루틴의 핵심 메커니즘입니다. 

 

코루틴은 Continuation 객체를 사용해서 자기 상태를 기억합니다. Continuation은 코루틴을 일시 중지하고 나중에 다시 시작할 수 있도록 관리하는 객체입니다. 코루틴이 중단될 때마다 해당 코루틴은 현재 상태(ex. 변숫값, 호출 스택 등)를 저장하고 그 후 해당 객체를 사용해서 코루틴을 재개할 수 있습니다. 

 

이번에는 디스패처에 대해서 조금 더 살펴보겠습니다. 

디스패처는 코루틴이 실행될 스레드나 스레드 풀을 선택하는 역할을 합니다. CoroutineDispatcher는 코루틴이 실행되는 실제 스레드를 관리하며 Dispatchers.IO, Dispatchers.Main, Dispatchers.Default 같은 디스패처가 사용됩니다. 

 

  • Dispatchers.Main: UI 스레드에서 실행
  • Dispatchers.IO: IO 작업(파일 입출력, 네트워크 등) 전용 스레드 풀
  • Dispatchers.Default: CPU 중심 작업을 위한 기본 스레드 풀

코루틴은 디스패처에 의해 지정된 스레드에서 큐에 등록되고 해당 스레드가 자유로워지면 실행됩니다. 이때 디스패처는 내부적으로 작업 큐를 관리하며 코루틴의 상태에 맞게 큐에 작업을 할당합니다. 

  1. 코루틴이 실행되면 Continuation객체가 생성되고, 디스패처는 이를 큐에 넣고 대기시킵니다. 
  2. 디스패처는 작업을 큐에서 꺼내어 실행합니다. 이때 큐에 작업이 없다면 스레드가 대기 상태로 돌아갑니다.
  3. 코루틴이 중단되면 Continuation은 디스패처에 의해 다시 스케줄링되어 재개됩니다. 

디스패처는 CoroutineContext 내에서 공유되므로 디스패처가 변하지 않으면 해당 코루틴은 같은 디스패처에서 실행되기 때문에 일관된 실행환경을 유지할 수 있습니다. 


정리

코루틴은 전통적인 멀티 스레드 방식에 비해 콘텍스트 스위칭의 수를 현저히 낮춰 비용을 줄일 수 있는 경량 스레드 방식입니다. 

코루틴은 '중단'과 '재개'라는 메커니즘을 통해 수천개의 코루틴도 하나의 스레드에서 실행 가능할 정도로 자원을 적게 사용할 수 있습니다.