코루틴과 Virtual Thread 비교와 사용

코루틴과 Virtual Thread 비교와 사용

요약: 코루틴과 Virtual Thread는 애플리케이션에서 동시성 처리 성능을 향상시키기 위한 핵심 기술입니다. 두 기술은 각자의 장단점을 가지고 있으며, 개발 환경과 프로젝트 특성에 따라 적절한 기술을 선택하여 사용할 수 있습니다. 이번 글에서 웨인은 코루틴과 Virtual Thread의 개념, 특징, 사용 예시, 성능 비교를 통해 두 기술의 차이점과 장단점을 상세히 설명하고, 실제 개발 환경에서 어떤 기술을 선택해야 할지에 대한 고민 과정을 공유합니다.

💡 리뷰어 한줄평

justin.bieber 비슷해 보이는 Virtual Thread와 Coroutine! 어떻게 차이가 날까요? 평소에 궁금증을 가지고 계셨던 분들에게 이 글을 추천드립니다!

cdragon.cd 처리량에 대한 고민은 서버 개발자라면 피할 수 없는 부분입니다. 코루틴과 버추얼 스레드는 어떤 차이가 있고, 어떤 선택을 해야 할까요?

시작하며

안녕하세요, 카카오페이 마이데이터클랜에서 서버 개발을 담당하고 있는 웨인입니다. 이번 글에서는 코루틴과 Virtual Thread를 소개 및 비교하고 간단한 예제들을 통하여 두 기술을 사용하는 법에 대하여 알아보겠습니다. 또한 어떤 걸 선택하면 좋을지 고민한 과정을 공유하겠습니다.

동시성 처리의 중요성

현대 API 시스템에서는 빠른 응답과 높은 처리량을 유지하는 것이 필수적입니다. 동시성 처리는 사용자 경험을 좌우하는 중요한 요소로 지연 시간을 최소화하고 시스템 자원을 효율적으로 사용하는 데 크게 기여합니다. 이는 특히 대규모 네트워크 요청, 데이터베이스 트랜잭션, CPU 연산이 동시에 이루어지는 복잡한 시스템에서 더 중요합니다. 동시성 처리를 통해 애플리케이션은 네트워크 지연이나 대용량 데이터를 처리하면서도 다른 작업이 중단되지 않고 효율적으로 순차적으로 처리될 수 있도록 도와줍니다.

기존의 접근법과 문제점

전통적으로 동시성 처리는 운영체제의 스레드를 통해 이루어졌습니다. 각 스레드는 독립적으로 작업을 수행하며 운영체제가 이들을 스케줄링하여 CPU에서 실행합니다. 그러나 이러한 방식은 몇 가지 단점이 있습니다.

  • 고비용의 스레드 생성과 관리: 많은 스레드를 생성하고 관리하는 것은 비용이 많이 듭니다.
  • 복잡한 동기화 처리: 여러 스레드가 자원을 공유할 때 동기화 문제가 발생하며 이로 인해 코드가 복잡해집니다.
  • 한정된 스레드 풀: 시스템에서 사용할 수 있는 스레드의 수가 한정적이어서 많은 동시 요청을 처리하기 어렵습니다.
  • I/O 대기 문제: 블로킹 I/O 작업으로 인해 스레드가 대기 상태에 있을 때 다른 작업이 진행되지 않아 성능 저하가 발생합니다.

이러한 문제를 해결하기 위해 비동기 프로그래밍과 이벤트 루프 기반 모델이 발전했습니다. 그러나 이러한 모델들도 콜백 지옥이나 가독성이 떨어지는 코드로 인해 개발자들은 여러 가지 어려움을 겪었습니다.

코루틴과 Virtual Thread 소개

이러한 전통적인 동시성 처리 방식의 한계를 극복하기 위해 코루틴과 Virtual Thread와 같은 새로운 기술들이 등장했습니다.

코루틴(Coroutines)

코루틴은 코틀린에서 제공하는 기능으로 비동기 작업을 효율적으로 처리하기 위해 설계된 경량화된 동시성 처리 방식입니다.

  • 작업을 일시 중단(suspend)하고 나중에 재개할 수 있으며 매우 적은 자원을 사용합니다.
  • I/O 작업이 많은 시스템에서 특히 효과적이며 자원 절약 측면에서 매우 유용합니다.
  • 협력적 멀티태스킹을 지원하여 코루틴 자체가 자발적으로 작업을 일시 중단하고 다른 작업에 CPU 점유를 넘기게 됩니다.

