Kotlin의 Coroutines: launch, async 그리고 scope

1. 개요

Coroutines은 Kotlin이 콜백 지옥이나 thread 블로킹 없이 비동기 코드를 처리하는 방식입니다. 이 글에서는 launch, async, suspend fun 그리고 CoroutineScope가 어떻게 작동하는지, 언제 어떤 것을 사용해야 하는지 설명합니다. 이 글을 읽은 후, 깔끔한 concurrent 코드를 작성할 수 있고, coroutine의 lifecycle을 제어하는 방법을 알며, JUnit 5로 테스트할 수 있습니다.

2. 필요한 의존성

Coroutines은 Kotlin 표준 라이브러리에 포함되어 있지 않으므로 kotlinx-coroutines-core를 추가해야 합니다.

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>1.8.1</version>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-test</artifactId>
    <version>1.8.1</version>
    <scope>test</scope>
</dependency>

Spring Boot를 사용한다면, spring-boot-starter-webflux 또는 spring-boot-starter 모듈이 이미 controller의 suspend fun을 지원하는 Kotlin extensions을 포함하고 있습니다.

3. suspend fun: Coroutine의 기본 단위

suspend는 thread를 블로킹하지 않고 일시 중단(suspend)될 수 있는 함수를 표시하는 키워드입니다.

suspend fun fetchOrder(orderId: Long): Order {
    delay(200) // I/O 호출을 시뮬레이션하고 실제 thread를 블로킹하지 않습니다
    return Order(orderId, "PENDING")
}

data class Order(val id: Long, val status: String)

delay()는 내장된 suspend 함수로, thread를 대기 중에 점유하지 않는다는 점에서 Thread.sleep()과 다릅니다. suspend fun은 다른 suspend fun에서만 호출할 수 있거나 coroutine builder(launch, async, runBlocking) 내에서만 호출할 수 있습니다.

주의: 일반 fun 함수(suspend가 아닌)에서 suspend fun을 직접 호출하면 컴파일 에러가 발생합니다. 이것은 Kotlin이 비동기 context를 명시적으로 선언하도록 강제하는 메커니즘입니다.

4. launch: fire-and-forget coroutine

launch는 새로운 coroutine을 만들고 Job을 반환하며 계산 결과를 반환하지 않습니다. — 단순히 “실행하고 잊는” 경우에 사용합니다.

fun main() = runBlocking {
    val job: Job = launch {
        println("thread에서 order 처리 시작: ${Thread.currentThread().name}")
        val order = fetchOrder(1L)
        println("Order 상태: ${order.status}")
    }
    println("launch가 반환되었고, coroutine이 병렬로 실행 중입니다")
    job.join() // coroutine이 완료될 때까지 대기
}
launch가 반환되었고, coroutine이 병렬로 실행 중입니다
thread에서 order 처리 시작: main
Order 상태: PENDING

runBlocking은 main에서 coroutine을 실행하기 위한 scope를 만듭니다(데모/테스트용으로만 사용하고, 실제 production 코드에서는 사용하지 않습니다). launch는 현재 thread를 블로킹하지 않습니다 — coroutine을 스케줄하고 즉시 반환합니다. job.join()이 coroutine이 완료될 때까지 대기하는 지점입니다.

5. async: 반환 값이 있는 coroutine

async는 launch와 비슷하지만 Deferred<T>를 반환합니다 — .await()를 호출하여 결과를 얻을 수 있는 “promise”입니다.

suspend fun fetchCustomer(customerId: Long): String {
    delay(150)
    return "Customer-$customerId"
}

suspend fun loadOrderDetail(orderId: Long, customerId: Long): String = coroutineScope {
    val orderDeferred = async { fetchOrder(orderId) }
    val customerDeferred = async { fetchCustomer(customerId) }

    val order = orderDeferred.await()
    val customer = customerDeferred.await()
    "Order ${order.id} (${order.status}) 의 $customer"
}

두 async 명령은 병렬로(concurrent) 실행되고 순차적으로 실행되지 않습니다. 여기서의 총 대기 시간은 ~200ms(200ms와 150ms의 최댓값)이지 350ms가 아닙니다. await()이 결과를 얻기 위해 suspend하는 지점입니다 — 각 async 이후에 바로 await()를 호출하면 코드가 순차적으로 실행되고 병렬의 이점을 잃게 됩니다.

