Kotlin 코루틴 | 비동기 프로그래밍 완벽 가이드

Kotlin 코루틴 | 비동기 프로그래밍 완벽 가이드

이 글의 핵심

Kotlin 코루틴에 대한 실전 가이드입니다. 비동기 프로그래밍 완벽 가이드 등을 예제와 함께 상세히 설명합니다.

들어가며

코루틴은 스레드를 아주 많이 늘리지 않고도 비동기 작업을 구조화해 표현하는 방법입니다. suspend 함수와 디스패처로 어디서 실행할지 나눕니다.

Go 고루틴·채널이 “많은 경량 작업자 + 통신”에 초점을 맞춘다면, 코루틴은 suspend 지점에서 실행을 넘기는 async/await 스타일에 가깝습니다. Rust async/Tokio·Node·JS 이벤트 루프·JavaScript Promise와도 자주 나란히 설명됩니다.

특징:

  • 경량: 수천 개의 코루틴 동시 실행 가능
  • 간결: async/await 스타일
  • 구조화: 구조화된 동시성
  • 취소 가능: 작업 취소 지원

1. 코루틴 설정

build.gradle.kts

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}

2. 코루틴 빌더

runBlocking

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("시작")
    delay(1000)
    println("끝")
}

launch

fun main() = runBlocking {
    launch {
        delay(1000)
        println("World!")
    }
    println("Hello,")
    delay(2000)  // 코루틴 완료 대기
}

// 출력:
// Hello,
// World!

async

fun main() = runBlocking {
    val deferred = async {
        delay(1000)
        "결과"
    }
    
    println("작업 중...")
    val result = deferred.await()
    println(result)
}

3. suspend 함수

suspend fun fetchUser(): String {
    delay(1000)  // 네트워크 요청 시뮬레이션
    return "홍길동"
}

suspend fun fetchPosts(): List<String> {
    delay(1500)
    return listOf("Post 1", "Post 2")
}

fun main() = runBlocking {
    val user = fetchUser()
    val posts = fetchPosts()
    
    println("사용자: $user")
    println("게시글: $posts")
}

4. 코루틴 스코프

CoroutineScope

val scope = CoroutineScope(Dispatchers.Default)

scope.launch {
    println("백그라운드 작업")
}

// 종료
scope.cancel()

Dispatchers

// Main: UI 스레드 (Android)
launch(Dispatchers.Main) {
    updateUI()
}

// IO: 네트워크, 파일
launch(Dispatchers.IO) {
    fetchData()
}

// Default: CPU 집약적
launch(Dispatchers.Default) {
    heavyComputation()
}

// Unconfined: 제한 없음
launch(Dispatchers.Unconfined) {
    // 작업
}

5. 병렬 실행

async + await

fun main() = runBlocking {
    val time = measureTimeMillis {
        val user = async { fetchUser() }      // 1초
        val posts = async { fetchPosts() }    // 1.5초
        
        println("사용자: ${user.await()}")
        println("게시글: ${posts.await()}")
    }
    
    println("총 시간: ${time}ms")  // 약 1500ms (병렬)
}

6. Flow

기본 Flow

fun numbers(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(100)
        emit(i)
    }
}

fun main() = runBlocking {
    numbers().collect { value ->
        println(value)
    }
}

Flow 연산자

fun main() = runBlocking {
    (1..10).asFlow()
        .filter { it % 2 == 0 }
        .map { it * it }
        .collect { println(it) }
    // 4, 16, 36, 64, 100
}

StateFlow와 SharedFlow

class ViewModel {
    private val _state = MutableStateFlow(0)
    val state: StateFlow<Int> = _state
    
    fun increment() {
        _state.value++
    }
}

fun main() = runBlocking {
    val vm = ViewModel()
    
    launch {
        vm.state.collect { value ->
            println("State: $value")
        }
    }
    
    delay(100)
    vm.increment()
    vm.increment()
    delay(100)
}

7. 실전 예제

예제 1: API 호출

data class User(val id: Int, val name: String)

suspend fun fetchUserFromApi(id: Int): User {
    delay(1000)  // 네트워크 지연
    return User(id, "사용자$id")
}

fun main() = runBlocking {
    val users = (1..5).map { id ->
        async { fetchUserFromApi(id) }
    }.awaitAll()
    
    users.forEach { println(it) }
}

