JVM 동시성 모델 이해하기 (5) – Kotlin Coroutines

Table of Contents

동기 코드의 가독성으로 논블로킹을 — suspend 하나로 바뀌는 세상

Part 3에서 Reactor의 Mono/Flux를, Part 4에서 WebFlux를 다뤘습니다. Reactor는 논블로킹 비동기 처리를 가능하게 해주지만, 코드가 복잡해지면 flatMap 체이닝이 깊어지고 가독성이 떨어진다는 문제가 있었습니다.

// Reactor — 유저의 주문 → 각 주문의 상품 → 각 상품의 리뷰를 조합
public Mono<UserDashboard> buildDashboard(Long userId) {
    return userRepository.findById(userId)
        .flatMap(user -> orderRepository.findByUserId(user.getId())
            .flatMap(order -> productRepository.findById(order.getProductId())
                .flatMap(product -> reviewRepository.findByProductId(product.getId())
                    .collectList()
                    .map(reviews -> new ProductWithReviews(product, reviews))))
            .collectList()
            .map(products -> new OrderDetail(user, products)))
        .flatMap(detail -> pointRepository.findByUserId(userId)
            .map(points -> new UserDashboard(detail, points)));
}Code language: PHP (php)

관계가 깊어질수록 flatMap 안에 flatMap이 중첩되면서 코드의 흐름을 따라가기 어려워집니다. 같은 로직을 Kotlin Coroutines로 작성하면 이렇게 됩니다.

// Coroutine — 같은 논블로킹, 하지만 위에서 아래로 읽힌다
suspend fun buildDashboard(userId: Long): UserDashboard {
    val user = userRepository.findById(userId)
    val orders = orderRepository.findByUserId(user.id)
    val products = orders.map { order ->
        val product = productRepository.findById(order.productId)
        val reviews = reviewRepository.findByProductId(product.id)
        ProductWithReviews(product, reviews)
    }
    val points = pointRepository.findByUserId(userId)
    return UserDashboard(OrderDetail(user, products), points)
}Code language: JavaScript (javascript)

두 코드 모두 논블로킹입니다. I/O 대기 중에 스레드를 블로킹하지 않습니다. 하지만 코루틴 버전은 for 루프, 변수 할당, 함수 호출 — 일반 코드와 형태가 같습니다. 데이터의 흐름이 위에서 아래로 읽히고, flatMap의 중첩 깊이를 머릿속으로 추적할 필요가 없습니다. 이 글에서는 이 “마법”이 어떻게 가능한지, 그리고 코루틴의 나머지 핵심 개념들을 다룹니다.

코루틴이란 — 중단 가능한 함수 실행

코루틴을 “경량 스레드(lightweight thread)”라고 설명하는 글이 많습니다. 비유로는 괜찮지만, 정확하지는 않습니다. 경량 스레드는 실재하는 개념으로, “green thread“, “fiber”라고도 불리며, OS 스레드가 아닌 사용자 공간에서 관리하는 스레드를 통칭합니다. Go goroutine, Erlang process, Java Virtual Thread가 대표적인 경량 스레드 구현입니다. 코루틴은 엄밀히 스레드 모델이 아니라 “중단 가능한 계산(suspendable computation)”이지만, 경량 스레드와 비슷한 효과를 내기 때문에 자주 비유됩니다.

경량 스레드와 코루틴의 차이는 선점형/협력형이 아닙니다 — Go goroutine도 기본적으로 협력형입니다. 핵심 차이는 개발자에게 보이는 모델입니다. 경량 스레드(goroutine, Virtual Thread)는 “진짜 스레드처럼 보이지만 비용이 적은 실행 단위”입니다. 개발자는 마치 OS 스레드를 쓰듯이 코드를 작성하고, 중단/재개/스케줄링은 런타임이 알아서 처리합니다 — 개발자가 “어디서 멈추고 어디서 재개할지” 신경 쓸 필요가 없습니다. 코루틴은 개발자가 suspend로 중단 지점을 직접 지정하고, Dispatcher로 실행 스레드도 직접 결정합니다. 실행의 제어권이 개발자에게 있습니다. goroutine은 go func()만 쓰면 Go 런타임이 스레드 매핑을 해주지만, 코루틴은 launch할 때 CoroutineScope + Dispatcher가 필요합니다. 힙에 상태를 저장하고 OS 스레드보다 저렴하다는 점은 둘 다 같지만, “스레드처럼 쓰되 런타임이 관리” vs “개발자가 중단점과 실행 스레드를 제어”라는 모델 차이가 있습니다.

용어 정리: 루틴, 서브루틴, 코루틴. 이 용어들은 OS가 아니라 프로그래밍 언어 이론에서 나온 개념입니다 — 1958년 Melvin Conway가 coroutine이라는 용어를 만들었습니다. 루틴(routine)은 호출 가능한 코드 단위의 총칭(일반 명사)이고, 제어 흐름 방식에 따라 두 종류로 나뉩니다. 서브루틴(sub-routine) = “하위 루틴” — 호출자(caller)에 종속적이고, 호출하면 처음부터 끝까지 실행하고 반환합니다. 제어 흐름이 비대칭(caller → subroutine → return to caller)입니다. 우리가 아는 일반 함수입니다. 코루틴(co-routine) = “협력 루틴” — 서로 대등하고, 실행을 중간에 양보(suspend)하고 나중에 이어갈 수 있습니다. 제어 흐름이 대칭(A ↔ B 서로 양보)입니다. 즉 루틴은 양보를 하거나 안 하는 것이 아니라 그냥 “코드 실행 단위”라는 일반 용어이고, 서브루틴과 코루틴이 구체적인 두 가지 종류입니다.

코루틴의 핵심은 함수 실행을 특정 지점에서 중단(suspend)하고, 나중에 이어서 재개(resume)할 수 있는 메커니즘입니다.

suspend fun fetchAndProcess() {
    val data = fetchFromNetwork()  // 여기서 중단 → 스레드 반환
    // ... 네트워크 응답이 오면 여기서 재개
    process(data)
}Code language: JavaScript (javascript)

fetchFromNetwork()가 네트워크 I/O를 시작하면, 코루틴은 중단됩니다. 이 순간 스레드는 반환되어 다른 코루틴을 실행할 수 있습니다. 네트워크 응답이 도착하면 코루틴이 재개되어 process(data)부터 이어서 실행됩니다.

스레드와의 근본적인 차이는 누가 관리하느냐입니다.

스레드코루틴
관리 주체OS 커널코틀린 런타임 (라이브러리)
중단/재개OS 스케줄러가 선점프로그래머가 명시 (suspend)
비용스택 메모리 ~1MB, 컨텍스트 스위칭 비용힙의 객체 몇 개, 매우 저렴
수량수백~수천 개수십만 개도 가능

Part 2에서 다룬 것처럼 스레드는 OS 커널이 관리하며, 각각 스택 메모리를 차지하고 컨텍스트 스위칭 비용이 발생합니다. 코루틴은 JVM 힙에 상태를 저장하는 객체일 뿐이어서, 메모리와 전환 비용이 훨씬 적습니다. 10만 개의 코루틴을 만들어도 스레드 10만 개를 만드는 것과는 비교할 수 없을 정도로 가볍습니다.

왜 비용이 이렇게 다른가? 두 가지 차이가 있습니다.

메모리: OS 스레드는 생성 시 고정 크기 스택(보통 1MB)을 미리 예약합니다. 실제 함수 호출이 3단계밖에 안 깊어도 1MB를 잡아놓습니다 — OS가 스택이 얼마나 깊어질지 모르니까요. 코루틴은 CPS 변환(뒤에서 자세히 다룹니다) 덕분에 스레드의 콜 스택을 사용하지 않습니다. “어디까지 실행했는지”를 스택 프레임 대신 label 필드 하나로 추적하고, 중간 변수만 상태 머신 객체의 필드에 저장합니다 — 수백 바이트 수준입니다.

전환(switching) 비용: 스레드 컨텍스트 스위칭은 유저 모드 → 커널 모드 → 유저 모드 전환이 필요하고, CPU 레지스터 전체(프로그램 카운터, 스택 포인터, 범용 레지스터 등)를 저장/복원해야 합니다. 코루틴 “전환”은 함수 리턴 + 함수 호출 수준이어서 커널을 거치지 않습니다.