// 잘못됨: 순차적으로 실행되고 async의 의미를 잃음
val order = async { fetchOrder(orderId) }.await()
val customer = async { fetchCustomer(customerId) }.await()

주의: 위의 코드는 문법적 오류는 아니지만 async 사용 목적을 파괴합니다 — 각 await()이 생성 직후 차단되므로 두 coroutine이 동시에 실행되지 않습니다.

6. CoroutineScope: Coroutine의 Lifecycle을 관리하는 것

모든 coroutine은 CoroutineScope에 속해야 합니다. Scope는 다음을 결정합니다: coroutine이 얼마나 오래 살아있는지, 언제 취소되는지, exception이 어떻게 전파되는지.

class OrderService {
    private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())

    fun processOrderAsync(orderId: Long) {
        serviceScope.launch {
            val order = fetchOrder(orderId)
            println("order ${order.id}을 처리했습니다")
        }
    }

    fun shutdown() {
        serviceScope.cancel() // 이 scope 내에서 실행 중인 모든 child coroutine을 취소합니다
    }
}

serviceScope를 class의 field로 유지합니다 — 이것이 object를 더 이상 사용하지 않은 후에도 coroutine이 “고아”처럼 계속 실행되는 것을 방지하는 방법입니다. SupervisorJob()은 하나의 child coroutine이 실패해도 다른 child coroutine을 취소하지 않게 합니다(일반 Job()과는 다르게, 하나가 실패하면 모든 형제를 취소합니다).

6.1. coroutineScope vs CoroutineScope

coroutineScope { ... }(suspend 함수, 소문자)과 CoroutineScope(...)(새로운 scope를 생성하는 생성자, 대문자)를 혼동하지 마세요.

suspend fun loadDashboard(): String = coroutineScope {
    // coroutineScope는 내부의 모든 child coroutine이 완료될 때까지 대기합니다
    // 그런 다음 반환하며, parent coroutine의 context를 사용합니다
    val a = async { fetchOrder(1L) }
    val b = async { fetchCustomer(2L) }
    "${a.await()} - ${b.await()}"
}

coroutineScope는 여러 child coroutine을 그룹화하는 suspend function builder로, structured concurrency를 보장합니다: 함수는 내부의 모든 async/launch가 완료될 때만 반환됩니다(또는 하나가 실패하면 전체가 취소됩니다). 새로운 독립적인 scope를 생성하지 않고 현재 context를 상속합니다 — 함수가 종료될 때 자동으로 취소되지 않는 독립적인 scope를 생성하는 섹션 6의 CoroutineScope(...)와는 완전히 다릅니다.

7. Dispatcher: Coroutine이 어느 Thread에서 실행되는가

Dispatcher는 coroutine이 어디서 실행될지 결정합니다 — Java에서 Executor를 선택하는 것과 비슷합니다.

suspend fun readConfigFile(path: String): String = withContext(Dispatchers.IO) {
    // Dispatchers.IO는 blocking I/O(파일 읽기, JDBC 호출...)에 사용됩니다
    File(path).readText()
}

suspend fun heavyCompute(data: List<Int>): Long = withContext(Dispatchers.Default) {
    // Dispatchers.Default는 CPU-bound 작업에 사용됩니다
    data.sumOf { it.toLong() * it }
}

withContext는 현재 coroutine을 다른 dispatcher로 전환한 다음 완료 후 자동으로 이전 dispatcher로 돌아갑니다 — blocking code(JDBC, file I/O)를 호출해야 하지만 Dispatchers.Default 또는 Dispatchers.Main의 메인 thread를 점유하고 싶지 않을 때 매우 유용합니다(Android에서).

Dispatcher 용도
Dispatchers.Default CPU-bound 계산(parse, sort, compute)
Dispatchers.IO Blocking I/O(파일, JDBC, network blocking)
Dispatchers.Main UI 업데이트(Android/JavaFX에서만 사용 가능)
Dispatchers.Unconfined 거의 사용하지 않음, 초기 호출 thread에서 실행

8. Coroutine에서 Exception 처리하기

launch와 async에서 exception propagate가 다릅니다 — 이것이 가장 흔한 bug 원인입니다.

