Skip to content
ioob.dev
Go back

Kotlin 9편 — 코루틴 기초

· 5분 읽기

Table of contents

비동기가 왜 필요한가

서버에서 데이터를 가져오는 데 2초가 걸린다고 해보자. 그 동안 스레드가 멈춰 있으면 UI가 얼거나, 서버가 다른 요청을 처리하지 못하게 된다. 그래서 비동기 처리가 필요하다.

Java에서는 이걸 Thread, ExecutorService, CompletableFuture, RxJava 같은 도구로 해결했는데, 코드가 복잡해지기 쉽다. 콜백이 중첩되면 이른바 “콜백 지옥”에 빠지고, 에러 처리도 까다롭다.

Kotlin 코루틴은 비동기 코드를 마치 동기 코드처럼 작성할 수 있게 해준다. 그게 핵심이다.

suspend 함수

코루틴의 시작점은 suspend 키워드다. “이 함수는 실행 도중 일시 정지할 수 있다”는 선언이다.

import kotlinx.coroutines.*

suspend fun fetchUser(): String {
    delay(1000)  // 1초 대기 (스레드를 블록하지 않음)
    return "Kim"
}

suspend fun fetchAge(): Int {
    delay(1000)
    return 25
}

fun main() = runBlocking {
    val user = fetchUser()
    val age = fetchAge()
    println("$user, ${age}세")  // Kim, 25세 (약 2초 소요)
}

delay(1000)Thread.sleep(1000)과 비슷해 보이지만, 결정적인 차이가 있다. Thread.sleep은 스레드를 통째로 멈추는 반면, delay는 코루틴만 일시 정지하고 스레드는 다른 일을 할 수 있게 놓아준다. 이 차이가 코루틴의 효율성의 핵심이다.

suspend 함수는 일반 함수에서 직접 호출할 수 없다. 반드시 다른 suspend 함수 안이나 코루틴 빌더(runBlocking, launch, async) 안에서 호출해야 한다.

runBlocking — 코루틴의 관문

runBlocking은 일반 세계와 코루틴 세계를 연결하는 다리다. 현재 스레드를 블록하고, 안쪽의 코루틴이 모두 끝날 때까지 기다린다.

import kotlinx.coroutines.*

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

    delay(500)

    println("끝: ${Thread.currentThread().name}")
}
시작: main
끝: main

main 함수에서 코루틴을 시작할 때 주로 쓴다. 다만 실무에서는 runBlocking을 남발하면 안 된다. 스레드를 블록하니까. 테스트 코드나 main 함수 진입점에서만 쓰는 게 원칙이다.

CoroutineScope

코루틴은 항상 어떤 스코프 안에서 실행된다. 스코프는 코루틴의 생명주기를 관리하는 그릇이다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // runBlocking이 CoroutineScope를 제공

    println("부모 시작")

    // 이 스코프 안에서 시작된 코루틴은 스코프가 끝나면 함께 종료
    coroutineScope {
        launch {
            delay(500)
            println("자식 1 완료")
        }
        launch {
            delay(300)
            println("자식 2 완료")
        }
        println("coroutineScope 안")
    }

    println("부모 끝")
}
부모 시작
coroutineScope 안
자식 2 완료
자식 1 완료
부모 끝

coroutineScope는 안에서 시작된 모든 코루틴이 끝날 때까지 기다린 뒤에야 다음 줄로 넘어간다. 자식 코루틴 하나가 실패하면 나머지도 취소된다. 이것이 **구조화된 동시성(Structured Concurrency)**의 기본 원리다.

코루틴 빌더와 디스패처가 어떻게 엮여서 동작하는지 한눈에 보자.

