Kotlin 코루틴 완벽 가이드 — launch, async, Flow

코루틴이란?

코루틴(Coroutine)은 Kotlin의 비동기 프로그래밍 솔루션입니다. 스레드보다 가볍고, 콜백 지옥 없이 순차적인 코드 스타일로 비동기 로직을 작성할 수 있습니다. suspend 함수를 통해 실행을 일시 중단하고 재개할 수 있으며, 구조화된 동시성(Structured Concurrency)으로 생명주기를 안전하게 관리합니다.

이 글에서는 코루틴의 기본 개념부터 Flow까지 실전 패턴을 정리합니다.

launch와 async — 코루틴 빌더

launch는 결과를 반환하지 않는 코루틴, async는 결과를 반환하는 코루틴을 생성합니다.

import kotlinx.coroutines.*

// suspend 함수 — 코루틴 내부에서만 호출 가능
suspend fun fetchUser(id: Int): String {
    delay(1000)  // 1초 대기 (스레드를 차단하지 않음)
    return "사용자_$id"
}

suspend fun fetchOrders(userId: String): List<String> {
    delay(800)   // 0.8초 대기
    return listOf("주문A", "주문B", "주문C")
}

fun main() = runBlocking {
    println("시작: ${Thread.currentThread().name}")

    // launch — 결과가 필요 없는 비동기 작업
    val job = launch {
        println("백그라운드 작업 시작")
        delay(500)
        println("백그라운드 작업 완료")
    }

    // async — 결과를 반환하는 비동기 작업
    val deferred = async {
        fetchUser(1)
    }
    val user = deferred.await()  // 결과 대기
    println("사용자: $user")

    // 병렬 실행 — async로 동시에 여러 작업 수행
    val startTime = System.currentTimeMillis()

    val user1 = async { fetchUser(1) }
    val user2 = async { fetchUser(2) }
    val user3 = async { fetchUser(3) }

    // 모든 결과를 동시에 대기
    println("결과: ${user1.await()}, ${user2.await()}, ${user3.await()}")
    val elapsed = System.currentTimeMillis() - startTime
    println("소요 시간: ${elapsed}ms (병렬이므로 약 1000ms)")

    job.join()  // launch 작업 완료 대기
    // 실행 결과:
    // 시작: main
    // 백그라운드 작업 시작
    // 사용자: 사용자_1
    // 백그라운드 작업 완료
    // 결과: 사용자_1, 사용자_2, 사용자_3
    // 소요 시간: 1012ms (병렬이므로 약 1000ms)
}

async로 여러 작업을 동시에 시작하고 await()로 결과를 모으면, 순차 실행 대비 실행 시간을 크게 줄일 수 있습니다.

구조화된 동시성과 코루틴 스코프

코루틴은 반드시 CoroutineScope 내에서 실행됩니다. 부모 코루틴이 취소되면 자식 코루틴도 모두 취소됩니다. 이를 구조화된 동시성(Structured Concurrency)이라 합니다.

import kotlinx.coroutines.*

// 사용자 정의 스코프에서 코루틴 관리
class DataProcessor {
    // 전용 스코프 — 취소 가능
    private val scope = CoroutineScope(
        Dispatchers.Default + SupervisorJob()
    )

    fun processAsync(items: List<String>) {
        scope.launch {
            println("처리 시작: ${items.size}개 항목")

            // coroutineScope — 모든 자식이 완료될 때까지 대기
            coroutineScope {
                items.forEach { item ->
                    launch {
                        processItem(item)
                    }
                }
            }
            println("모든 항목 처리 완료")
        }
    }

    private suspend fun processItem(item: String) {
        delay(500)  // 처리 시뮬레이션
        println("처리 완료: $item [${Thread.currentThread().name}]")
    }

    fun cancel() {
        scope.cancel()  // 모든 코루틴 취소
        println("프로세서 취소됨")
    }
}