Virtual Thread

Virtual Thread는 자바 진영에서 도입된 기술로 기존의 스레드 모델을 경량화한 형태입니다.

  • JVM 레벨에서 관리되며 운영체제의 스레드보다 훨씬 가볍습니다.
  • 수십만 개의 스레드를 생성할 수 있으며 I/O 작업 시 효율적으로 대기 상태에 들어가 시스템 자원을 적게 소모합니다.
  • 기존 자바 코드와 동일한 API를 사용하기 때문에 기존 코드에 큰 수정 없이 적용 가능합니다.

코루틴과 Virtual Thread 비교

Virtual Thread코루틴
경량성가볍게 생성 가능가볍게 생성 가능
코드기존의 자바 코드를 거의 수정하지 않고 사용할 수 있음비교적 쉽게 작성 가능하지만 정확한 실행을 위해서는 러닝 커브가 있음
디버깅기존 디버깅 방식과 동일하게 사용 가능복잡한 비동기 작업에서 디버깅이 상대적으로 어려울 수 있음
스레드풀JVM이 직접 효율적으로 관리하기 때문에 개발자가 관리할 필요가 없음스레드풀을 개발자가 직접 관리해야 함

코루틴과 Virtual Thread 예제

코루틴(Coroutines)

코틀린에서 코루틴은 비동기 작업을 동기식 코드처럼 작성할 수 있도록 설계되었습니다. 기본적으로 suspend 함수를 사용하여 비동기 작업을 정의하고, 코루틴 빌더(launch, async 등)를 통해 병렬 작업을 실행할 수 있습니다.

아래 예제는 기본적인 코루틴 사용 예제입니다. 여기서 sleep 대신에 delay를 사용했다면 모두가 생각하는 대로 기다림 없이 (혹은 아주 약간의 기다림으로) 100개의 List가 전부 add 되어 끝날 것입니다. 하지만 코루틴에서 스레드 블록인 sleep을 사용했기 때문에 여기서는 100ms씩 기다리게 됩니다. 따라서 이 예제는 적절하지 않습니다.

    @Test
    fun coroutineTest(): Unit = runBlocking {
        val results = mutableListOf<Int>()
        val jobs = List(100) {
            launch {
                sleep(100L)
                results.add(it)
                println("Adding $it")
            }
        }
        jobs.forEach { it.join() }
        assertEquals(100, results.size)
    }

아래 코드는 스레드 블록을 대비하는 코드입니다. withContext 자체는 병렬성을 제공하지 않지만 디스패처를 변경하여 서로 다른 스레드에서 병렬 처리를 가능하게 합니다. 따라서 스레드 블록이 되는 상황에서는 이 코드가 적절합니다.

    @Test
    fun coroutineParallelTest(): Unit = runBlocking {
        val results = mutableListOf<Int>()
        val jobs = List(100) {
            launch {
                withContext(Dispatchers.IO) {
                    sleep(100L)
                    synchronized(results) {
                        results.add(it)
                    }
                    println("Adding $it")
                }
            }
        }
        jobs.forEach { it.join() }
        assertEquals(100, results.size)
    }

Virtual Thread

Virtual Thread의 스레드풀 사용은 기존의 스레드풀 생성과 비슷합니다. Executors.newVirtualThreadPerTaskExecutor() 해당 선언에 의해서 Virtual Thread를 사용하게 됩니다.

아래 코드에서 Virtual Thread는 sleep(100L)의 스레드 중단에 대응하여 다른 Virtual Thread로 전환되므로 추가적인 병렬 처리 로직을 구현하지 않아도 자연스럽게 병렬 처리가 이루어집니다.

  @Test
    fun virtualThreadTest() {
        val results = mutableListOf<Int>()
        val executor = Executors.newVirtualThreadPerTaskExecutor()
        val jobs = List(100) {
            executor.submit {
                sleep(100L)
                synchronized(results) {
                    results.add(it)
                }
                sleep(100L)
                println("Adding $it")
            }
        }
        jobs.forEach { it.get() }
        assertEquals(100, results.size)
        executor.shutdown()
    }

