코루틴 완정 정복(1) (feat, Virtual Thread)

시작하며

사실 이전 글 포스팅을 짚어보면, Block VS Reactive(WebFlux) 비교, 가상 스레드(Virtual Thread)와 `synchronized` 까지 다루었었다.

사실 그 이유는 내가 그때까지는 Java를 사용했었기 때문이다...

하지만, 지금은 자연스럽게 코틀린을 사용하고 있고 자연스럽게 관심은 코루틴과 가상스레드의 비교가 너무 하고 싶었다.

그래서 이번 포스팅에서는 JDK 25 기준 Virtual Thread와 Coroutine 을 비교하고 실측까지 해보려고 한다.


개념 정리 먼저!

모두가 알고 있는 내용들이지만 Coroutine에 들어가기 전에 간단한 개념 정리부터 하려고 한다.

`lanuch`/ `async` 같은 구체적인 API로 들어가기 전에, 그 밑바탕이 되는 두 가지부터 짚고 가야한다.

suspend - 코틀린이 제공하는 유일한 문법

`launch`/ 'async`는 라이브러리 함수지만 `suspend`는 진짜 코틀린 언어 키워드이다. `suspend fun`으로 선언하면 컴파일러가 그 함수를 일시 중단하고 나중에 재개할 수 있는 형태로 바꿔준다.

suspend fun fetchUser(id: Long): User {
    delay(100)          // 여기서 "일시 중단"될 수 있다
    return userRepository.findById(id)
}

 

내부적으로는 컴파일러가 이 함수를 어떻게 처리하는지 쉽게 표현하면 아래와 같이 표현할 수 있겠다.

  1. 지금까지 진행한 게임 진행 상태(몇 스테이지, 몇번째 단계)와 갖고 있는 아이템을 세이브 파일에 저장한다.
  2. 게임기를 그냥 끈다.
  3. 나중에 세이브 파일을 불러오면(어떤 PC에서든), 처음부터가 아니라 저장해둔 그 지점부터 정확히 이어서 시작한다.

`suspend fun`이 딱 이렇게 동작한다.

  • 세이브 파일 - 컴파일러가 만든 Continuation 객체로서 그냥 어디서부터 다시 시작해야 하는지 다 적어놓은 메모 한 장이라고 생각하면 된다.
  • 몇 번째 지점까지 왔는지 - 그 메모 안의 진행 위치(`label`이 딱 이 개념이다)
  • 그때까지 모은 아이템 - 그때까지의 지역변수들, 이것도 세이브 파일 안에 같이 적혀 있다.
  • 게임기를 끈다 - 함수가 스레드를 반납하고 그냥 `return`해버리는 것. 게임기(스레드)는 꺼져도 세이브 파일(언속)은 안 사라진다.
  • 어떤 PC에서든 - `delay`가 끝났을 때, 어떤 스레드라도 그 세이브 파일을 들고 이어서 재개할 수 있다.(곡 처음 그 스레드일 필요가 없다.)

즉, `delay(100)` 같은 지점(suspension point)에서 함수는 멈춰서 기다리는게 아니라 세이브하고 스레드를 완전히 놓아준 뒤 나중에이어하는걸로 이해하면 된다. 반면에 `Thread.sleep()`은 세이브 파일을 만들지 않는다. 따라서 게임기를 켠 채로 일시정지 화면만 띄워놓고 계속 붙잡고 있는 것과 같아서 그동안 그 스레드로는 아무것도 못 한다.

 

suspend 디컴파일

실제 코드에서 decomile 한 결과를 보면 좀더 자세히 확인 할 수 잇다.

suspend fun fetchUser(id: Long): String {
    val token = fetchToken(id)   // suspend 호출 1
    delay(100)                   // suspend 호출 2
    return "user-$id-$token"
}

 

