Kotlin 코루틴 (1/5)

다음 편: [Kotlin] 2. CoroutineScope, Context, Dispatcher

Kotlin 코루틴 시리즈 (1/5)

다음 편: 2 CoroutineScope, Context, Dispatcher

코루틴은 Kotlin의 비동기 프로그래밍 솔루션이다. Java의 Thread, CompletableFuture, RxJava와 같은 목적을 가지지만, 접근 방식이 근본적으로 다르다. 비동기 코드를 동기 코드처럼 작성할 수 있게 해준다.

Spring MVC + 블로그 프로젝트에서 코루틴을 직접 쓸 일은 적을 수 있다. 하지만 Kotlin 생태계에서 코루틴은 핵심 기술이고, 라이브러리들이 점점 코루틴을 기반으로 설계되고 있다. 원리를 이해하지 못하면 Kotlin을 깊이 있게 다루기 어렵다.

이 문서에서는 코루틴의 가장 기본 단위인 suspend 함수가 내부적으로 어떻게 동작하는지 파고든다.

코루틴이 필요한 이유

네트워크 요청, 파일 I/O, DB 쿼리 같은 작업은 시간이 걸린다. 이 시간 동안 스레드는 아무것도 하지 않고 대기한다. 스레드가 비싼 자원이라는 점을 생각하면, 이건 낭비다.

Java에서 이 문제를 해결하려면 여러 방법이 있다.

  • Thread — 직접 스레드를 만든다. 비용이 크고 관리가 어렵다.
  • ExecutorService — 스레드풀을 쓴다. 여전히 대기 중에 스레드를 점유한다.
  • CompletableFuture — 콜백 기반. 체이닝이 복잡해지면 읽기 어렵다.
  • RxJava — 리액티브 스트림. 강력하지만 학습 곡선이 가파르다.

코루틴은 이 문제를 다른 관점에서 해결한다. 대기 중에 스레드를 점유하지 않으면서도, 코드는 동기 코드처럼 읽힌다.

// 코루틴 — 비동기인데 동기처럼 보인다
suspend fun fetchUserProfile(userId: Long): UserProfile {
    val user = userService.findById(userId)         // 대기 (스레드 반환)
    val posts = postService.findByUserId(userId)    // 대기 (스레드 반환)
    return UserProfile(user, posts)
}

콜백도 없고, thenApply도 없고, subscribe도 없다. 그냥 위에서 아래로 읽히는 일반 코드다. 하지만 각 findById, findByUserId 호출에서 스레드는 반환되고, 결과가 오면 다시 이어서 실행된다.


suspend 키워드의 의미

suspend는 "이 함수는 일시 중단될 수 있다"는 선언이다. 함수 실행 중간에 잠시 멈추고, 나중에 결과가 준비되면 이어서 실행될 수 있다는 뜻이다.

suspend fun fetchData(): String {
    delay(1000)  // 1초 대기 — 스레드를 블로킹하지 않는다
    return "데이터"
}

delay(1000)Thread.sleep(1000)과 다르다.

  • Thread.sleep — 스레드가 1초 동안 잠든다. 그 동안 아무것도 못 한다.
  • delay — 코루틴이 1초 동안 중단된다. 스레드는 반환되어 다른 일을 할 수 있다.

비유하자면, 식당에서 주문 후 기다리는 두 가지 방식이다.

  • Thread.sleep — 자리에 앉아서 음식이 나올 때까지 꼼짝 안 한다 (스레드 블로킹).
  • delay — 주문 번호표를 받고 다른 일을 하다가, 번호가 호출되면 돌아온다 (스레드 반환 후 재개).

CPS 변환 — suspend의 내부 동작

suspend 함수는 컴파일 시 CPS(Continuation Passing Style) 변환을 거친다. 겉으로는 일반 함수처럼 보이지만, 컴파일러가 내부적으로 구조를 바꾼다.

// 우리가 작성하는 코드
suspend fun fetchData(): String {
    val a = stepA()       // 중단점 1
    val b = stepB(a)      // 중단점 2
    return "$a + $b"
}

컴파일러가 이 함수를 대략 이런 형태로 변환한다(개념적).

// 컴파일러가 변환한 코드 (개념적)
fun fetchData(continuation: Continuation<String>): Any? {
    when (continuation.label) {
        0 -> {
            continuation.label = 1
            val result = stepA(continuation)  // 중단되면 COROUTINE_SUSPENDED 반환
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            // 중단 안 됐으면 계속 진행
        }
        1 -> {
            val a = continuation.result       // stepA의 결과
            continuation.label = 2
            val result = stepB(a, continuation)
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
        }
        2 -> {
            val b = continuation.result       // stepB의 결과
            return "$a + $b"
        }
    }
}

핵심을 짚으면 이렇다.

  • Continuation — "이 함수를 다음에 어디서부터 이어서 실행할지"를 담은 객체. 콜백과 비슷하지만 컴파일러가 자동으로 관리한다.
  • label — 함수 내의 중단점을 나타내는 상태. when으로 분기해서 이어서 실행한다.
  • COROUTINE_SUSPENDED — "이 함수가 중단됐다"는 신호.