flowchart TB
    subgraph CoroutineScope
        runBlocking["runBlocking\n(main 스레드 블록)"]

        runBlocking --> launch["launch\n→ Job 반환\n(결과 없음)"]
        runBlocking --> async["async\n→ Deferred 반환\n(결과 있음)"]
    end

    subgraph Dispatchers
        Default["Dispatchers.Default\nCPU 집약적 작업\n(코어 수만큼 스레드)"]
        IO["Dispatchers.IO\n네트워크 / 파일 / DB\n(최대 64 스레드)"]
        Main["Dispatchers.Main\nUI 업데이트\n(메인 스레드)"]
    end

    launch -->|"디스패처 지정"| Default
    launch -->|"디스패처 지정"| IO
    async -->|"디스패처 지정"| Default
    async -->|"디스패처 지정"| IO
    launch -.->|"Android 전용"| Main

launch — “실행하고 잊기”

launch는 새 코루틴을 시작하고 Job을 반환한다. 결과값이 필요 없는 비동기 작업에 쓴다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        println("코루틴 시작: ${Thread.currentThread().name}")
        delay(1000)
        println("코루틴 끝")
    }

    println("launch 호출 직후")
    job.join()  // 코루틴이 끝날 때까지 대기
    println("메인 끝")
}
launch 호출 직후
코루틴 시작: main
코루틴 끝
메인 끝

launch를 호출하면 코루틴이 바로 시작되지만, 호출한 쪽은 기다리지 않고 다음 줄로 넘어간다. 그래서 “launch 호출 직후”가 먼저 출력된다. 코루틴이 끝나기를 기다리고 싶으면 job.join()을 쓴다.

여러 개를 동시에 실행하면 진짜 병렬로 돌아간다.

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

fun main() = runBlocking {
    val time = measureTimeMillis {
        val job1 = launch {
            delay(1000)
            println("작업 1 완료")
        }
        val job2 = launch {
            delay(1000)
            println("작업 2 완료")
        }
        job1.join()
        job2.join()
    }

    println("총 소요 시간: ${time}ms")  // 약 1000ms (2000ms가 아님)
}

각 1초짜리 작업 두 개를 동시에 실행하니, 총 소요 시간은 약 1초다. 순차적으로 실행했다면 2초 걸렸을 것이다.

async/await — 결과가 필요할 때

launch는 결과를 반환하지 않는다. 비동기 작업의 결과가 필요하면 async를 쓴다.

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun fetchUserName(): String {
    delay(1000)
    return "Kim"
}

suspend fun fetchUserAge(): Int {
    delay(1000)
    return 25
}

fun main() = runBlocking {
    // 순차 실행 — 약 2초
    val seqTime = measureTimeMillis {
        val name = fetchUserName()
        val age = fetchUserAge()
        println("순차: $name, ${age}세")
    }
    println("순차 소요: ${seqTime}ms")

    // 병렬 실행 — 약 1초
    val parTime = measureTimeMillis {
        val name = async { fetchUserName() }
        val age = async { fetchUserAge() }
        println("병렬: ${name.await()}, ${age.await()}세")
    }
    println("병렬 소요: ${parTime}ms")
}
순차: Kim, 25세
순차 소요: 2012ms
병렬: Kim, 25세
병렬 소요: 1007ms

asyncDeferred<T>를 반환한다. .await()을 호출하면 결과가 준비될 때까지 일시 정지하고, 준비되면 값을 꺼내준다. 서로 독립적인 두 API를 호출할 때 async로 동시에 요청하면 총 대기 시간을 절반으로 줄일 수 있다.

Dispatchers — 어디서 실행할 것인가

코루틴이 어떤 스레드에서 돌아가는지를 결정하는 게 디스패처다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch(Dispatchers.Default) {
        println("Default: ${Thread.currentThread().name}")
        // CPU 집약적 작업에 적합
    }

    launch(Dispatchers.IO) {
        println("IO: ${Thread.currentThread().name}")
        // 네트워크, 파일, DB 작업에 적합
    }

    launch(Dispatchers.Main) {
        // UI 업데이트에 적합 (Android 환경에서만 사용 가능)
        // println("Main: ${Thread.currentThread().name}")
    }

    launch(Dispatchers.Unconfined) {
        println("Unconfined 시작: ${Thread.currentThread().name}")
        delay(100)
        println("Unconfined 재개: ${Thread.currentThread().name}")
        // 일시 정지 후 재개 시 스레드가 바뀔 수 있음
    }
}