“그러면 스레드도 힙에 저장하면 되지 않느냐?”라고 생각할 수 있습니다. 스택이 존재하는 이유는 속도 때문입니다. 스택 할당은 포인터 하나를 움직이는 게 전부(O(1))이고, 힙 할당은 빈 공간 찾기와 GC 관리가 필요해서 더 느립니다. 일반 함수 호출이 빈번한 환경에서는 스택의 속도가 중요합니다. 하지만 동시성을 위해 수만 개를 만들어야 하는 상황에서는 “각각 1MB 예약”이 치명적이 됩니다 — 코루틴은 함수 호출 속도를 약간 포기하고 메모리 효율을 극대화한 트레이드오프입니다.

콜 스택이 없으면 디버깅은 어떻게? 코루틴이 스택을 쓰지 않는다는 것은, 에러가 발생했을 때 스택 트레이스가 불완전하다는 뜻이기도 합니다. 코루틴 A가 suspend → 코루틴 B에서 예외가 터지면, 전통적 스택 트레이스에는 “A가 B를 호출했다”는 정보가 없습니다 — 중단 시점에 A의 스택 프레임은 이미 사라졌으니까요. 이를 해결하기 위해 kotlinx-coroutines-debug 모듈이 있고, -Dkotlinx.coroutines.debug JVM 옵션으로 코루틴 이름과 생성 위치를 트레이스에 포함시킬 수 있습니다. Part 4에서 Reactor가 Hooks.onOperatorDebug()ReactorDebugAgent로 연산자 조립 위치를 추적했던 것과 같은 문제, 같은 접근입니다 — 기본적으로는 성능을 위해 추적 정보를 저장하지 않고, 디버깅이 필요할 때만 옵션으로 켭니다.

그렇다면 코루틴이 이렇게 가볍고 좋은데, 왜 처음부터 OS가 코루틴 같은 것을 제공하지 않았을까요? 핵심은 선점형(preemptive) vs 협력형(cooperative)의 트레이드오프입니다.

OS 스레드 (선점형)코루틴 (협력형)
전환 방식OS가 강제로 멈추고 다른 스레드 실행코루틴이 스스로 양보(suspend)해야 전환
CPU 독점 방지OS가 보장 — 한 스레드가 아무리 바빠도 다른 스레드에 기회 줌보장 안 됨 — suspend 안 하면 스레드 독점
비용비쌈 (스택, 컨텍스트 스위칭)저렴 (힙 객체, 함수 호출 수준)

코루틴의 치명적 약점은 CPU 집약 작업에서 드러납니다. 코루틴이 suspend 없이 긴 연산을 하면, 그 스레드를 독점해서 같은 스레드의 다른 코루틴이 전부 멈춥니다. OS 스레드는 이런 상황에서도 강제로 전환(선점)할 수 있지만, 코루틴은 그런 능력이 없습니다 — 자발적으로 양보할 때까지 기다릴 수밖에 없습니다. 코루틴은 I/O 대기가 많은 작업에는 매우 효율적이지만, CPU를 오래 점유하는 작업에는 스레드의 선점형 스케줄링이 여전히 필요합니다.

“경량 스레드”라는 아이디어는 코틀린만의 것이 아닙니다. 다른 언어/플랫폼에서도 비슷한 시도가 있었는데, 구현 레벨이 다릅니다.

구현레벨설명
Kotlin Coroutines라이브러리kotlinx.coroutines 라이브러리가 제공. JVM 자체는 코루틴을 모름
Go goroutine런타임Go 런타임 스케줄러가 goroutine을 OS 스레드에 매핑 (M:N 스케줄링)
Erlang processVMBEAM VM이 수백만 프로세스를 자체 스케줄링
Java Virtual ThreadJVMJVM이 관리하는 경량 스레드 (JEP 444). Part 7에서 다룹니다

중요한 점은, 어느 것도 OS 레벨이 아닙니다. OS는 여전히 무거운 스레드만 알고, 경량 스레드/코루틴은 각 플랫폼이 “OS 위에서” 자체적으로 구현한 것입니다. Kotlin은 라이브러리 레벨이라 JVM을 수정하지 않고도 사용할 수 있다는 장점이 있고, 반대로 JVM의 네이티브 지원을 받는 Virtual Thread는 기존 블로킹 코드를 수정 없이 경량화할 수 있다는 장점이 있습니다.

“런타임”과 “VM”은 무엇이 다른가? 먼저, 런타임(runtime)이란 “프로그램이 실행될 때 함께 동작하는 지원 코드”를 의미합니다 — GC, 스케줄러, 메모리 관리 등 프로그램이 직접 작성하지 않았지만 실행에 필요한 기반 코드입니다. 사실 Java도 런타임을 가지고 있고(JRE = Java Runtime Environment), C도 최소한의 런타임(libc)이 있습니다.

VM(Virtual Machine)은 바이트코드를 해석/실행하는 가상 머신입니다 — JVM이 .class 파일을, BEAM이 Erlang 바이트코드를 실행합니다. VM은 런타임의 한 형태로, 런타임 기능(GC, 스케줄러 등) + 바이트코드 실행 엔진을 포함합니다. Go는 VM이 없습니다. Go 코드는 C/C++처럼 네이티브 바이너리로 직접 컴파일됩니다. 하지만 그 바이너리 안에 Go 런타임이 함께 링크되어 있습니다 — goroutine 스케줄러, GC, 메모리 관리 등의 지원 코드가 실행 파일에 내장됩니다. 정리하면: Java/Kotlin → VM(런타임 포함) 위에서 실행, Go → 네이티브 바이너리 + 런타임 내장 (VM 없음), Erlang → VM(런타임 포함) 위에서 실행입니다.

suspend 키워드 — 진입점

suspend는 코루틴의 가장 기본적인 키워드입니다. 함수 앞에 suspend를 붙이면 “이 함수는 실행 중 중단될 수 있다”는 선언입니다.

suspend fun fetchUser(id: Long): User {
    delay(1000)  // 1초 대기 — 스레드를 블로킹하지 않음
    return User(id, "Alice")
}Code language: JavaScript (javascript)

중요한 규칙이 있습니다 — suspend 함수는 suspend 함수 안에서만, 또는 코루틴 빌더 안에서만 호출할 수 있습니다.

// 컴파일 에러 — 일반 함수에서 suspend 함수 호출 불가
fun main() {
    fetchUser(1L)  // Error: Suspend function 'fetchUser' should be called
                   // only from a coroutine or another suspend function
}

// OK — 코루틴 빌더(runBlocking) 안에서 호출
fun main() = runBlocking {
    val user = fetchUser(1L)
    println(user)
}Code language: JavaScript (javascript)

그리고 suspend ≠ “무조건 중단”입니다. “중단할 수 있다”는 가능성의 선언이지, 호출할 때마다 반드시 중단되는 것은 아닙니다. 실제로 중단을 일으키는 것은 delay(), withContext(), await() 같은 중단점(suspension point)을 포함하는 함수입니다.

그러면 실제 중단점이 없는데 suspend를 붙이는 건 의미가 없을까요? 맞습니다 — IDE(IntelliJ)도 “Redundant suspend modifier”라고 경고합니다. suspend는 컴파일러에게 “이 함수에 Continuation 파라미터를 추가하고 상태 머신을 생성하라”는 지시이므로, 실제 중단점이 없으면 불필요한 변환 비용만 추가됩니다.

suspend가 함수에 필요한 이유는 두 가지입니다. 첫째, 컴파일 타임 표시 — 컴파일러가 이 표시를 보고 CPS 변환(뒤에서 자세히 다룹니다)을 수행합니다. 둘째, 타입 안전성 — suspend 함수는 코루틴 안에서만 호출할 수 있으므로, 실수로 일반 함수에서 느린 비동기 작업을 호출하는 것을 컴파일 단계에서 방지합니다. 임의의 위치에서 함수를 강제로 중단하는 것이 아니라, 프로그래머가 중단점을 명시적으로 지정하는 협력형 방식이 코루틴의 핵심입니다.

이것은 동시성 성능에도 직결됩니다. 코루틴이 스레드를 다른 코루틴에게 양보하는 것은 오직 suspend 지점뿐입니다. 만약 코루틴 안에서 suspension point 없이 긴 연산을 수행하면, 같은 스레드의 다른 코루틴이 전부 대기하게 됩니다 — 앞의 선점형 vs 협력형 테이블에서 다룬 문제가 바로 이것입니다. suspend가 많을수록 스레드를 더 자주 양보하므로, 코루틴의 동시성이 좋아집니다.

코루틴 빌더 — 코루틴을 시작하는 방법

suspend 함수는 코루틴 안에서만 호출할 수 있다고 했습니다. 그렇다면 최초의 코루틴은 어떻게 시작할까요? 코루틴 빌더가 그 역할을 합니다.