예제 2: 타임아웃

fun main() = runBlocking {
    try {
        withTimeout(1500) {
            repeat(3) {
                println("작업 $it")
                delay(1000)
            }
        }
    } catch (e: TimeoutCancellationException) {
        println("타임아웃!")
    }
}

8. launch와 async: 차이와 선택 기준

항목launchasync
반환Job (결과 없음)Deferred<T> (결과 있음)
예외 전파CoroutineExceptionHandler 또는 부모에 전파await() 시점에 잡히거나 Deferred로 전달
사용 목적부수 효과(로그, UI 갱신, 이벤트)병렬 계산 후 값 조합

선택 기준

  • 결과가 필요 없고 “끝날 때까지 기다리기만” 하면 launch + 필요 시 join().
  • 여러 작업을 동시에 돌리고 마지막에 값을 합치면 async + await() / awaitAll().
  • 같은 스코프에서 async만 쓰고 await를 빼면 예외가 조용히 남을 수 있으므로, 반드시 await 또는 joinAll 등으로 완료·실패를 처리합니다.
fun main() = runBlocking {
    launch { println("fire-and-forget") }

    val a = async { delay(10); 1 }
    val b = async { delay(10); 2 }
    println(a.await() + b.await())  // 병렬 후 합산
}

9. Dispatchers 상세 (Main, IO, Default, Unconfined)

  • Dispatchers.Main: Android에서는 메인(UI) 스레드. 짧은 UI 갱신만. 네트워크·파일·무거운 연산은 여기서 하지 않습니다.
  • Dispatchers.IO: 블로킹 I/O에 맞춘 풀(파일, 소켓, DB). 스레드 수가 더 많을 수 있습니다.
  • Dispatchers.Default: CPU 바운드 작업(정렬, 이미지 처리, 대량 연산). 코어 수에 맞는 크기로 제한됩니다.
  • Dispatchers.Unconfined: 디스패치를 고정하지 않음. 첫 suspend 지점까지는 호출한 스레드에서 실행될 수 있어, 예측이 어렵고 테스트·UI 코드에 섞기 쉽습니다. 특수 목적이 아니면 피하는 편이 안전합니다.
// UI → 백그라운드 → 다시 UI
withContext(Dispatchers.IO) { readFile() }
withContext(Dispatchers.Default) { heavySort(data) }
withContext(Dispatchers.Main) { updateUi() }

10. 구조화된 동시성 (Structured Concurrency)

부모 Job이 취소되면 자식 코루틴도 함께 취소됩니다. coroutineScope { }, supervisorScope { }, ViewModelviewModelScope 등이 이 규칙을 따릅니다.

  • coroutineScope: 자식 중 하나라도 실패하면 나머지를 취소하고 전체 실패로 전파합니다.
  • supervisorScope: 자식 실패가 형제에게 전파되지 않음. UI에서 한 요청만 실패해도 다른 요청은 계속 돌릴 때 사용합니다.
suspend fun loadDashboard() = supervisorScope {
    val profile = async { api.profile() }   // 실패해도
    val feed = async { api.feed() }         // feed는 시도 가능
    runCatching { profile.await() }.getOrNull() to feed.await()
}

11. Flow vs Channel

FlowChannel
모델콜드/핫 스트림 (주로 cold flow { })송신–수신 파이프
다수 소비자SharedFlow, StateFlow로 브로드캐스트여러 소비자는 설계가 더 까다로움
용도이벤트 스트림, UI 상태, 페이지네이션액터 스타일, 워커 큐, 백프레셔가 필요한 파이프

실무 팁: 화면 상태·API 결과 스트림은 Flow가 자연스럽고, 코루틴 간에 “작업 한 건 넘기기”는 Channel을 고려합니다. 둘 다 필요하면 channelFlow { }로 결합하기도 합니다.


12. 실전 에러 처리 (try-catch, CoroutineExceptionHandler)

  • suspend 안에서의 동기적 예외: 일반처럼 **try / catch**로 처리합니다.
  • launch 루트에서의 미처리 예외: 스코프의 **CoroutineExceptionHandler**가 잡거나, 부모로 전파됩니다.
  • async: 예외는 await() 할 때 터집니다. await 전에 취소만 되면 CancellationException이 될 수 있습니다.
