한 줄 정의

Kotlin 컴파일러가 suspend 함수를 CPS(Continuation-Passing Style)로 변환하여 구현하는 경량 동시성 메커니즘. 스레드를 블로킹하지 않고 실행을 중단/재개할 수 있다.

쉽게 말하면

일반 함수는 “전화 통화”와 같습니다. 한번 시작하면 통화가 끝날 때까지 전화기(스레드)를 계속 붙잡고 있어야 하죠.

반면 Coroutine의 suspend 함수는 “카카오톡 대화”에 가깝습니다. 메시지를 보내놓고(I/O 요청) 답이 올 때까지 폰(스레드)을 내려놓은 채 다른 일을 하다가, 답이 도착하면 다시 대화를 이어가는 방식입니다. 폰은 하나뿐이지만 카톡방은 수만 개를 동시에 열어둘 수 있는 것처럼, 스레드 하나로도 수많은 코루틴을 동시에 처리할 수 있습니다.

왜 이걸 알아야 하는가?

  • Kotlin 서버 개발의 표준 동시성 모델: Spring WebFlux + Kotlin, Ktor 등 Kotlin 기반 서버에서 Coroutine은 사실상 기본이기 때문에, suspend 함수를 이해하지 못하면 코드를 읽는 것 자체가 어렵습니다.
  • Structured Concurrency로 리소스 누수 방지: 스레드를 spawn하고 잊어버리는 fire-and-forget 패턴은 리소스 누수의 대표적인 원인입니다. Coroutine의 CoroutineScope/Job 계층 구조를 이해하면 “부모가 취소되면 자식도 자동 취소”라는 보장을 활용하여 안전한 동시성 코드를 작성할 수 있습니다.
  • Dispatcher 선택이 성능을 결정: CPU-bound 작업을 Dispatchers.IO에서 돌리거나, 블로킹 JDBC를 Dispatchers.Default에서 호출하면 성능이 크게 떨어집니다. 따라서 각 Dispatcher의 스레드 풀 구조와 용도를 알아야 올바른 선택이 가능합니다.

왜 이렇게 설계했는가?

  • 해결하는 문제: 비동기 코드를 동기 코드처럼 읽기 쉽게 작성하고 싶다는 것이 핵심 동기이며, 콜백 지옥(Callback Hell)과 리액티브 체인의 가독성 문제를 해결합니다.
  • 이게 없다면:
    • 콜백 방식: 중첩이 깊어지고 에러 핸들링이 산발적이라 코드 흐름을 따라가기 어렵습니다.
    • 리액티브 (RxJava, Reactor): 체이닝 덕분에 콜백보다는 낫지만, .flatMap, .switchMap 등 연산자를 외워야 하고 스택 트레이스도 끊깁니다.
    • Virtual Thread: 블로킹 코드 스타일로 작성할 수 있지만, Structured Concurrency가 아직 미성숙(JEP 462 프리뷰)하고 Java에서만 사용 가능합니다.
  • Coroutine의 접근: 컴파일러가 코드를 변환하여 “동기처럼 보이지만 비동기로 동작”하게 만들고, 언어 레벨에서 Structured Concurrency를 강제함으로써 생명주기 관리까지 자동화합니다.

어떻게 동작하는가?

컴파일러 변환: CPS (Continuation-Passing Style)

suspend 키워드가 붙은 함수를 Kotlin 컴파일러가 **상태 머신(state machine)**으로 변환하는 것이 Coroutine의 핵심입니다.

변환 전 (개발자가 작성하는 코드):

suspend fun fetchUserData(userId: String): UserData {
    val user = userApi.getUser(userId)       // suspend point 1
    val orders = orderApi.getOrders(user.id)  // suspend point 2
    return UserData(user, orders)
}

변환 후 (컴파일러가 생성하는 바이트코드의 논리적 구조):

// 실제로는 바이트코드 레벨에서 생성됨. 개념적 표현.
fun fetchUserData(userId: String, continuation: Continuation<UserData>): Any? {
    val sm = continuation as? FetchUserDataSM ?: FetchUserDataSM(continuation)
 
    when (sm.label) {
        0 -> {
            sm.label = 1
            val result = userApi.getUser(userId, sm)  // Continuation 전달
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
        }
        1 -> {
            sm.user = sm.result as User
            sm.label = 2
            val result = orderApi.getOrders(sm.user.id, sm)
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
        }
        2 -> {
            sm.orders = sm.result as List<Order>
            return UserData(sm.user, sm.orders)
        }
    }
}