fun main() = runBlocking {
    // launch: exception이 즉시 throw되고, 잡지 않으면 scope를 crash시킬 수 있습니다
    val job = launch {
        try {
            throw IllegalStateException("Order가 존재하지 않습니다")
        } catch (e: IllegalStateException) {
            println("launch에서 오류를 잡았습니다: ${e.message}")
        }
    }
    job.join()

    // async: exception은 Deferred에 보관되고 await()를 호출할 때만 throw됩니다
    val deferred = async {
        throw IllegalStateException("Customer가 존재하지 않습니다")
    }
    try {
        deferred.await()
    } catch (e: IllegalStateException) {
        println("async에서 오류를 잡았습니다: ${e.message}")
    }
}
launch에서 오류를 잡았습니다: Order가 존재하지 않습니다
async에서 오류를 잡았습니다: Customer가 존재하지 않습니다

async로 .await()를 호출하지 않으면 exception은 Deferred가 garbage-collected될 때까지 또는 어딘가에서 await()를 호출할 때까지 조용히 “삼켜집니다” — production에서 debug하기 어려운 bug를 야기하기 쉽습니다.

9. Coroutine에 대한 Test 작성하기

kotlinx-coroutines-test의 runTest를 사용하여 실제 delay를 기다릴 필요 없이 suspend function을 테스트합니다.

class OrderServiceTest {

    @Test
    fun `fetchOrder가 PENDING 상태를 반환한다`() = runTest {
        val order = fetchOrder(1L)

        assertThat(order.id).isEqualTo(1L)
        assertThat(order.status).isEqualTo("PENDING")
    }

    @Test
    fun `loadOrderDetail이 두 coroutine을 병렬로 실행한다`() = runTest {
        val result = loadOrderDetail(orderId = 1L, customerId = 2L)

        assertThat(result).contains("Order 1")
        assertThat(result).contains("Customer-2")
    }
}

runTest는 자동으로 virtual time을 사용합니다 — fetchOrder 내부의 delay(200)이 실제로 test를 200ms 느리게 하지 않습니다, 즉시 resume 로직으로 skip됩니다. 이것이 unit test에서 runBlocking으로 coroutine을 테스트하면 안 되는 이유입니다: 실제 시간을 기다려 test suite가 test 수에 따라 점점 느려집니다.

10. 흔한 실수들

  • GlobalScope 사용: coroutine은 애플리케이션의 전체 생명 동안 존재하고 어떤 lifecycle도 따르지 않습니다 — leak을 유발합니다. 항상 섹션 6처럼 특정 class/component와 연결된 scope를 만드세요.
  • Production 코드에서 runBlocking 호출: 실제로 현재 thread를 블로킹하여 coroutine의 목적을 파괴합니다. main(), test, 또는 blocking code를 suspend code와 연결해야 하는 경우에만 사용하세요.
  • async에서 await() 호출 잊음: exception이 삼켜지고, coroutine이 누군가 결과를 기다리지 않고 실행됩니다 — 낭비적이고 오류를 추적하기 어렵습니다.
  • CPU-bound 작업에 Dispatchers.IO 섞기: Dispatchers.IO는 훨씬 큰 thread pool을 가지고 있으며(I/O 대기에 최적화됨) 무거운 계산에 사용하면 scheduling 효율이 떨어집니다.

주의: service가 실제 concurrency가 필요하지 않다면(몇 개의 I/O를 순차적으로만 호출) coroutine은 over-engineering일 수 있습니다. 때로 async/launch 없이 순차적으로 호출하는 간단한 suspend fun 함수가 더 명확하고 유지 관리하기 쉽습니다.

11. 결론

suspend fun은 기본 단위이고, launch는 반환 결과가 필요 없을 때 사용하며, async는 병렬로 실행하고 await()를 통해 결과를 얻을 때 사용합니다. CoroutineScope는 lifecycle을 결정합니다 — 항상 scope를 특정 component와 바인드하고, GlobalScope를 피하며, structured concurrency를 제어하기 위해 coroutineScope/SupervisorJob을 우선시하세요. 이 세 가지 개념을 잘 이해하면 Kotlin에서 대부분의 비동기 코드를 안전하고 쉽게 테스트할 수 있는 방식으로 작성할 수 있습니다.

kotlincoroutinesconcurrencyandroidspring-boot