static Object fetchUser$suspendImpl(BasicsService $this, long id, Continuation $completion) {
    label27: {
        // 넘어온 게 이미 이 함수의 상태 머신이면(=재개), 그걸 그대로 쓴다
        if ($completion instanceof <fetchUser$1> $continuation) {
            // label의 최상위 비트가 켜져 있으면 "재개 중"이라는 표시 → 비트 끄고 진행
            if (($continuation.label & Integer.MIN_VALUE) != 0) {
                $continuation.label -= Integer.MIN_VALUE;
                break label27;
            }
        }
        // 최초 진입이면 세이브 파일(상태 머신)을 새로 만든다
        $continuation = new ContinuationImpl($this, $completion) {
            long J$0;        // id 저장 슬롯 (J = long)
            Object L$0;      // token 저장 슬롯 (L = Object)
            Object result;   // 재개될 때 받은 결과
            int label;       // 몇 번째 지점까지 왔는지

            public final Object invokeSuspend(Object $result) {
                this.result = $result;
                this.label |= Integer.MIN_VALUE;   // "재개 중" 비트를 켜고
                return BasicsService.fetchUser$suspendImpl($this, 0L, this);  // 자기 자신을 넘겨 재호출
            }
        };
    }

    Object $result = $continuation.result;
    Object var7 = IntrinsicsKt.getCOROUTINE_SUSPENDED();   // = SUSPENDED
    String token;
    Object var10000;
    switch ($continuation.label) {
        case 0:
            ResultKt.throwOnFailure($result);
            $continuation.J$0 = id;              // id를 세이브 파일에 저장
            $continuation.label = 1;
            var10000 = $this.fetchToken(id, $continuation);  // 세이브 파일을 콜백으로 넘김
            if (var10000 == var7) return var7;   // 멈추면 그냥 리턴(게임기 끄기)
            break;
        case 1:
            id = $continuation.J$0;              // 재개: 저장해둔 id 복원
            ResultKt.throwOnFailure($result);
            var10000 = $result;
            break;
        case 2:
            id = $continuation.J$0;              // 재개: id, token 둘 다 복원
            token = (String) $continuation.L$0;
            ResultKt.throwOnFailure($result);
            return "user-" + id + "-" + token;   // 최종 결과
        default:
            throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
    }

    // case 0 또는 1을 통과한 뒤 공통으로 실행되는 부분 (두 번째 suspend 호출)
    token = (String) var10000;
    $continuation.L$0 = token;                   // token을 세이브 파일에 저장
    $continuation.J$0 = id;
    $continuation.label = 2;
    if (DelayKt.delay(100L, $continuation) == var7) {
        return var7;                             // delay가 멈추면 리턴
    } else {
        return "user-" + id + "-" + token;       // 안 멈췄으면 바로 결과
    }
}

 

조금 복잡해보이지만, 앞서 세이브 파일 비유로 짚은 뼈대는 그대로다.

  1. `suspend fun`은 파라미터가 하나 더 있다. - `Continuation $completion`. 원본 `suspend fun fetchUser(id: Long): String`이 `Object fetchUser(long, Continuation)`으로, 파라미터에 continuation이 붙고 반환 타입도 `Object`로 바뀐 게 그대로 보인다. 이게 세이브파일이라고 할 수 있다.
  2. 함수 몸통이 `switch(label)`로 쪼개진다.  - `label`이 진행 위치, 지역변수(id, token)는 상태 머신 필드의 (`J$0`, `L$0`)로 옮겨진다.  스택이 아니라 힙에 사는 세이브 파일 안에 저장된다.

나중에 `delay(100)`이 끝나면 스케줄러가 세이브 파일의 `invokeSuspend(result)`를 호출하고 그안에서 `fetchUser$suspendImpl`이 자기 자신(`this`)을 넘겨 재호출된다. `switch`가 case2로 점프해 저장해준 id/token으로 결과를 만든다. 이때 재개 스레드는 처음 멈춘 스레드와 달라도 상관 없다. (상태가 스택이 아니라 힙 객체로 들고 다닌다.)

 