launch — “실행하고 잊어라”

결과를 반환하지 않는 코루틴을 시작합니다. “이 작업을 백그라운드에서 해줘”라는 느낌입니다.

val scope = CoroutineScope(Dispatchers.Default)

scope.launch {
    val user = fetchUser(1L)
    saveToCache(user)
    println("캐시 저장 완료")
}
// launch는 즉시 반환 — 코루틴은 백그라운드에서 실행 중Code language: PHP (php)

launchJob 객체를 반환합니다. 이 Job으로 코루틴의 완료를 기다리거나 취소할 수 있습니다.

val job = scope.launch {
    delay(2000)
    println("작업 완료")
}

// 코루틴이 끝날 때까지 기다림 (블로킹 아님 — 현재 코루틴을 중단)
job.join()
println("job 완료 후 실행됨")

// 또는 취소
val longJob = scope.launch {
    repeat(1000) { i ->
        println("작업 $i")
        delay(500)
    }
}
delay(2000)
longJob.cancel()  // 코루틴 취소Code language: JavaScript (javascript)

job.join()은 suspend 함수입니다 — 스레드를 블로킹하는 것이 아니라, 현재 코루틴을 중단하고 스레드를 반환합니다. Job이 완료되면 현재 코루틴이 재개됩니다.

async — “결과를 나중에 받겠다”

결과를 반환하는 코루틴을 시작합니다. Deferred<T>를 반환하며, await()로 결과를 받습니다.

val deferred: Deferred<User> = scope.async {
    fetchUser(1L)  // User를 반환
}

val user: User = deferred.await()  // 결과가 준비될 때까지 중단(블로킹 아님)Code language: JavaScript (javascript)

async의 진짜 힘은 병렬 실행에 있습니다.

suspend fun getUserWithOrders(userId: Long): UserWithOrders = coroutineScope {
    // 두 작업을 동시에 시작
    val userDeferred = async { fetchUser(userId) }
    val ordersDeferred = async { fetchOrders(userId) }

    // 둘 다 완료되면 결합
    UserWithOrders(userDeferred.await(), ordersDeferred.await())
}Code language: JavaScript (javascript)

fetchUserfetchOrders가 각각 1초 걸린다면, 순차 실행은 2초, 위 코드는 1초입니다. Reactor에서 같은 패턴을 구현하면 이렇습니다.

// Reactor — Mono.zip()으로 병렬 실행
public Mono<UserWithOrders> getUserWithOrders(Long userId) {
    Mono<User> userMono = fetchUser(userId);
    Mono<List<Order>> ordersMono = fetchOrders(userId).collectList();

    return Mono.zip(userMono, ordersMono,
        (user, orders) -> new UserWithOrders(user, orders));
}Code language: PHP (php)

Mono.zip()은 두 Mono를 병렬로 실행하고 결과를 결합하는 연산자입니다. 동작은 같지만, 결합 함수를 별도로 전달해야 하고, 3개 이상의 병렬 작업이 되면 Tuple3, Tuple4 같은 타입이 등장하면서 가독성이 떨어집니다. 코루틴에서는 async + await로 자연스러운 변수 할당 형태를 유지할 수 있습니다.

runBlocking — 코루틴 세계로의 다리

현재 스레드를 블로킹하면서 코루틴을 실행합니다. “일반 세계”에서 “코루틴 세계”로 진입하는 다리 역할입니다.

fun main() = runBlocking {
    // 여기서부터 코루틴 세계
    val user = fetchUser(1L)
    println(user)
}Code language: JavaScript (javascript)

runBlockingmain() 함수나 테스트 코드에서 사용합니다. 프로덕션 코드에서는 사용을 지양해야 합니다 — 스레드를 블로킹하므로, WebFlux의 이벤트 루프 스레드에서 호출하면 Part 4에서 다룬 “이벤트 루프 블로킹” 문제가 그대로 발생합니다.

runBlocking이 “코루틴 세계로의 유일한 다리”는 아닙니다. CoroutineScope(Dispatchers.Default)처럼 직접 스코프를 만들어서 launchasync를 호출할 수도 있습니다.

// runBlocking 없이 코루틴 시작
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
    val user = fetchUser(1L)
    println(user)
}Code language: PHP (php)

차이는 runBlocking호출 스레드를 블로킹하면서 기다리고, CoroutineScope.launch코루틴을 시작만 하고 즉시 반환한다는 것입니다. main() 함수에서 CoroutineScope.launch만 호출하면 메인 스레드가 종료되어 프로그램이 바로 끝납니다 — 그래서 main()에서는 runBlocking으로 기다려야 합니다. 하지만 Spring 같은 프레임워크에서는 애플리케이션이 계속 살아있으므로, 프레임워크가 제공하는 스코프를 사용합니다 — Part 6에서 자세히 다룹니다.

잠깐 Part 6 미리보기를 하면 Spring WebFlux에서는 컨트롤러 함수를 suspend fun으로 선언할 수 있습니다. 코루틴과 Reactor는 별개의 기술이지만, kotlinx-coroutines-reactor 라이브러리가 둘 사이의 어댑터(adapter)를 제공합니다. 이 어댑터가 suspend funMono, FlowFlux 변환을 해줍니다. 예를 들어 mono {} 브릿지는 내부에서 코루틴을 시작하고, 코루틴이 반환한 값을 Mono의 onNext()로 전달합니다. Spring의 요청 처리 파이프라인이 Reactor 기반이므로, 이 어댑터 덕분에 runBlocking 없이도 코루틴 세계로 진입할 수 있습니다. 어댑터의 내부 동작과 다양한 변환 패턴은 Part 6에서 자세히 다룹니다.

// Spring WebFlux 컨트롤러 — suspend fun을 직접 선언
@GetMapping("/users/{id}")
suspend fun getUser(@PathVariable id: Long): User {
    return userRepository.findById(id)  // suspend 호출
}
// Spring이 내부적으로: mono { getUser(id) } 로 변환하여 Reactor에 연결Code language: JavaScript (javascript)

coroutineScope — “모든 자식이 끝날 때까지 기다림”

새로운 코루틴 스코프를 만들되, 현재 코루틴을 중단하고 모든 자식 코루틴이 완료될 때까지 기다립니다.

suspend fun processAll() = coroutineScope {
    launch { task1() }
    launch { task2() }
    launch { task3() }
    // 세 작업이 모두 완료될 때까지 여기서 중단
}
// processAll() 이후 — 세 작업 모두 완료됨이 보장Code language: JavaScript (javascript)

runBlocking vs coroutineScope — 둘 다 “기다리는” 건데 뭐가 다른가?

둘 다 “내부 코루틴이 끝날 때까지 기다린다”는 점은 같습니다. 차이는 기다리는 방식입니다.

// runBlocking — 스레드 자체를 잡고 안 놓아줌
fun main() = runBlocking {  // main 스레드가 여기서 멈춤
    launch { delay(1000) }
    // main 스레드는 이 블록이 끝날 때까지 다른 일을 못 함
}

// coroutineScope — 코루틴만 중단, 스레드는 반환
suspend fun process() = coroutineScope {  // 코루틴이 중단됨
    launch { delay(1000) }
    // 스레드는 반환되어 다른 코루틴을 실행할 수 있음
}Code language: JavaScript (javascript)

runBlocking호출한 스레드를 물리적으로 점유합니다. 내부 코루틴이 완료될 때까지 그 스레드는 아무것도 할 수 없습니다 — Part 4에서 다룬 “이벤트 루프 스레드를 블로킹하면 안 된다”는 규칙을 정면으로 위반하게 됩니다.

여기서 “스레드를 블로킹한다”는 것은 JVM 레벨의 이야기입니다. OS 레벨에서는 여전히 선점형 스케줄링이 동작하고 있어서, 블로킹된 스레드도 다른 프로세스/스레드와 CPU 타임 슬라이스를 나눠 씁니다. “블로킹”이란 그 스레드가 해당 JVM 프로세스 내의 다른 코루틴이나 요청을 처리하지 못하고 대기 중이라는 것이지, CPU를 완전히 독점한다는 뜻이 아닙니다.

coroutineScope코루틴을 중단(suspend)합니다. 스레드는 반환되어 다른 코루틴을 실행할 수 있고, 자식이 완료되면 중단된 코루틴이 재개됩니다. 코루틴의 장점을 그대로 유지하는 방식입니다.