함수가 중단되면 COROUTINE_SUSPENDED를 반환하고 스레드를 놓아준다. 나중에 결과가 준비되면 ContinuationresumeWith()가 호출되고, label에 따라 이전에 멈춘 지점부터 다시 실행된다.

우리가 쓰는 suspend 함수는 사실 상태 머신이다. 컴파일러가 자동으로 상태 머신 코드를 생성해주기 때문에, 우리는 동기 코드처럼 작성할 수 있는 것이다.


Continuation 인터페이스

Continuation은 코루틴의 핵심 인터페이스다.

public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}
  • context — 코루틴이 실행되는 맥락 정보 (Dispatcher, Job 등). 이건 다음 문서에서 다룬다.
  • resumeWith — 중단된 코루틴을 재개한다. 성공이면 값을, 실패면 예외를 전달한다.

비유하자면 Continuation"여기서 멈췄으니, 나중에 이 번호로 다시 연락해줘"라는 콜백 카드다. 비동기 작업이 완료되면 이 카드의 resumeWith를 호출해서 코루틴을 깨운다.


코루틴 vs 스레드

코루틴과 스레드의 근본적인 차이를 이해하는 것이 중요하다.

graph LR subgraph "스레드 모델" T1[Thread 1] --> B1[작업 A
대기 중...] T2[Thread 2] --> B2[작업 B
대기 중...] end subgraph "코루틴 모델" T3[Thread 1] --> C1[작업 A 시작] C1 --> C2[작업 A 중단 → 작업 B 시작] C2 --> C3[작업 B 중단 → 작업 A 재개] end
  • 스레드 — 하나의 작업이 하나의 스레드를 점유한다. 대기 중에도 스레드가 블로킹된다.
  • 코루틴 — 하나의 스레드에서 여러 코루틴이 번갈아 실행된다. 대기 중에 스레드를 반환한다.

코루틴은 경량이다. 수만 개의 코루틴을 만들어도 스레드 수만 개를 만드는 것과 비교하면 메모리 부담이 거의 없다. 코루틴은 힙 메모리에 상태를 저장하는 객체이고, 스레드는 OS 레벨의 자원이기 때문이다.

// 10만 개의 코루틴 — 문제없다
repeat(100_000) {
    launch {
        delay(1000)
        println("코루틴 $it")
    }
}

// 10만 개의 스레드 — OutOfMemoryError
repeat(100_000) {
    Thread {
        Thread.sleep(1000)
        println("스레드 $it")
    }.start()
}

심화 분석

suspend 함수의 호출 규칙

suspend 함수는 아무 데서나 호출할 수 없다. 다른 suspend 함수 안에서이거나, 코루틴 빌더(launch, async 등) 안에서만 호출할 수 있다.

// 일반 함수에서 suspend 함수 호출 — 컴파일 에러
fun normalFunction() {
    // delay(1000)  // Suspend function 'delay' should be called only from a coroutine or another suspend function
}

// suspend 함수에서 호출 — OK
suspend fun suspendFunction() {
    delay(1000)
}

// 코루틴 빌더에서 호출 — OK
fun main() = runBlocking {
    delay(1000)
}

이 제약이 있는 이유는 suspend 함수를 호출하려면 Continuation 객체가 필요하기 때문이다. 코루틴 빌더가 최초의 Continuation을 만들어주고, 이후의 suspend 함수들은 그 Continuation을 체인으로 전달한다.

코루틴과 Java의 Virtual Thread

Java 21에서 도입된 Virtual Thread(가상 스레드)는 코루틴과 비슷한 문제를 해결한다. 둘 다 "대기 중에 OS 스레드를 점유하지 않는다"는 핵심 아이디어가 같다.

차이는 접근 방식이다.

  • Virtual Thread — 기존 블로킹 코드(Thread.sleep, JDBC 호출 등)를 그대로 쓰되, JVM이 자동으로 경량 스레드로 처리한다.
  • 코루틴suspend 키워드로 중단 가능한 코드를 명시적으로 표시한다. 더 많은 제어(취소, 구조화된 동시성 등)가 가능하다.

Spring MVC + Kotlin 조합에서는 Virtual Thread가 더 자연스러울 수 있다. 하지만 Flow, 구조화된 동시성 등 코루틴만의 강력한 기능은 Virtual Thread에 없다.


자주 하는 실수

suspend 함수 안에서 블로킹 호출

suspend fun readFile(path: String): String {
    // 나쁜 예 — 블로킹 I/O를 suspend 함수 안에서 직접 호출
    return File(path).readText()  // 스레드를 블로킹한다!
}

suspend라고 선언했다고 자동으로 비블로킹이 되는 게 아니다. suspend는 "이 함수가 중단될 수 있다"는 선언이지, "이 함수가 스레드를 블로킹하지 않는다"는 보장이 아니다.

블로킹 I/O를 써야 하면 적절한 Dispatcher로 감싸야 한다. 이건 다음 문서에서 다룬다.

suspend를 붙일 필요가 없는 함수에 붙이기

// 불필요한 suspend — 내부에서 suspend 함수를 호출하지 않는다
suspend fun add(a: Int, b: Int) = a + b

suspend를 붙이면 일반 함수에서 호출할 수 없게 되므로, 사용처가 제한된다. 내부에서 다른 suspend 함수를 호출하지 않는다면 suspend를 붙일 이유가 없다.