가지 제약이 있다`suspend fun` 다른 `suspend fun` 안에서나, 코루틴 빌더(`launch`/`async`/`runBlocking`) 안에서만 호출할 있다.

 일반 함수에서 그냥 호출은 된다(컴파일 에러). 이게 앞서 Virtual Thread 비교할 언급했던 suspend 전염(coloring)이며,  suspend 함수를 하나 쓰기 시작하면 그걸 호출하는 함수도 suspend 돼야 하고, 위도 계속 그래야 하는 현상이다.

 

그래서 코루틴이 뭔데?

여기까지 `suspend`(멈췄다 재개할 수 있는 함수)만 봤는데, 그럼 코루틴이 뭘까?

  • suspend 함수 - 멈췄다 이어갈 수 있는 함수라는 설계도이며, 그냥 선언만으로는 아무것도 실행되지 않는다.
  • coroutine(코루틴) - 그 suspend 함수를 실제로 실행하는 하나의 작업 단위를 말한다. 스레드와 비슷하지만 훨씬 가볍고 스레드를 붙잡지 않고 멈출 수 있다. 마치 Virtual Trhead 처럼!
  • 코루틴 빌더 - 코루틴을 실제로 만들어서 시작시키는 함수를 말한다. 바로 `launch`, `async`, `runBlocking`이다.

coroutineScope - 구조화된 동시성

coroutineScope는 여러 코루틴을 하나로 묶어서 관리하는 울타리정도 될 수 있다.

코루틴을 여러 개 동시에 띄우려면 아래와 같은 문제가 발생할 수 있다.

  • 그중 하나가 실패하면 나머지는 어떻게 될까?(계속 진행할까? 멈춰야할까?)
  • 모든 코루틴이 다 끝났다는걸 어떻게 알 수 있을까? 
  • 하나라도 잘못되면 나머지 자원을 정리해야하는데 누가 해야할까?

`coroutineScope`는 문법 차원에서 강제로 해결한다. coroutineScope{ ... } 블록으로 감싸면, 그 안에서 시작한 모든 코루틴이 이 울타리에 묶여서 :

블록이 끝나려면 안의 코루틴이 전부 끝나야한다.

  • 하나가 실패하면 나머지도 자동으로 취소된다.
  • 블록을 벗어난 코루틴은 존재할 수 없다.

이렇게 코루틴의 생명주기를 코드 블록 범위에 묶는 걸  Structured Concurrency 라고 부르고, coroutineScope가 그 역할을 수행한다.

사실, coroutineScope도 suspend 함수다.

public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }
}

 

  • 새로운 coroutineScope를 만든다. - Job 만큼은 새로운 자식으로 만든다.(그래서 스코프 안은 바깥과 같은 스레드 설정에서 돌지만, 취소/실패는 이 스코프 울타리 안에서 관리된다.)
    • Job은 코루틴 하나의 생명주기를 연결하는 손잡이
  • 그 스코프 안에서 `launch`/`async` 로 시직한 자식 코루틴들이 전부 끝날 때 까지 자신도 끝나지 않고 기다린다.
  • 자식 중 하나라도 실패하면 나머지 자식들도 전부 취소하고, 그 예외를 `coroutineScope` 자신의 예외로 던진다. 반대로 `supervisorScope`는 자식이 실패해도 형제를 취소하진 않는다.

launch vs async 

suspend fun launchVsAsync(): Map<String, Any?> {
    var launchSideEffectRan = false
    val launchElapsed = measureTimeMillis {
        coroutineScope {
            launch {
                delay(100)
                launchSideEffectRan = true
            }
        }
    }

    var asyncResult = 0
    val asyncElapsed = measureTimeMillis {
        coroutineScope {
            val deferred = async {
                delay(100)
                42
            }
            asyncResult = deferred.await()
        }
    }

    return mapOf(
        "launch" to mapOf("elapsedMs" to launchElapsed, "sideEffectRan" to launchSideEffectRan),
        "async" to mapOf("elapsedMs" to asyncElapsed, "result" to asyncResult),
    )
}

 

실제 호출 결과