그렇다면 runBlocking은 왜 존재할까요? “일반 세계”에서 “코루틴 세계”로 진입하는 다리가 필요하기 때문입니다. main() 함수, JUnit 테스트 메서드는 suspend 함수가 아닙니다. 이런 곳에서 코루틴을 시작하려면 스레드를 블로킹하더라도 진입점이 필요합니다. 프로덕션의 비즈니스 로직에서는 coroutineScope을 사용하고, runBlocking은 진입점(main, 테스트)에만 사용하는 것이 원칙입니다.

CoroutineScope과 구조화된 동시성

앞의 코루틴 빌더 예시에서 scope.launch { ... }처럼 항상 scope가 등장했습니다. launchasync를 아무 데서나 호출하는 것이 아니라, 반드시 CoroutineScope 안에서 호출해야 합니다. 이것은 코루틴에서 가장 중요한 설계 원칙 — 구조화된 동시성(Structured Concurrency) — 때문입니다.

CoroutineScope은 코루틴의 생명주기를 관리하는 경계입니다. 이 스코프 안에서 시작된 코루틴은 부모-자식 관계를 형성합니다.

val scope = CoroutineScope(Dispatchers.Default)

scope.launch {           // 부모 코루틴
    launch { task1() }   // 자식 코루틴 1
    launch { task2() }   // 자식 코루틴 2
}Code language: PHP (php)

이 부모-자식 관계가 만드는 규칙은 이렇습니다.

부모가 취소되면 자식도 모두 취소됩니다. 예를 들어 사용자가 화면을 떠나서 부모 스코프가 취소되면, 진행 중이던 네트워크 요청(자식 코루틴)도 자동으로 취소됩니다. 리소스 누수가 구조적으로 방지됩니다.

자식이 실패하면 부모에게 전파되고, 부모의 다른 자식도 취소됩니다. task1()이 예외를 던지면 task2()도 취소됩니다.

부모는 모든 자식이 완료될 때까지 완료되지 않습니다. launch로 시작한 자식이 아직 실행 중이면 부모 코루틴도 완료되지 않습니다.

flowchart TD
    S[CoroutineScope] --> P[부모 코루틴]
    P --> C1[자식 1 - task1]
    P --> C2[자식 2 - task2]
    P --> C3[자식 3 - task3]

    C1 -->|실패| P
    P -->|취소 전파| C2
    P -->|취소 전파| C3

Reactor에서도 Mono.zip()으로 묶은 파이프라인에서 하나가 실패하면 나머지가 취소됩니다. 그렇다면 코루틴의 구조화된 동시성과 뭐가 다를까요? 차이는 서로 관련 없는 독립 작업을 어떻게 묶느냐에 있습니다. Reactor에서는 zip()이나 merge()명시적으로 연결해야만 한쪽 실패가 다른 쪽에 전파됩니다 — 연결하지 않은 두 개의 독립 Mono는 서로 영향을 주지 않습니다. 코루틴에서는 같은 스코프 안에서 launch로 시작하기만 하면 자동으로 부모-자식 관계가 형성되어, 명시적 연결 없이도 한쪽 실패가 전파됩니다. 즉 “실행시켜 놓고 묶는 것을 잊어버리는” 실수가 구조적으로 방지됩니다.

이것이 GlobalScope.launch를 쓰면 안 되는 이유입니다. GlobalScope은 애플리케이션 전체 생명주기를 따르므로, 구조화된 동시성의 장점을 모두 포기하는 것입니다.

// GlobalScope — 구조가 없는 코루틴
GlobalScope.launch {
    throw RuntimeException("실패!")
    // → GlobalScope는 취소되지 않음
    // → 다른 GlobalScope 코루틴에도 전파되지 않음
    // → 예외는 스레드의 UncaughtExceptionHandler로 전달 (기본: stderr 로그)
}

GlobalScope.launch {
    // 위 코루틴의 실패와 무관하게 계속 실행됨
    // 아무도 이 코루틴의 생명주기를 관리하지 않음
}Code language: JavaScript (javascript)

GlobalScope의 자식이 실패해도 GlobalScope 자체는 취소되지 않고, 다른 자식에게도 전파되지 않습니다. 각 코루틴이 고아처럼 독립적으로 존재하는 것입니다. 예외가 완전히 무시되는 것은 아닙니다 — CoroutineExceptionHandler가 설정되어 있으면 거기로, 없으면 스레드의 UncaughtExceptionHandler로 전달되어 기본적으로 stderr에 로그가 찍힙니다. 핵심은 형제 코루틴에 전파되지 않는다는 것입니다. 반면 일반 CoroutineScope에서는 자식의 실패가 부모로 전파되어 다른 자식도 정리되므로, 리소스 누수 없이 깔끔하게 실패를 처리할 수 있습니다.

주의: 구조화된 동시성이 “끊기는” 것은 GlobalScope 경계까지입니다. GlobalScope.launch로 시작한 코루틴 A가 내부에서 launch로 코루틴 B를 시작하면, A-B 사이에는 정상적인 부모-자식 관계가 형성됩니다 — B가 실패하면 A도 취소됩니다. GlobalScope이 “고아”를 만드는 것은 GlobalScope → 직접 자식 관계뿐이고, 그 아래의 서브트리에서는 구조화된 동시성이 정상 작동합니다.

Dispatcher — 코루틴이 실행되는 스레드 결정

코루틴이 어떤 스레드에서 실행되느냐를 결정하는 것이 Dispatcher입니다.

Dispatcher스레드 풀용도Reactor 대응
Dispatchers.DefaultCPU 코어 수연산 집약 작업Schedulers.parallel()
Dispatchers.IO최대 64개 (확장 가능)블로킹 I/OSchedulers.boundedElastic()
Dispatchers.MainUI 스레드 1개Android UI 갱신
Dispatchers.Unconfined호출자 스레드특수 목적Schedulers.immediate()

Part 3에서 subscribeOn(Schedulers.boundedElastic())으로 블로킹 I/O를 격리했던 것을 떠올려보면, 코루틴에서는 withContext(Dispatchers.IO)가 같은 역할을 합니다.

// Reactor
fun readFile(): Mono<String> =
    Mono.fromCallable { File("data.txt").readText() }
        .subscribeOn(Schedulers.boundedElastic())

// Coroutine — 같은 의미
suspend fun readFile(): String = withContext(Dispatchers.IO) {
    File("data.txt").readText()  // 블로킹 I/O를 IO 스레드에서 실행
}Code language: JavaScript (javascript)

withContext는 코루틴의 실행 스레드를 전환합니다. 블록 안의 코드가 완료되면 원래 Dispatcher로 돌아옵니다.

suspend fun process() {
    // Default 스레드에서 실행 중
    val data = withContext(Dispatchers.IO) {
        // IO 스레드로 전환
        readFromDatabase()
    }
    // 다시 Default 스레드로 복귀
    transform(data)
}Code language: JavaScript (javascript)

Dispatchers.IODispatchers.Default는 실제로 스레드를 공유합니다. 같은 스레드 풀에서 관리되지만 동시에 사용할 수 있는 최대 스레드 수가 다릅니다. Default는 CPU 코어 수로 제한되고, IO는 최대 64개(또는 코어 수 중 큰 값)까지 확장됩니다. 이는 CPU 집약 작업이 코어 수 이상의 스레드를 쓰면 컨텍스트 스위칭 오버헤드만 늘어나기 때문입니다 — Part 2에서 다룬 스레드 풀 사이징 원칙과 같은 이유입니다.

IO 디스패처가 고갈되면? 블로킹 I/O 작업이 64개를 초과하면 새 코루틴은 스레드를 기다리며 대기합니다. 이를 방지하기 위해 Dispatchers.IO.limitedParallelism(n)으로 용도별 하위 디스패처를 만들어 격리할 수 있습니다 — 예를 들어 DB 접근용 10개, 파일 I/O용 5개로 분리하면 한쪽이 고갈되어도 다른 쪽에 영향을 주지 않습니다. 모니터링은 Micrometer 메트릭이나 -Dkotlinx.coroutines.debug 옵션으로 활성 코루틴 수를 추적할 수 있습니다.

suspend의 내부 동작 — CPS 변환과 상태 머신

이 섹션이 코루틴의 핵심입니다. “동기 코드처럼 보이는데 어떻게 논블로킹인가?”의 답이 여기에 있습니다.

마법의 정체 — 컴파일러 변환

코루틴의 핵심 마법은 코틀린 컴파일러가 해줍니다. 우리가 작성한 suspend 함수를 컴파일러가 Continuation Passing Style(CPS)로 변환하고, 내부적으로 상태 머신(state machine)을 만들어줍니다.