다음 코드에서 StructuredTaskScope는 구조화된 동시성 개념을 Virtual Thread와 함께 구현한 것으로 동시 실행되는 작업들을 체계적으로 관리하고 그 생명주기를 명확하게 해 줍니다.

    @Test
    fun structuredTest() {
        val results = mutableListOf<Int>()

        try {
            StructuredTaskScope.ShutdownOnFailure().use { scope ->
                val jobs = List(100) {
                    scope.fork {
                        sleep(1000L)
                        if (it % 3 == 0) {
                            throw ClassNotFoundException()
                        }
                        synchronized(results) {
                            results.add(it)
                        }
                        println("Adding $it")
                    }
                }
                scope.join()
                scope.throwIfFailed()
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }

        println("Results size: ${results.size}")
    }

보통 newVirtualThreadPerTaskExecutor() 스레드 풀을 사용하게 되면 자동으로 관리해 주기 때문에 스레드 풀 개수를 지정하지 않아도 됩니다. 아래 예제는 Virtaul Thread의 경량성을 보여주기 위해 임의로 10,000개를 생성한 모습입니다.

    @Test
    fun manyVirtualThread() {
        val threads = (1..10000).map {
            Thread.ofVirtual().start {
                try {
                    sleep(1000)
                    println("Thread $it finished")
                } catch (e: InterruptedException) {
                    e.printStackTrace()
                }
            }
        }
        threads.forEach { it.join() }
    }

코루틴과 Virtual Thread의 테스트 환경

코루틴(Coroutines)

Kotlin의 코루틴은 테스트 친화적인 환경을 제공합니다. runBlocking과 같은 코루틴 스코프를 사용하면 동기식 코드처럼 코루틴을 실행하고 테스트할 수 있습니다. 이로 인해 비동기 작업을 테스트할 때 큰 어려움이 없으며 단위 테스트 환경에서도 쉽게 통합할 수 있습니다.

아래 코드와 같이 runBlocking을 사용하면 코루틴 함수가 끝날 때까지 대기하고 결과를 검증할 수 있습니다.

    @Test
    fun coroutineSimpleTest() = runBlocking {
        val result = getNumberSuspend()
        assertEquals(100, result)
    }

    suspend fun getNumberSuspend(): Int {
        delay(1000)
        return 100
    }

코루틴 테스트에서 중요한 점은 Dispatcher를 제어할 수 있다는 점입니다. 테스트 환경에서 실제로 I/O를 사용하는 대신 Dispatchers.UnconfinedTestCoroutineDispacher를 사용해 가상의 환경에서 테스트할 수 있습니다.

    @Test
    fun dispatcherTest() = runBlocking {
        withContext(Dispatchers.Unconfined) {
            (1..10).forEach {
                launch {
                    println("Unconfined$it: ${Thread.currentThread().name}")
                    val result = getNumberSuspend()
                    assertEquals(100, result)
                }
            }
        }
    }

Virtual Thread

Virtual Thread는 동기식 코드처럼 동작하기 때문에 비동기적 작업을 처리할 때도 동기식 코드와 동일한 방식으로 테스트와 디버깅을 수행할 수 있습니다.

아래는 특별한 로직이 없는 직관적인 코드입니다. 덕분에 테스트가 쉽고 스레드풀이나 스케줄러에 의존하지 않아 격리된 테스트 환경에서 실행이 용이합니다. 이는 코루틴에서 Dispatcher를 변경해야 하는 경우와 달리 Virtual Thread는 별도의 스케줄러 조작 없이도 동작합니다.

    @Test
    fun virtualThreadSimpleTest() {
        val virtualThread = Thread.ofVirtual().start {
            sleep(1000)
            println("Virtual thread finished")
        }
        virtualThread.join()
        assertTrue { virtualThread.isAlive.not() }
    }

코루틴과 Virtual Thread의 성능 테스트

테스트 코드

아래는 동일한 소수 찾기를 10만 번 처리하는 로직입니다.

    @Test
    fun cpuBoundVirtualThreadTest() {
        val taskCount = 100_000
        val executor = Executors.newVirtualThreadPerTaskExecutor()

        val time = measureTimeMillis {
            val tasks = List(taskCount) {
                executor.submit {
                    findPrime(10_000)
                }
            }
            tasks.forEach { it.get() }
        }

        executor.shutdown()
        println("Virtual Thread: $time ms")
    }

    @Test
    fun cpuBoundCoroutineTest() = runBlocking {
        val taskCount = 100_000
        val dispatcher = Dispatchers.Default

        val time = measureTimeMillis {
            val jobs = List(taskCount) {
                launch(dispatcher) {
                    findPrime(10_000)
                }
            }
            jobs.forEach { it.join() }
        }

        println("Coroutine: $time ms")
    }

    fun findPrime(limit: Int): Long {
        var count = 0L
        for (i in 2 until limit) {
            if (isPrime(i)) {
                count++
            }
        }
        return count
    }

    fun isPrime(number: Int): Boolean {
        for (i in 2 until sqrt(number.toDouble()).toInt()) {
            if (number % i == 0) {
                return false
            }
        }
        return true
    }

속도 결과

해당 테스트에서는 Virtual Thread가 코루틴보다 10~15% 더 빠른 성능을 보였습니다.

Virtual Thread코루틴
1회차1910 ms2306 ms
2회차1919 ms2296 ms
3회차1912 ms2180 ms

여기에서 혹시 메모리나 CPU 사용은 어떻게 되는지 궁금하여 다시 한번 측정해 보았습니다.

메모리 사용량 및 CPU Time

다음은 메모리 사용량과 CPU 사용량을 측정하는 코드입니다.

    @Test
    fun measureMemoryAndCpu(task: () -> Unit) {
        val runtime = Runtime.getRuntime()
        val threadMXBean: ThreadMXBean = ManagementFactory.getThreadMXBean()

        val initialMemory = runtime.totalMemory() - runtime.freeMemory()
        val initialCpuTime = threadMXBean.currentThreadCpuTime

        task()

        val finalMemory = runtime.totalMemory() - runtime.freeMemory()
        val finalCpuTime = threadMXBean.currentThreadCpuTime

        val memoryUsed = finalMemory - initialMemory
        val cpuTimeUsed = finalCpuTime - initialCpuTime

        println("Memory used: $memoryUsed bytes")
        println("CPU time used: $cpuTimeUsed ns")
    }

성능이나 메모리 사용면에서는 확실하게 Virtual Thread가 우위에 있는 모습을 볼 수 있었습니다.

Virtual Thread코루틴
1회차Virtual Thread: 2005 ms
Memory used: 29565808 bytes
CPU time used: 174137000 ns
Coroutine: 2375 ms
Memory used: 67239952 bytes
CPU time used: 450631000 ns
2회차Virtual Thread: 1923 ms
Memory used: 35333448 bytes
CPU time used: 198288000 ns
Coroutine: 2350 ms
Memory used: 35150504 bytes
CPU time used: 552492000 ns
3회차Virtual Thread: 1941 ms
Memory used: 35728416 bytes
CPU time used: 214724000 ns
Coroutine: 2355 ms
Memory used: 43628296 bytes
CPU time used: 477424000 ns

마치며

코루틴은 이미 성숙한 기술로 코틀린에서 동시성 문제를 해결하기 위한 강력한 도구로 널리 사랑받았으며 코루틴을 활용한 동시성 처리 방식은 개발자들 사이에서 매우 익숙하며 안정적인 선택지로 자리 잡고 있습니다. 반면 Virtual Thread는 비교적 새로운 기술로 정식 릴리즈된 지 오래되지 않았습니다. 실제로 많은 시스템에서 JDK 19 이상을 사용하지 않고 있기 때문에 아직 제한적인 사용만 가능한 상황입니다.

그러나 글을 쓰면서 Virtual Thread의 여러 가지를 테스트해 본 상황으로 평가해 보자면 매우 간편하면서도 뛰어난 성능을 제공하는 기술입니다. 기존의 스레드 모델에서의 복잡성을 크게 줄여주며 복잡한 동시성 처리를 보다 직관적으로 구현할 수 있다는 강점이 있습니다.

기술선택에는 정답이 없으며 프로젝트의 성격이나 팀의 경험에 따라 달라진다고 생각합니다. 코드의 간결함과 편의성을 중요하게 생각한다면 JDK 버전의 제한이 있지만 Virtual Thread는 높은 생산성을 제공하는 매력적인 대안이 될 수 있다고 생각합니다. 특히 디버깅이나 오류 추적 측면에서 기존기술을 사용할 수 있기 때문에 좀 더 이점을 가진 선택이 될 수 있습니다. 직접 진행한 성능 테스트에서도 코루틴에 비해 여러 강점을 보여주었기 때문에 한 번쯤 과감히 선택한다면 좋은 결과를 얻을 수 있지 않을까 생각합니다.

wayne.cai
wayne.cai

뭐든지 즐겁게 일하는 서버 개발자 웨인입니다!