실무에서 자주 쓰는 건 세 가지다.

디스패처용도스레드 풀
Dispatchers.DefaultCPU 집약적 계산CPU 코어 수만큼
Dispatchers.IO네트워크, 파일, DB최대 64개 (또는 코어 수)
Dispatchers.MainUI 업데이트메인 스레드 (Android)

대부분의 경우 Dispatchers.IODispatchers.Default 중 하나를 고르면 된다. 네트워크 호출이면 IO, 대량의 데이터를 정렬하거나 계산하는 거면 Default다.

코루틴 취소

실행 중인 코루틴을 중단해야 할 때가 있다. 사용자가 화면을 떠나거나, 요청이 타임아웃됐거나 하는 상황이다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("작업 중... $i")
            delay(200)
        }
    }

    delay(1000)
    println("취소 요청")
    job.cancel()       // 취소 요청
    job.join()         // 취소 완료까지 대기
    println("취소 완료")
}
작업 중... 0
작업 중... 1
작업 중... 2
작업 중... 3
작업 중... 4
취소 요청
취소 완료

cancel()을 호출하면 코루틴에 취소 신호가 전달된다. 다만 코루틴이 즉시 멈추는 건 아니다. delay, yield, withContext 같은 일시 정지 지점을 만나야 취소가 실제로 처리된다.

CPU 집약적인 루프를 돌고 있어서 일시 정지 지점이 없다면, isActive를 확인해야 한다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        var sum = 0L
        var i = 0
        while (isActive) {  // 취소 상태를 직접 확인
            sum += i++
            if (i % 1_000_000 == 0) {
                println("계산 중... i=$i")
            }
        }
        println("최종 합: $sum")
    }

    delay(100)
    job.cancelAndJoin()
    println("완료")
}

isActive는 현재 코루틴이 취소되지 않았는지를 나타내는 플래그다. 취소 요청이 들어오면 false가 되고, while 루프가 빠져나온다.

타임아웃

특정 시간 안에 끝나지 않으면 자동으로 취소하고 싶을 때는 withTimeout을 쓴다.

import kotlinx.coroutines.*

suspend fun slowOperation(): String {
    delay(3000)
    return "완료"
}

fun main() = runBlocking {
    // 타임아웃 시 예외 발생
    try {
        val result = withTimeout(1000) {
            slowOperation()
        }
        println(result)
    } catch (e: TimeoutCancellationException) {
        println("타임아웃 발생!")
    }

    // 타임아웃 시 null 반환 (예외 없음)
    val result = withTimeoutOrNull(1000) {
        slowOperation()
    }
    println(result ?: "시간 초과")  // 시간 초과
}

withTimeout은 시간 초과 시 TimeoutCancellationException을 던진다. 예외 처리가 귀찮으면 withTimeoutOrNull을 쓰면 되는데, 시간 초과 시 null을 반환해서 ?:와 조합하기 좋다.


코루틴의 기본 도구들을 살펴봤다. suspend로 일시 정지 가능한 함수를 만들고, launchasync로 코루틴을 시작하고, 디스패처로 실행 위치를 정하고, 취소와 타임아웃으로 생명주기를 관리한다.

다음 편에서는 코루틴 심화로 들어간다. Flow, Channel, 에러 처리, 그리고 구조화된 동시성을 깊이 파헤쳐 보자.

10편: 코루틴 심화


Share this post on:

Comments

Loading comments...


Previous Post
Kotlin 10편 — 코루틴 심화
Next Post
Kotlin 8편 — sealed class와 enum