쉽게 말하면 — 우리는 동기 코드를 작성하고, 컴파일러가 그것을 콜백 기반 코드로 변환해줍니다. Reactor에서 직접 콜백 체이닝(flatMap, map)을 작성하던 것을 컴파일러가 대신 해주는 것입니다.

단계별로 살펴보기

다음 suspend 함수를 예시로 보겠습니다.

suspend fun fetchUserWithOrders(userId: Long): UserWithOrders {
    println("시작")                           // 중단점 없음
    val user = fetchUser(userId)              // 중단점 1
    println("유저 조회 완료: ${user.name}")
    val orders = fetchOrders(user.id)         // 중단점 2
    println("주문 조회 완료: ${orders.size}건")
    return UserWithOrders(user, orders)
}Code language: JavaScript (javascript)

이 함수에는 두 개의 중단점(suspension point)이 있습니다 — fetchUser()fetchOrders() 호출 부분입니다. 컴파일러는 이 중단점을 기준으로 함수를 쪼갭니다.

1단계: CPS 변환 — 숨겨진 파라미터 추가

컴파일러는 모든 suspend 함수에 Continuation 파라미터를 하나 추가합니다.

// 우리가 작성한 코드
suspend fun fetchUser(userId: Long): User

// 컴파일러가 변환한 코드 (개념적 예시)
fun fetchUser(userId: Long, continuation: Continuation<User>): Any?Code language: JavaScript (javascript)

Continuation은 “중단된 이후 어떻게 이어갈 것인가”를 담은 콜백입니다.

interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}Code language: HTML, XML (xml)

resumeWith(result)가 호출되면 중단된 지점부터 실행이 재개됩니다. Reactor에서 onNext()가 다음 연산자를 호출하는 것과 비슷한 역할입니다.

반환 타입이 User가 아니라 Any?로 바뀐 것에 주목하세요. 변환된 함수는 두 가지 중 하나를 반환합니다.

// fetchUser의 변환된 내부 (개념적 의사코드)
fun fetchUser(userId: Long, cont: Continuation<User>): Any? {
    // 네트워크 요청 시작
    val pending = networkClient.requestAsync("/users/$userId")

    if (pending.isCompleted) {
        // 이미 완료된 경우 (캐시 히트 등) → 결과를 직접 반환
        return pending.result  // User 객체
    } else {
        // 아직 응답 안 옴 → continuation을 콜백으로 등록하고 SUSPENDED 반환
        pending.onComplete { user ->
            cont.resumeWith(Result.success(user))  // 나중에 호출됨
        }
        return COROUTINE_SUSPENDED  // "지금은 결과 없음" 신호
    }
}Code language: JavaScript (javascript)

COROUTINE_SUSPENDED는 “결과가 아직 준비되지 않았으니, 나중에 continuation 콜백으로 알려줄게”라는 신호입니다. 호출자의 상태 머신은 이 값을 보고 “중단 → 스레드 반환”을 결정합니다. 만약 결과가 즉시 사용 가능하면(캐시 히트 등) User 객체가 직접 반환되고, 상태 머신은 중단 없이 다음 label로 바로 진행합니다.

2단계: 상태 머신 변환 — 함수를 쪼개기

컴파일러는 중단점을 기준으로 함수를 상태 머신으로 변환합니다. 각 중단점이 하나의 상태(label)가 됩니다.

// 컴파일러가 생성한 상태 머신 (의사코드)
fun fetchUserWithOrders(userId: Long, cont: Continuation<*>): Any? {
    // 상태를 저장하는 객체 (최초 호출 시 생성)
    val sm = cont as? FetchUserWithOrdersSM ?: FetchUserWithOrdersSM(cont)

    when (sm.label) {
        0 -> {
            // 상태 0: 시작 ~ 첫 번째 중단점
            println("시작")
            sm.label = 1              // 다음 상태 설정
            sm.userId = userId        // 지역 변수 저장
            // fetchUser 호출 — 반환값은 User 또는 COROUTINE_SUSPENDED
            val result = fetchUser(userId, sm)  // sm을 콜백으로 전달
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            // 중단 안 됨 (캐시 히트 등) → result가 곧 User → 다음 상태로 직행
            sm.result = Result.success(result)  // 즉시 반환값을 sm.result에 저장
        }
        1 -> {
            // 상태 1: fetchUser 완료 후 ~ 두 번째 중단점
            // ※ 여기에 오는 경로는 두 가지:
            //   (a) 중단 후 재개 — resumeWith()가 sm.result에 값을 저장해둠
            //   (b) 중단 없이 위 label 0에서 직행 — 위에서 sm.result에 저장해둠
            // 어느 경로든 sm.result에 User가 들어있음
            val user = sm.result!!.getOrThrow() as User
            sm.user = user                 // 다음 중단에서도 쓸 수 있게 저장
            println("유저 조회 완료: ${user.name}")
            sm.label = 2
            val result = fetchOrders(user.id, sm)
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            sm.result = Result.success(result)
        }
        2 -> {
            // 상태 2: fetchOrders 완료 후 ~ 함수 끝
            val orders = sm.result!!.getOrThrow() as List<Order>
            val user = sm.user             // 저장해둔 지역 변수 복원
            println("주문 조회 완료: ${orders.size}건")
            return UserWithOrders(user, orders)
        }
    }
}Code language: PHP (php)

길어 보이지만, 핵심 패턴은 간단합니다.

label — 현재 어디까지 실행했는지 기억하는 상태 번호입니다. 중단점마다 1씩 증가합니다.

sm (상태 머신 객체) — 중단 시점의 지역 변수를 저장하는 객체입니다. user 같은 중간 결과를 이 객체에 보관했다가, 재개 시 복원합니다. 일반 함수는 지역 변수를 스택에 저장하지만, 코루틴은 중단 시 스택이 사라지므로 힙의 객체에 저장합니다.

sm.result — suspend 함수의 반환값이 저장되는 곳입니다. result라는 지역 변수는 fetchUser()즉시 반환값으로, COROUTINE_SUSPENDED이거나 실제 User입니다. 중단된 경우 나중에 resumeWith()sm.result에 값을 저장하고, 중단되지 않은 경우 즉시 반환값을 sm.result에 직접 저장합니다. 어느 경로든 다음 label에서는 sm.result에서 값을 꺼내면 됩니다.

COROUTINE_SUSPENDED — suspend 함수가 “지금 결과를 줄 수 없으니 나중에 콜백(continuation)으로 알려줄게”라는 신호입니다. 이 값이 반환되면 함수 실행이 멈추고, 스레드는 반환됩니다.

전체 흐름을 시각화하면

sequenceDiagram
    participant T as 스레드 A
    participant SM as 상태 머신
    participant N as 네트워크

    T->>SM: fetchUserWithOrders() 호출
    Note over SM: label=0, println("시작")
    T->>N: fetchUser() 시작
    Note over SM: label=1로 설정, userId 저장
    T-->>T: SUSPENDED 반환 → 스레드 반환

    Note over T: 스레드 A는 다른 코루틴 실행 가능

    N->>SM: resumeWith(user) — 유저 데이터 도착
    Note over SM: label=1, user 복원
    SM->>N: fetchOrders() 시작
    Note over SM: label=2로 설정, user 저장
    SM-->>SM: SUSPENDED

    N->>SM: resumeWith(orders) — 주문 데이터 도착
    Note over SM: label=2, user+orders 복원
    SM->>SM: UserWithOrders 반환 → 완료

핵심을 정리하면: 우리가 작성한 순차적인 코드를 컴파일러가 상태 머신으로 변환합니다. 각 중단점에서 함수 실행이 멈추고 스레드가 반환되며, I/O가 완료되면 resumeWith()가 호출되어 다음 상태부터 이어서 실행됩니다. 우리는 동기 코드를 작성하고, 컴파일러가 콜백으로 변환해줍니다.

resumeWith()는 정확히 어떻게 상태 머신을 이어서 돌리는가? 의사코드로 보겠습니다.

// Result — 성공 또는 실패를 감싸는 래퍼
value class Result<T>(val value: Any?) {
    val isSuccess: Boolean get() = value !is Failure
    fun getOrThrow(): T = if (isSuccess) value as T
                          else throw (value as Failure).exception

    companion object {
        fun <T> success(value: T) = Result<T>(value)
        fun <T> failure(e: Throwable) = Result<T>(Failure(e))
    }
}