{"launch":{"elapsedMs":108,"sideEffectRan":true},"async":{"elapsedMs":101,"result":42}}

 

`launch` 결과가 필요 없는 fire-and-forget(그래도 `coroutineScope` 끝날 때까지는 기다려준다), `async` `await()` 결과를 직접 받아야 하는 경우에 쓴다. `coroutineScope` 안에서 자식이 끝날 때까지는 스코프가 종료되지 않는다이게 바로 다음에 나올 구조화된 동시성이다.

 

Dispatcher와 Spring에서 스레드

Dispatcher는 이 코루틴이 어느 스레드(풀)에서 실행할지 정하는 담당자이다.

  • Dispatcher.Default - CPU를 많이 사용하는 계산 작업 (대략 CPU 코어 수)
  • Dispatcher.IO - 블로킹 IO(파일, 네트워크, JDBC 작업(기본 64)
  • 커스텀(Executor.asCoroutineDispatcher()) - 커스텀 스레드풀(정한 만큼)

추가로 Dispatcher.Default와 Dispatcher.IO는 같은 스레드풀을 공유하고 동시성 한도만 다르다. (약 210만개 물리적 스레드풀 개수)

그렇다면 특정 코루틴이 어디서 실행되는지도 정리할 수 있다.

1. 명시하면 그게 최우선 — `launch(Dispatchers.IO) { }`, `withContext(Dispatchers.Default) { }`처럼 직접 넘기면 그 Dispatcher의 풀에서 돈다.

2. 안 넘기면 부모를 물려받는다 — 아무것도 안 주면, 지금 실행 중인 바깥 코루틴의 Dispatcher를 그대로 상속한다. (앞서 `coroutineScope`가 "바깥 컨텍스트를 물려받는다"고 한 게 이거다.)

3. 최상단이면 시작 방식의 기본값 — `runBlocking` 자기를 호출한 스레드, `GlobalScope.launch` `Dispatchers.Default`, Spring suspend 컨트롤러는 프레임워크가 정한 .

 

그렇다면

Spring MVC의 suspend  컨트롤러는 어떻게 처리 될까?

 

실제로 spring-web `CoroutinesUtils` 구현을 뜯어보면, Spring suspend 컨트롤러를 `Dispatchers.Unconfined` 시작한다.

`Unconfined` 자기 전용 스레드 풀이 없는 특수한 디스패처라, 지금 호출한 스레드에서 그냥 시작하고, suspension 뒤엔 재개시킨 스레드에서 이어간다.

[요청 도착]
   ↓
① Tomcat 스레드 풀 (http-nio-8081-exec-N)   ← 요청 받고 코루틴 시작
   ↓  (코루틴 안에서 withContext(Dispatchers.IO) 등을 만나면)
② Dispatcher가 정한 풀 (DefaultDispatcher-worker-N 등)  ← 실제 작업 실행

 

- 요청 받는 스레드 = Tomcat 스레드 풀 → `server.tomcat.threads.max`(기본 200)로 조정

코루틴 작업 스레드 = 코드에서 `withContext(Dispatcher)` 지정한 → Dispatcher(커스텀이면 크기) 조정

 

Spring 코루틴용 스레드 풀을 따로 만들지 않는다(`Unconfined`) 그래서 코루틴이 도는 조정하고 싶으면 Spring 설정이 아니라, 코드에서 직접 `withContext` Dispatcher 지정하고 그걸 조정해야 한다. 

 

만약 컨트롤러부터 아무도 Dispatcher를 만나지 않는다면?

 

컨트롤러도, 그 안의 서비스도 `withContext`를 안 붙이면 그 코루틴은 처음부터 끝까지 `undefined`로 흐른다.

그럼 실제로 어떤 스레드에서 돌까?

코루틴 안에서 만나는것 그 뒤 도는 스레드
suspension이 아예 없음(시작한 스레드에서 쭉 끝남) Tomcat 스레드에서 시작부터 끝까지
withContext(Dispatcher)를 만남 그 지점부터 해당 Dispatcher 스레드
Dispatcher 없이 delay 등 논블로킹 대기만 만남 kotlinx 내부 스케줄러 스레드(kotlinx.coroutines.DefaultExecutor)에서 재개될 수

 

번째 "suspension 아예 없음" — 이게 헷갈리는데, suspend 붙였다고 항상 멈추는 아니다. suspend "멈출 있는 능력" 뿐이고, 실행 중에 실제로 기다릴 (delay/await/논블로킹 IO) 번도 만나면 그냥 일반 함수처럼 시작한 스레드에서 돌다가 끝난다. 그게 여기 Tomcat 스레드다.

 

근데 suspension 없음 사실 정반대인 상황을 뭉뚱그린다:

// 기다릴 게 없어서 빨리 끝남 → 안전
suspend fun add(a: Int, b: Int): Int = a + b   // Tomcat 스레드 잠깐 쓰고 바로 반납

// 블로킹으로 스레드를 오래 붙잡음 → 위험 (블로킹은 suspension이 아니다!)
suspend fun getUser(): User {
    Thread.sleep(1000)                  // Tomcat 스레드를 1초 통째로 점유
    return jdbcRepository.findById(id)  // JDBC 블로킹도 마찬가지
}

 

따라서, undefined인데 블로킹 호출이면 Tomcat 워커 스레드를 통째로 붙잡기 때문에 코루틴을 쓰는 이유가 사라지므로 유의해야한다.

 

Virtual Thread VS Coroutine

실제로 Virtual Thread와 Coroutine은 굉장히 많은 비교가 된다.

(Virtual Thread는 JDK 기능이므로 코틀린에서 사용 할 수 있다.)

사실 2개 모두 해결하고 있는 문제는 동일하다. 

블로킹 IO 대기 중 스레드 낭비를 어떻게 해결할까?

 

  • Virtual Thread - JVM이 알아서 스레드 언마운트/마운트를 통해 재사용하도록 한다
  • Coroutine - 컴파일러가 suspend 함수를 상태 머신으로 바꿔서 스레드를 점유하지 않는다.

2개의 성능은 사실 어느정도 많이 관측됐으나 나도 한번 해보았다.

성능 테스트

 

 

blocking 함수 VS virtual Thread 함수 VS coroutine 함수를 비교해보았다.

방식 클라이언트측 평균 응답 대기 시간 P95 처리량
blocking 824ms 885ms 949 req/s
virtual-thread 208ms 223ms 3,345 req/s
coroutine 208ms 220ms 3,338 req/s

 

예상대로 blocking은 처리량은 낮고, 응답 대기 시간을 길었고 Virtual Thread와 coroutine 사이에 차이는 없었다.

 

어떤걸 선택해야할까?

실측 결과에서 성능은 비슷했다. 그럼 성능이 아니라 다른 기준으로 판단해야할것같다.

 

Virtual Thread

  • 기존 블로킹 코드가 많다 
    • JDBC, 레거시 블로킹 HTTP 클라이언트 등, 코드 한 줄도 안고치고 `spring.thread.virtual.enabled=true` 하나로 스레드 효율을 얻을 수 있다.
  • suspend 전염
    • suspend 함수는 suspend 함수안에서만 호출이 가능하기 때문에 코루틴 도입은 호출 체인 전체(컨트롤러 -> 서비스 -> 리포지토리)를 다시 써야한다.

 

Coroutine

  • 구조화된 동시성이 필요하다
    • coroutineScope와 supervisorScope처럼 하나가 실패하면 나머진 확실히 취소하고 혹은 반대로 서로 독립적으로 두고 싶을때 사용해야한다. Virtual Thread에서는 이런 기능을 제공하지 않는다.
  • Flow 같은 스트림 처리, 명시적취소
    • 단순히 스레드 효율화를 넘어 풍부한 동시성 프로그래밍 모델 자체가 목적이라면 코루틴이 훨씬 더 다양한 문법을 지원한다.