fun main() = runBlocking {
    // withContext — 디스패처 전환
    val result = withContext(Dispatchers.IO) {
        // IO 스레드에서 실행 (파일, 네트워크 등)
        "IO 스레드에서 가져온 데이터"
    }
    println(result)
    // 실행 결과: IO 스레드에서 가져온 데이터

    // 타임아웃 처리
    val data = withTimeoutOrNull(1500) {
        delay(1000)
        "성공 데이터"
    }
    println("타임아웃 결과: $data")
    // 실행 결과: 타임아웃 결과: 성공 데이터

    val expired = withTimeoutOrNull(500) {
        delay(1000)
        "이 값은 반환되지 않음"
    }
    println("만료 결과: $expired")
    // 실행 결과: 만료 결과: null

    // 예외 처리
    val handler = CoroutineExceptionHandler { _, exception ->
        println("예외 발생: ${exception.message}")
    }

    val job = launch(handler) {
        throw RuntimeException("코루틴 내부 에러")
    }
    job.join()
    // 실행 결과: 예외 발생: 코루틴 내부 에러
}

Flow — 비동기 데이터 스트림

Flow는 여러 값을 순차적으로 내보내는 비동기 스트림입니다. Sequence의 비동기 버전이며, 리액티브 스트림(RxJava)을 대체할 수 있습니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

// Flow 생성 — 여러 값을 비동기로 방출
fun numbersFlow(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(300)  // 비동기 대기
        emit(i)     // 값 방출
        println("방출: $i")
    }
}

// 실시간 데이터 스트림 시뮬레이션
fun sensorDataFlow(): Flow<Double> = flow {
    repeat(5) {
        delay(500)
        val value = 20.0 + (Math.random() * 10)
        emit(value)
    }
}

fun main() = runBlocking {
    // 기본 Flow 수집
    println("=== 기본 Flow ===")
    numbersFlow().collect { value ->
        println("수집: $value")
    }
    // 실행 결과:
    // 방출: 1
    // 수집: 1
    // 방출: 2
    // 수집: 2
    // ... (5까지 반복)

    // Flow 연산자 — map, filter, take
    println("\n=== Flow 연산자 ===")
    numbersFlow()
        .filter { it % 2 != 0 }       // 홀수만
        .map { it * it }               // 제곱
        .take(2)                       // 처음 2개만
        .collect { println("결과: $it") }
    // 실행 결과:
    // 결과: 1
    // 결과: 9

    // 센서 데이터 스트림
    println("\n=== 센서 데이터 ===")
    sensorDataFlow()
        .map { String.format("%.1f°C", it) }
        .collect { println("온도: $it") }
    // 실행 결과 (랜덤 값):
    // 온도: 25.3°C
    // 온도: 22.8°C
    // ... (5회)

    // flowOf, asFlow — 간단한 Flow 생성
    println("\n=== 간단한 Flow ===")
    flowOf("A", "B", "C")
        .onEach { delay(100) }
        .collect { print("$it ") }
    println()
    // 실행 결과: A B C

    listOf(1, 2, 3, 4, 5)
        .asFlow()
        .reduce { acc, value -> acc + value }
        .let { println("합계: $it") }
    // 실행 결과: 합계: 15
}

디스패처 정리

코루틴 디스패처는 코루틴이 실행될 스레드를 결정합니다.

  • Dispatchers.Main — UI 스레드 (Android, JavaFX)
  • Dispatchers.IO — I/O 작업 (파일, 네트워크, DB)
  • Dispatchers.Default — CPU 집약 작업 (정렬, JSON 파싱)
  • Dispatchers.Unconfined — 호출 스레드에서 시작 (테스트용)

실전 팁

  • runBlocking은 테스트와 main에서만: 프로덕션 코드에서는 CoroutineScope를 사용합니다.
  • SupervisorJob 사용: 하나의 자식 코루틴 실패가 다른 자식에 영향을 주지 않아야 하면 SupervisorJob을 사용합니다.
  • withContext로 디스패처 전환: launch(Dispatchers.IO) 대신 withContext(Dispatchers.IO)로 전환하면 코루틴 계층이 단순해집니다.
  • Flow는 Cold 스트림: 수집자가 collect를 호출할 때만 실행됩니다. 여러 수집자에게 동시에 방출하려면 SharedFlowStateFlow를 사용합니다.
  • 취소 협조: 장시간 실행되는 코루틴에서는 isActive를 확인하거나 ensureActive()를 호출하여 취소에 협조합니다.

이 글이 도움이 되었나요?