val handler = CoroutineExceptionHandler { _, e ->
    println("Unhandled: ${e.message}")
}

fun main() = runBlocking {
    val scope = CoroutineScope(SupervisorJob() + handler)
    scope.launch {
        error("from launch")
    }
    delay(100)
    scope.cancel()
}

async 블록 내부 예외를 기록만 하고 계속하려면 runCatching { } 또는 supervisorScope와 조합합니다.


13. 취소와 타임아웃

  • 협력적 취소: delay, yield, isActive 확인 등이 있어야 빨리 멈춥니다. 블로킹 코드만 있으면 취소가 지연됩니다.
  • withTimeout(time): 시간 초과 시 TimeoutCancellationException(상위는 CancellationException).
  • withTimeoutOrNull: 초과 시 null 반환으로 실패를 단순화.
suspend fun work() = coroutineScope {
    launch {
        while (isActive) {
            delay(100)
            // 취소 시 루프 종료
        }
    }
}

suspend fun fetchOrNull() = withTimeoutOrNull(3000) {
    api.slowCall()
}

I/O는 가능하면 Dispatchers.IO + 블로킹 대신 중단 가능한 API를 쓰면 취소에 유리합니다.


실전 심화 보강

실전 예제: coroutineScope로 부분 실패 수집

여러 비동기 작업을 동시에 시도하되, 하나가 실패해도 나머지 결과를 모으고 싶을 때 supervisorScope와 조합할 수 있습니다. 아래는 표준 라이브러리만으로 성공 문자열과 예외를 분리하는 예입니다.

import kotlinx.coroutines.*

suspend fun mayFail(id: Int): String {
    delay(50)
    if (id % 4 == 0) error("fail on $id")
    return "ok-$id"
}

suspend fun gather(ids: List<Int>): Pair<List<String>, List<Throwable>> = coroutineScope {
    val results = ids.map { id ->
        async {
            runCatching { mayFail(id) }
        }
    }.awaitAll()
    val oks = results.mapNotNull { it.getOrNull() }
    val errs = results.mapNotNull { it.exceptionOrNull() }
    oks to errs
}

fun main() = runBlocking {
    val (oks, errs) = gather((1..12).toList())
    println("성공: $oks")
    println("실패 수: ${errs.size}")
}

자주 하는 실수

  • **GlobalScope.launch**를 남발해 생명주기와 무관한 코루틴이 살아남는 경우.
  • Dispatchers.Main에서 장시간 연산을 수행해 UI가 멈추는 경우.
  • async 반환값을 await하지 않고 결과를 버리는 경우.

주의사항

  • 뷰모델이나 Android 컴포넌트에서는 SupervisorJob + viewModelScope 패턴으로 취소를 맞춥니다.

실무에서는 이렇게

  • I/O는 Dispatchers.IO, CPU 바운드는 **Dispatchers.Default**로 분리합니다.
  • 구조화된 동시성으로 부모가 취소되면 자식도 함께 취소되게 설계합니다.

비교 및 대안

모델장면
코루틴Kotlin 퍼스트, 구조화된 동시성
RxJava레거시 Android
가상 스레드(Java 21+)JVM 서버

추가 리소스


정리

핵심 요약

  1. launch / async: 부수 효과 vs Deferred로 병렬 결과 조합; await 누락 주의
  2. Dispatchers: Main(UI), IO(블로킹 I/O), Default(CPU), Unconfined(특수)
  3. 구조화된 동시성: 부모 취소 시 자식 취소; coroutineScope vs supervisorScope
  4. Flow vs Channel: 스트림·상태는 Flow, 송수신 파이프는 Channel
  5. 에러 처리: try/catch, CoroutineExceptionHandler, asyncawait 시점
  6. 취소·타임아웃: 협력적 취소, withTimeout / withTimeoutOrNull
  7. suspend: 일시 중단 함수
  8. Flow / StateFlow: 비동기 스트림·UI 상태

다음 단계

  • Kotlin Android 개발
  • Kotlin 테스팅
  • Kotlin Spring Boot

관련 글

  • C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]
  • C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
  • C++ 코루틴 |
  • C++ future와 promise |
  • C++ Asio Composed Operation | 비동기 함수 설계 [#7]