// 컴파일러가 생성하는 상태 머신 객체 (개념적 예시)
class FetchUserWithOrdersSM(
    val completion: Continuation<UserWithOrders>
) : ContinuationImpl(completion) {
    var label = 0
    var result: Any? = null    // resumeWith가 저장하는 곳
    var userId: Long = 0
    var user: User? = null

    // ContinuationImpl.resumeWith()가 호출하는 메서드
    override fun invokeSuspend(result: Result<Any?>): Any? {
        this.result = result       // 결과 저장
        return fetchUserWithOrders(userId, this)  // 함수를 다시 진입!
    }
}Code language: HTML, XML (xml)

핵심은 invokeSuspend()함수를 처음부터 다시 호출한다는 것입니다. “when 블록만 따로 실행”하는 것이 아닙니다. 하지만 함수 첫 줄 val sm = cont as? FetchUserWithOrdersSM ?: ...에서 cont가 이미 SM 객체이므로 기존 객체를 재사용하고, when(sm.label)에서 현재 label에 해당하는 분기로 즉시 점프합니다. 결과적으로 “함수를 다시 시작하지만, label 덕분에 중단된 지점부터 이어서 실행”되는 효과가 나는 것입니다.

흐름을 정리하면: 네트워크 라이브러리가 응답을 받음 → sm.resumeWith(Result.success(data)) 호출 → ContinuationImpl이 result를 저장 → invokeSuspend() 호출 → 함수 재진입 → when(label)에서 올바른 분기 실행 → 저장된 result에서 값 꺼내기 → 다음 코드 실행. 콜백이 상태 머신을 한 칸 전진시키는 구조입니다.

Reactor와 비교하면:

ReactorCoroutine
비동기 표현flatMap 체이닝 (개발자가 작성)순차 코드 (컴파일러가 변환)
상태 저장연산자 체인의 각 단계가 상태상태 머신 객체 (힙)
콜백onNext() → 다음 연산자resumeWith() → 다음 label
중간 결과 전달Mono/Flux 파이프라인을 통해상태 머신 객체의 필드에 저장

본질적으로 같은 일을 합니다 — 논블로킹 I/O와 콜백 기반 실행. 차이는 Reactor에서는 개발자가 직접 콜백 체이닝을 작성하고, 코루틴에서는 컴파일러가 대신 해준다는 것입니다.

상태 머신을 좀 더 직관적으로 이해하기

상태 머신이 낯설 수 있습니다. 일상적인 비유를 들어보겠습니다.

책을 읽다가 누군가 부르면, 우리는 페이지 번호에 책갈피를 꽂고 자리를 뜹니다. 나중에 돌아와서 책갈피가 있는 페이지부터 이어서 읽습니다.

코루틴의 상태 머신도 같습니다. label이 책갈피(어디까지 읽었는지), 상태 머신 객체가 메모장(읽다가 기억해둔 내용)입니다. 중단 시 책갈피를 꽂고 스레드를 반환하고, 재개 시 책갈피부터 이어서 실행합니다.

Flow — 코루틴의 스트림 처리

지금까지 다룬 suspend 함수는 단일 값을 비동기로 반환합니다. 여러 값을 시간에 걸쳐 방출해야 한다면? 코루틴의 Flow를 사용합니다. Reactor에서 Flux가 담당하던 역할입니다.

Part 3의 용어로 대응시키면: Mono ↔ suspend fun, Flux ↔ Flow 입니다. 그리고 Reactor에서 Sinks로 프로그래밍적으로 신호를 주입하던 역할은, 코루틴에서는 SharedFlowStateFlow가 담당합니다 — 이들은 모두 Flow 인터페이스를 구현하며, Flow 안에서 Cold/Hot이 나뉩니다.

Cold Flow — 기본 Flow

fun numbers(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(100)       // 논블로킹 대기
        emit(i)          // 값 방출
    }
}

// 사용
suspend fun main() {
    numbers().collect { value ->  // collect = subscribe
        println(value)
    }
}Code language: JavaScript (javascript)

flow { } 빌더 안에서 emit()으로 값을 방출합니다. 수신 측은 collect()로 값을 받습니다. Reactor의 Fluxsubscribe()에 대응됩니다. 비교해보면 이렇습니다.

// Reactor — Flux
Flux<Integer> numbers() {
    return Flux.range(1, 5)
        .delayElements(Duration.ofMillis(100));
}

numbers().subscribe(value -> System.out.println(value));Code language: JavaScript (javascript)
// Coroutine — Flow (같은 동작)
fun numbers(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(100)
        emit(i)
    }
}

numbers().collect { value -> println(value) }Code language: HTML, XML (xml)

기본 Flow는 Cold 스트림입니다 — collect()를 호출하기 전에는 아무것도 실행되지 않습니다. Part 3에서 다룬 Flux의 Cold 특성과 같습니다.

Flow 연산자

Flux처럼 Flow도 중간 연산자를 제공합니다.

numbers()
    .filter { it % 2 == 0 }          // 짝수만
    .map { it * 10 }                  // 10배
    .collect { println(it) }          // 20, 40Code language: JavaScript (javascript)

map, filter, transform, take, drop 등 Flux에서 익숙한 연산자들이 대부분 있습니다.

스레드 전환은 flowOn으로 합니다 — Reactor의 publishOn에 대응됩니다.

flow {
    // IO 스레드에서 실행
    emit(readFromDatabase())
}
.flowOn(Dispatchers.IO)        // 위쪽 flow의 실행 스레드를 지정
.map { transform(it) }         // Default 스레드에서 실행
.collect { println(it) }Code language: JavaScript (javascript)

Flow의 Backpressure — suspend가 자연스럽게 해결

Part 3에서 Backpressure를 다뤘습니다. 생산자가 소비자보다 빠르면 어떻게 할 것인가? Reactor에서는 request(n), onBackpressureBuffer(), onBackpressureDrop() 같은 전략을 명시적으로 설정해야 했습니다.

// Reactor — 명시적 Backpressure 전략 필요
Flux.range(1, 1000)
    .onBackpressureBuffer(100)       // 버퍼 100개, 넘치면 에러
    .delayElements(Duration.ofSeconds(1))  // 느린 소비자
    .subscribe(value -> System.out.println(value));Code language: JavaScript (javascript)

Flow에서는 별도의 Backpressure 전략이 필요 없습니다. emit()collect()가 모두 suspend 함수이기 때문입니다.

// Coroutine — Backpressure 전략 없이 suspend가 자동 해결
flow {
    for (i in 1..1000) {
        emit(i)          // collect가 처리 중이면 여기서 자동 중단
    }
}
.collect { value ->
    delay(1000)          // 느린 소비자
    println(value)
}Code language: JavaScript (javascript)

collect가 아직 이전 값을 처리 중이면 emit()은 자동으로 중단(suspend)됩니다. 소비자가 다음 값을 요청할 준비가 되면 emit()이 재개됩니다. Reactor에서는 버퍼 크기, 오버플로 전략, Drop/Latest 정책 등을 개발자가 선택해야 하지만, Flow에서는 suspend가 Backpressure를 자연스럽게 해결합니다 — “기다려”를 request(n) 프로토콜이 아닌 함수 중단으로 표현합니다.

Cold Flow vs Flux 비교

항목Flux (Reactor)Flow (Coroutine)
단일 값Mono<T>suspend fun(): T
스트림Flux<T>Flow<T>
구독subscribe()collect()
스트림 타입Cold (기본)Cold (기본)
Backpressurerequest(n) 프로토콜suspend로 자동
스레드 전환publishOn() / subscribeOn()flowOn() / withContext()
에러 처리onErrorResume()try-catch

Hot Flow — SharedFlowStateFlow

기본 Flow는 Cold — collect()를 호출해야 생산이 시작되고, 각 collector가 독립적인 실행을 받습니다. 하지만 구독과 무관하게 데이터가 생산되는 Hot stream이 필요한 경우도 있습니다. Reactor에서 Sinks가 이 역할이었다면, 코루틴에서는 SharedFlowStateFlow가 담당합니다. 둘 다 Flow 인터페이스를 구현하므로, collect()로 값을 수신하고 map, filter 같은 Flow 연산자를 그대로 사용할 수 있습니다.

“Flow 인터페이스를 구현하면 Cold 아닌가?”라고 생각할 수 있지만, Flow 인터페이스는 Hot/Cold를 결정하지 않습니다. Flowcollect()로 값을 수신할 수 있다는 소비자 측 계약일 뿐이고, Hot/Cold는 생산자 측의 동작 방식입니다. Kotlin 공식 문서도 SharedFlow를 명시적으로 “hot”이라고 설명합니다 — “collector의 존재 여부와 무관하게 독립적으로 존재한다”는 점에서 Cold Flow와 대비됩니다.