핵심 포인트:

  • 각 suspend point가 when의 분기(label)가 되며, 함수 하나가 여러 번 호출될 수 있는 상태 머신으로 변환됩니다.
  • 중간 상태(지역변수 값)는 Continuation 객체의 필드에 저장되므로, Heap에 저장되어 스레드 스택이 필요 없습니다.
  • COROUTINE_SUSPENDED를 리턴하면 호출 스택이 풀리면서 스레드가 해방되고, 이후 I/O가 완료되면 Continuation의 resumeWith()가 호출되어 다음 label부터 이어서 실행됩니다.

CoroutineDispatcher — 어디서 실행할 것인가

Coroutine은 어떤 스레드에서 실행될지를 Dispatcher가 결정합니다.

Dispatcher스레드 풀용도사이즈
Dispatchers.Default공유 스레드 풀CPU-bound 연산코어 수 (최소 2)
Dispatchers.IO별도 스레드 풀블로킹 I/O기본 64, 필요시 확장
Dispatchers.Main메인 스레드Android UI 업데이트1
Dispatchers.Unconfined호출자 스레드테스트, 특수 용도없음

Dispatchers.Default vs IO의 분리가 중요한 이유: Default는 코어 수만큼의 스레드만 갖고 있어서, 여기서 블로킹 I/O를 수행하면 코어를 점유하여 CPU-bound 작업이 밀리게 됩니다. 반대로 IO Dispatcher에서 CPU-bound 작업을 돌리면 불필요하게 많은 스레드가 생성됩니다. 따라서 작업 특성에 맞는 Dispatcher를 선택하는 것이 중요합니다.

withContext로 Dispatcher 전환:

suspend fun processImage(url: String): Bitmap {
    val data = withContext(Dispatchers.IO) {       // I/O 스레드에서 다운로드
        httpClient.download(url)
    }
    return withContext(Dispatchers.Default) {       // CPU 스레드에서 디코딩
        decodeImage(data)
    }
}

Structured Concurrency — Coroutine의 핵심 차별점

Structured Concurrency는 “모든 Coroutine은 부모 CoroutineScope 안에서 실행되어야 한다”는 원칙으로, Coroutine을 단순한 경량 스레드가 아닌 구조화된 동시성 프레임워크로 만들어주는 핵심 차별점입니다.

원칙:

  1. 생명주기 바운딩: 자식 Coroutine은 부모 Scope가 끝나기 전에 반드시 완료되어야 하므로, “실행하고 잊어버리는(fire-and-forget)” 패턴이 원천적으로 불가능합니다.
  2. 취소 전파: 부모가 취소되면 모든 자식도 자동으로 취소되며, 자식 하나가 예외를 던지면 형제들도 함께 취소됩니다.
  3. 에러 전파: 자식의 예외가 부모로 전파되기 때문에, try-catch로 일관되게 처리할 수 있습니다.
// 구조화된 동시성 예시
suspend fun loadDashboard(): Dashboard = coroutineScope {
    val userDeferred = async { userApi.getUser() }       // 병렬 실행 1
    val ordersDeferred = async { orderApi.getOrders() }  // 병렬 실행 2
    val statsDeferred = async { statsApi.getStats() }    // 병렬 실행 3
 
    // 하나라도 실패하면 나머지도 자동 취소
    Dashboard(
        user = userDeferred.await(),
        orders = ordersDeferred.await(),
        stats = statsDeferred.await()
    )
}
// coroutineScope 블록이 끝나면 3개의 자식이 모두 완료된 것이 보장됨

Structured Concurrency가 없다면: 스레드 3개를 spawn한 뒤 하나가 실패했을 때 나머지 2개를 수동으로 interrupt/cancel해야 하는데, 이를 빼먹으면 곧바로 리소스 누수로 이어집니다. 실제로 Java의 ExecutorService에서 이런 실수가 매우 흔합니다.

취소 메커니즘 — CancellationException

Coroutine의 취소는 **협력적(cooperative)**입니다. cancel()을 호출하더라도 Coroutine이 즉시 멈추는 것이 아니라, 다음 suspend point에서 CancellationException이 발생하면서 비로소 중단됩니다.

isActive 체크가 필요한 경우: CPU-bound 루프에서는 suspend point가 없으므로 취소가 전달되지 않습니다. 이런 경우 명시적인 체크가 필요합니다.

suspend fun heavyComputation(data: List<Int>) = coroutineScope {
    for (item in data) {
        ensureActive()  // 취소 확인. 없으면 취소 불가능한 좀비 Coroutine이 됨
        process(item)
    }
}

이것은 Virtual Thread의 비선점 스케줄링과 같은 맥락입니다 — 둘 다 자발적 양보 지점이 있어야 동작합니다.

Job 계층 구조

모든 Coroutine은 Job 객체를 가지며, 이 Job들이 부모-자식 트리를 형성합니다.

graph TD
    Parent["Parent Job (CoroutineScope)"]
    Parent --> Child1["Child Job 1 (async)"]
    Parent --> Child2["Child Job 2 (async)"]
    Child2 --> GrandChild1["GrandChild Job (launch)"]

    Parent -->|"취소 시"| Child1
    Parent -->|"취소 시"| Child2
    Child2 -->|"취소 시"| GrandChild1
  • 부모 Job이 취소되면 모든 자식이 재귀적으로 취소됩니다.
  • 자식 Job이 실패하면 부모에게 전파되고, 형제도 함께 취소됩니다 (SupervisorJob 제외).
  • SupervisorJob은 자식의 실패가 형제에게 전파되지 않으므로, 독립적인 작업들을 병렬로 돌릴 때 사용합니다.

시각화

Coroutine suspend/resume 흐름

sequenceDiagram
    participant C as Coroutine
    participant D as Dispatcher
    participant T as 스레드 풀
    participant IO as I/O

    D->>T: Coroutine을 스레드에 배정
    T->>C: 코드 실행
    C->>IO: suspend point (API 호출)
    C-->>T: COROUTINE_SUSPENDED 리턴 → 스레드 반환
    Note over T: 다른 Coroutine 실행 가능
    IO-->>D: I/O 완료
    D->>T: Coroutine을 아무 스레드에 재배정
    T->>C: resume → 다음 label부터 이어서 실행

CPS 변환 상태 머신

stateDiagram-v2
    [*] --> Label0: 함수 최초 호출
    Label0 --> Suspended1: suspend point 1 (API 호출)
    Suspended1 --> Label1: resume (결과 전달)
    Label1 --> Suspended2: suspend point 2 (API 호출)
    Suspended2 --> Label2: resume (결과 전달)
    Label2 --> [*]: 최종 결과 리턴

정리

기준Kotlin CoroutineJava Virtual Thread리액티브 (Reactor/RxJava)
구현 레벨컴파일러 (CPS 변환)JVM (Continuation)라이브러리 (연산자 체인)
코드 스타일suspend 함수 (순차적)블로킹 (순차적)체이닝 (.flatMap 등)
스레드 모델Dispatcher 기반 배정캐리어 스레드 M:N이벤트 루프 (소수 스레드)
Structured Concurrency성숙 (CoroutineScope, Job)프리뷰 (StructuredTaskScope)없음 (Disposable 수동 관리)
취소 메커니즘CancellationException 전파interrupt()Disposable.dispose()
기존 블로킹 코드 호환withContext(IO) 필요대부분 자동 호환별도 래퍼 필요
백프레셔Flow (cold stream)없음 (별도 구현 필요)내장 (Flux, Observable)
스택 트레이스일부 손실 가능 (설정으로 개선)완전 유지거의 완전 손실
학습 곡선중간 (suspend 개념)낮음 (기존 코드 유지)높음 (연산자, 스케줄러)

실무에서 만난 사례

  • Spring WebFlux + Kotlin Coroutine: WebFlux의 Mono/Flux 대신 suspend 함수로 핸들러를 작성할 수 있으며, 내부적으로 mono { } 등의 브릿지 함수가 Coroutine ↔ Reactor 간 변환을 담당합니다. 이를 통해 리액티브의 성능과 Coroutine의 가독성을 동시에 얻을 수 있습니다.
  • Ktor: 처음부터 Coroutine 기반으로 설계된 서버 프레임워크로, 모든 핸들러가 suspend 함수입니다. Structured Concurrency 덕분에 요청 스코프 안에서 자식 Coroutine의 생명주기가 자동으로 관리됩니다.
  • Android: viewModelScope, lifecycleScope를 통해 UI 생명주기와 Coroutine 생명주기를 바인딩할 수 있습니다. Activity가 종료되면 진행 중인 네트워크 요청도 자동으로 취소되는데, 이것이 Structured Concurrency의 대표적인 실용 사례입니다.
  • 블로킹 라이브러리 호출 주의: JDBC는 블로킹 API이므로 반드시 withContext(Dispatchers.IO) 안에서 호출해야 합니다. Default Dispatcher에서 JDBC를 호출하면 CPU 스레드가 I/O 대기에 묶여 다른 Coroutine이 실행되지 못하는 문제가 발생합니다.

관련 개념

출처

  • Roman Elizarov, “Structured Concurrency” (KotlinConf 2019)
  • Kotlin 공식 문서: Coroutines Guide
  • KotlinConf 2023: “Coroutines Under the Hood”
  • JetBrains Blog: “How Kotlin Coroutine suspend functions work”