SharedFlow이벤트 스트림입니다. Reactor의 Sinks.many().multicast()에 대응됩니다. 값을 emit하면 그 시점의 모든 collector에게 전달되고 끝입니다. “현재 값”이라는 개념이 없으므로, 기본 설정(replay = 0)에서 collector가 없으면 emit()한 값은 유실됩니다 — Reactor의 Sinks.many().multicast()가 구독자 없을 때 값이 유실되는 것과 같습니다.

StateFlow상태 홀더입니다. Reactor의 Sinks.many().replay().latest()에 대응됩니다. SharedFlow의 특수 형태로, 항상 “현재 값”을 하나 가지고 있고, 새 collector가 구독하면 즉시 현재 값을 받습니다. 같은 값을 다시 emit하면 무시됩니다(distinctUntilChanged 동작이 내장). 생성할 때 반드시 초기값이 필요합니다 — Android에서 UI 상태 관리에 많이 쓰입니다.

// SharedFlow — 이벤트용. 초기값 없음.
val events = MutableSharedFlow<ClickEvent>()

// StateFlow — 상태용. 초기값 필수.
val uiState = MutableStateFlow(UiState.Loading)Code language: JavaScript (javascript)
비교 포인트SharedFlowSinks.multicast()StateFlowSinks.replay().latest()
현재 값없음없음항상 있음 (초기값 필수)최신 값 1개 보관
새 구독자replay만큼 받음 (기본 0)구독 후 값만 받음현재 값 즉시 받음최신 값 즉시 받음
같은 값 emit매번 전달매번 전달무시 (distinctUntilChanged)매번 전달
구독자 없을 때값 유실 (replay=0 일때)값 유실현재 값 유지최신 값 유지
버퍼replay + extraBufferCapacityonBackpressureBuffer(n)1 (현재 값만)1 (최신 값만)
용도이벤트 (클릭, 알림, 에러)이벤트상태 (UI 상태, 설정값)상태

StateFlow는 사실 MutableSharedFlow(replay = 1)distinctUntilChanged를 적용한 특수 형태라고 볼 수 있습니다.

SharedFlow의 버퍼와 replay

SharedFlow는 두 가지 버퍼 설정을 제공합니다.

MutableSharedFlow<Int>(
    replay = 2,              // 새 collector에게 최근 2개 값을 재생
    extraBufferCapacity = 3  // emit이 suspend되지 않는 추가 버퍼
)Code language: JavaScript (javascript)

replay는 새 collector가 구독할 때 과거 값을 몇 개까지 다시 보내줄지입니다. replay = 2면 나중에 구독한 collector도 최근 2개 값을 즉시 받습니다. extraBufferCapacity는 모든 collector가 아직 처리 못한 값을 얼마나 쌓아둘지입니다 — 이 버퍼가 차면 emit()이 suspend됩니다. 버퍼는 모든 활성 collector가 해당 값을 소비하면 비워집니다 — 가장 느린 collector 기준입니다.

replay 캐시는 별도의 저장소가 아니라 같은 내부 버퍼의 일부입니다. MutableSharedFlow(replay = 2, extraBufferCapacity = 3)이면 내부 버퍼 크기는 총 5(2+3)이고, 그중 최근 2개는 항상 유지되는 replay 캐시 영역(새 구독자를 위해 모든 collector가 소비해도 지우지 않음), 나머지 3개는 느린 collector를 위한 여유 버퍼 영역(모든 활성 collector가 소비하면 비워짐)입니다.

Flow 정리 — Cold vs Hot, 그리고 선택 기준

비교 포인트Cold FlowHot: SharedFlowHot: StateFlow
생산 시점collect()할 때 시작독립적 — 구독자와 무관독립적 — 구독자와 무관
구독자 없을 때생산 안 됨값 유실 (replay=0 일때)현재 값 유지
구독 시처음부터 새로 실행replay만큼 재생현재 값 즉시 전달
Reactor 대응FluxSinks.multicast()Sinks.replay().latest()

왜 Hot과 Cold 구분이 필요한가? 핵심은 “데이터가 언제 생산되느냐”입니다. Cold stream은 소비자가 구독해야 생산이 시작됩니다 — collect()할 때마다 flow 블록이 새로 실행됩니다. DB 조회, API 호출처럼 “요청할 때마다 새로 실행”하는 것에 적합합니다. Hot stream은 구독자와 무관하게 데이터가 생산됩니다 — 사용자 클릭 이벤트, 센서 데이터, 실시간 채팅처럼 “이미 일어나고 있는 것”에 적합합니다.

데이터 성격에 맞는 스트림을 써야 합니다. DB 조회를 Hot(SharedFlow)으로 만들면: 쿼리가 한 번 실행되고 결과가 공유됩니다. 새 구독자가 “최신 데이터를 달라”고 해도 쿼리가 다시 실행되지 않습니다 — Cold로 만들어야 각 구독자가 신선한 데이터를 받습니다. 클릭 이벤트를 Cold(Flow)로 만들면: collect할 때마다 독립적인 이벤트 리스너가 새로 등록됩니다. 하지만 이벤트는 본질적으로 “지금 일어나는 것”이지 “새로 실행”하는 것이 아닙니다 — 과거의 클릭을 재실행하는 건 의미가 없습니다. Hot으로 만들면 하나의 이벤트 소스가 존재하고, 관심 있는 관찰자가 실시간으로 수신하는 자연스러운 구조가 됩니다.

Channel — 코루틴 간 통신

Flow가 데이터를 스트림으로 변환하고 처리하는 파이프라인이라면, Channel은 코루틴 간 메시지를 주고받는 통신 수단입니다. Go의 channel과 같은 모델로, send()/receive()라는 명령형 API를 사용하며 map, filter 같은 연산자 체인이 없습니다. Reactor에서 SinksFlux가 같은 Reactive Streams 파이프라인 위에 있었던 것과 달리, 코루틴의 Channel은 Flow와 완전히 별도의 모델입니다.

그렇다면 Channel은 언제 쓰는가? Channel은 코루틴이 서로 협력하는 패턴에서 씁니다. 대표적인 세 가지 용도가 있습니다.

작업 큐(생산자-소비자 패턴): 한쪽에서 작업을 만들고, 다른 쪽에서 처리합니다. 이미지 업로드 요청을 Channel에 넣고, 워커 코루틴이 꺼내서 처리하는 식입니다.

Fan-out / Fan-in: 하나의 Channel에서 여러 워커가 작업을 나눠 받아 병렬 처리하고(fan-out), 각 워커의 결과를 다른 Channel로 모아서 합치는(fan-in) 패턴입니다. Part 2에서 다룬 스레드 풀의 작업 큐와 비슷하지만, 코루틴 레벨에서 동작합니다.

코루틴 간 이벤트 전달: 코루틴 A가 “이 작업을 해줘”라고 코루틴 B에게 메시지를 보내는 것입니다. 두 코루틴이 독립적으로 실행되면서, Channel을 통해 필요할 때만 데이터를 주고받습니다.

// Fan-out 예시: 여러 워커가 작업을 나눠 처리
val tasks = Channel<Task>(capacity = 100)  // 작업 큐

// 생산자 — 작업을 Channel에 넣음
launch {
    for (task in fetchPendingTasks()) {
        tasks.send(task)
    }
    tasks.close()
}

// 워커 3개가 작업을 나눠 받아 처리 (fan-out)
repeat(3) { workerId ->
    launch {
        for (task in tasks) {  // 각 task는 하나의 워커만 받음
            println("워커 $workerId 처리: ${task.id}")
            process(task)
        }
    }
}Code language: JavaScript (javascript)

Spring 웹 개발에서는 데이터 스트림 처리가 주요 패턴이므로 Flow/SharedFlow/StateFlow로 대부분 해결됩니다. Channel은 코루틴 간 작업 분배나 동기화가 필요한 경우 — 백그라운드 워커 풀, 배치 처리 파이프라인, 동시 요청 제한 같은 인프라 레벨의 동시성 패턴에서 주로 활용됩니다.

Channel은 하나의 메시지를 하나의 수신자만 가져가는 point-to-point 큐입니다.

val channel = Channel<Int>()

// 생산자 코루틴
launch {
    for (i in 1..5) {
        channel.send(i)       // 소비자가 받을 준비가 되면 전송
        println("보냄: $i")
    }
    channel.close()
}

// 소비자 코루틴
launch {
    for (value in channel) {  // 채널이 닫힐 때까지 반복
        println("받음: $value")
        delay(1000)           // 느린 소비자
    }
}Code language: JavaScript (javascript)

send()receive()가 모두 suspend 함수입니다. 소비자가 아직 이전 값을 처리 중이면 send()가 중단됩니다 — Flow와 마찬가지로 suspend가 Backpressure를 자연스럽게 해결합니다. 위 코드에서 for (value in channel)은 내부적으로 channel.receive()를 반복 호출하는 문법적 설탕(syntactic sugar)입니다 — 채널이 닫히면 루프가 종료됩니다. 여러 receiver가 있으면 각 메시지를 하나의 receiver만 가져갑니다 (competing consumers 패턴) — 위 fan-out 예시가 이 방식으로 동작합니다.

기본 Channel(RENDEZVOUS)은 버퍼가 0이므로 send()는 누군가 receive()를 호출할 때까지 suspend됩니다 — 메시지가 유실되는 상황이 없습니다. 버퍼가 있는 Channel은 수신자가 없어도 버퍼에 메시지가 쌓이고, 버퍼가 찰 때까지 send()가 suspend 안 됩니다.

Channel 버퍼 전략

// 버퍼 없음 — send와 receive가 만날 때까지 둘 다 중단
val rendezvous = Channel<Int>(Channel.RENDEZVOUS)

// 고정 버퍼 — 버퍼가 차면 send 중단
val buffered = Channel<Int>(capacity = 10)

// 무제한 버퍼 — send가 중단되지 않음 (메모리 주의)
val unlimited = Channel<Int>(Channel.UNLIMITED)

// 최신 값만 유지 — 버퍼가 차면 오래된 값을 덮어씀
val conflated = Channel<Int>(Channel.CONFLATED)Code language: HTML, XML (xml)
버퍼 전략동작
RENDEZVOUS버퍼 없음, send와 receive가 만날 때까지 둘 다 중단
BUFFERED고정 크기 버퍼, 버퍼가 차면 send 중단
UNLIMITED무제한 버퍼, send가 중단되지 않음 (메모리 주의)
CONFLATED최신 값만 유지, 소비되지 않은 이전 값은 덮어씀

예외 처리와 취소

코루틴의 가장 큰 실용적 장점 중 하나는 try-catch가 그대로 동작한다는 것입니다.

try-catch — 익숙한 방식 그대로

suspend fun getUser(id: Long): User {
    return try {
        fetchUser(id)
    } catch (e: NetworkException) {
        getCachedUser(id)  // 폴백
    }
}Code language: JavaScript (javascript)

Reactor에서 같은 로직은 onErrorResume()으로 작성해야 했습니다.

// Reactor
public Mono<User> getUser(Long id) {
    return fetchUser(id)
        .onErrorResume(NetworkException.class, e -> getCachedUser(id));
}Code language: JavaScript (javascript)

기능은 같지만, try-catch가 더 직관적입니다. 특히 에러 처리가 복잡해질수록 차이가 커집니다.

CoroutineExceptionHandler — 전역 에러 핸들러

val handler = CoroutineExceptionHandler { _, exception ->
    println("처리되지 않은 예외: ${exception.message}")
}

val scope = CoroutineScope(Dispatchers.Default + handler)

scope.launch {
    throw RuntimeException("문제 발생!")
    // → handler가 잡아서 처리
}Code language: PHP (php)

launch에서 발생한 예외 중 try-catch로 잡히지 않은 것은 CoroutineExceptionHandler로 전달됩니다. Spring의 @ExceptionHandler와 비슷한 역할입니다.

supervisorScope — 자식 실패를 격리

기본적으로 자식 코루틴의 실패는 부모와 다른 자식에게 전파됩니다. 하지만 자식들이 서로 독립적이어서, 하나가 실패해도 나머지는 계속 실행되어야 할 때가 있습니다.

suspend fun loadDashboard() = supervisorScope {
    val profile = async { fetchProfile() }       // 실패해도
    val notifications = async { fetchNotifications() }  // 이건 계속 실행
    val recommendations = async { fetchRecommendations() }

    DashboardData(
        profile = try { profile.await() } catch (e: Exception) { null },
        notifications = notifications.await(),
        recommendations = recommendations.await()
    )
}Code language: JavaScript (javascript)

supervisorScope 안에서는 자식의 실패가 다른 자식에게 전파되지 않습니다. fetchProfile()이 실패해도 fetchNotifications()fetchRecommendations()는 정상적으로 계속 실행됩니다.

주의: supervisorScope이 “실패를 무시해준다”는 뜻은 아닙니다. 실패한 Deferredawait()를 호출하면 그 시점에 예외가 발생합니다. supervisorScope이 해주는 것은 “실패가 형제 코루틴으로 전파되지 않도록 막아주는 것”뿐입니다. 따라서 실패한 작업의 결과를 안전하게 처리하려면 위 코드처럼 await() 호출부를 try-catch로 감싸서 폴백 값(여기서는 null)을 반환해야 합니다.

CancellationException — 취소는 정상 흐름

코루틴의 취소는 예외가 아닌 정상적인 흐름으로 취급됩니다. CancellationExceptionCoroutineExceptionHandler에 전달되지 않습니다.

val job = launch {
    try {
        repeat(1000) { i ->
            println("작업 $i")
            delay(500)  // 취소 가능한 중단점
        }
    } catch (e: CancellationException) {
        println("취소됨 — 정리 작업 수행")
        throw e  // 반드시 다시 던져야 취소가 전파됨
    }
}

delay(2000)
job.cancel()  // CancellationException 발생Code language: JavaScript (javascript)

delay(), yield() 같은 suspend 함수는 취소를 확인합니다. 코루틴이 취소되면 다음 중단점에서 CancellationException이 던져집니다. CPU 집약적인 작업에서 중단점이 없으면 취소가 동작하지 않으므로, yield()로 취소 확인 기회를 주거나 isActive를 확인해야 합니다.

// 방법 1: yield()로 취소 확인
suspend fun heavyComputation() = coroutineScope {
    var result = 0
    for (i in 1..1_000_000) {
        result += complexCalc(i)
        if (i % 1000 == 0) yield()  // 1000번마다 취소 확인 + 다른 코루틴에 양보
    }
    result
}

// 방법 2: isActive로 직접 확인
suspend fun heavyComputation2() = coroutineScope {
    var result = 0
    for (i in 1..1_000_000) {
        if (!isActive) break    // 취소되었으면 루프 탈출
        result += complexCalc(i)
    }
    result
}Code language: JavaScript (javascript)

yield()는 suspend 함수이므로 취소를 확인하고, 동시에 같은 스레드의 다른 코루틴에 실행 기회를 줍니다. isActive는 suspend 없이 취소 상태만 확인하므로, 취소 시 CancellationException을 던지지 않고 직접 정리 로직을 작성할 수 있습니다.

마무리 — 코루틴이 바꾸는 것과 바꾸지 않는 것

이 글에서 다룬 내용을 정리하면 이렇습니다.

개념핵심
코루틴중단/재개 가능한 함수 실행, 스레드보다 훨씬 가벼움
suspend“이 함수는 중단될 수 있다”는 선언
CPS + 상태 머신컴파일러가 순차 코드를 콜백 기반으로 변환
Flow여러 값의 비동기 스트림 (Flux 대응), suspend로 자연스러운 Backpressure
구조화된 동시성부모-자식 생명주기 관리, 리소스 누수 구조적 방지

코루틴이 바꾸는 것은 코드의 형태입니다. flatMap 체이닝 대신 순차 코드를 작성하고, onErrorResume 대신 try-catch를 사용합니다.

코루틴이 바꾸지 않는 것은 실행 원리입니다. 논블로킹 I/O, 콜백 기반 재개, 스레드 풀 격리 — 본질적인 메커니즘은 Reactor와 동일합니다. 컴파일러가 “보기 좋은 동기 코드”를 “실행 가능한 콜백 코드”로 변환해주는 것이 전부입니다.

flowchart LR
    A[Reactor - 논블로킹 파이프라인] -->|가독성 개선| B[Coroutines - 같은 논블로킹 + 동기 문법]
    B -->|Spring 웹에 적용| C[Spring + Coroutines - Part 6]

    style A fill:#ffcdd2
    style B fill:#c8e6c9
    style C fill:#bbdefb

다음 글에서는 코루틴을 Spring 웹 프레임워크에서 실제로 사용하는 방법을 다룹니다. WebFlux + Coroutines 조합, MVC + Coroutines 가능성, 그리고 Reactor와 코루틴의 상호 변환(mono {}, asFlow(), awaitSingle())까지 살펴보겠습니다.

참고 자료

공식 문서

설계 문서 및 제안

발표 및 참고

댓글 남기기