Java

Kotlin Coroutine 과 Java Virtual thread

jwKim96 2024. 3. 17. 23:59

최근 Java 21 에서 Virtual thread 가 정식 출시되어 많은 관심을 받고 있는데요.
문득 Virtual thread 가 Kotlin Coroutine 을 대체하는 양상이 나오지 않을까 하는 생각이 들었습니다.
그래서 이 둘을 비교하며, 각각 어떤 문제를 해결하기 위해 나온 기술들이고 어떤 특징들이 있는지 살펴보고자 합니다.

1. 서론

Kotlin coroutine(코루틴)과 Java Virtual threads(가상 스레드)는 '컴퓨팅 자원을 더 효율적으로 사용하기 위한 경량 스레드 기술' 입니다.
컴퓨터의 발전 과정에서 우리는 자원을 효율적으로 사용하기 위해, 가용 자원의 유휴시간을 줄이기 위해 노력해왔는데요.
CPU Scheduling 이 그 대표적인 사례이죠.

코루틴과 가상 스레드 둘 다 위와 비슷한 맥락에서, 가용한 자원(스레드)의 유휴시간을 줄이는 방향으로 접근했는데요.
하지만 이 둘이 해결하려는 문제와 구체적인 접근방식(구현 방식)은 사뭇 달랐습니다.

2. Kotlin Coroutine

코틀린은 초기에 안드로이드 진영에서 활발히 사용되었는데, 안드로이드 특성상 멀티 스레딩을 잘 활용해야했습니다.
사용자에게 끊김 없는 UI를 보여주기 위해, UI 스레드와 백그라운드 스레드를 별도로 관리하며 UI 렌더링과 IO 작업을 분리해서 수행했습니다.

하지만, 멀티 스레딩을 하게되며 스레드간 동기화, 컨텍스트 스위칭, 타 스레드 Interrupt 등의 관리 포인트가 늘어나게 되었고
일부 스레드가 정상적으로 정리되지 않는 문제가 발생하며 Memory leak 을 유발하기도 했습니다.

2.1 Callback pattern

그래서 이를 해결하기 위한 방법으로 Callback pattern 을 이용했는데요.

fun main() {
    performAsyncOperation { result ->
        println("Operation result: $result")
    }
}

// 비동기 작업을 수행하고, 완료 후 콜백을 호출하는 함수
fun performAsyncOperation(callback: (String) -> Unit) {
    Thread {
        Thread.sleep(1000)
        callback("Success") // 콜백 함수 호출
    }.start()
}

Callback 을 이용하여 Non-blocking 방식으로 동작시키면, 복잡한 동기화 문제를 어느 정도 해결할 수 있는데요.
하지만, 아시다시피 Callback 로직이 복잡해지면 아래와 같이 Callback 지옥에 빠질 수 있습니다.

fun main() {
    performAsyncOperation("Task 1") { result1 ->
        println(result1)
        performAsyncOperation("Task 2") { result2 ->
            println(result2)
            performAsyncOperation("Task 3") { result3 ->
                println(result3)
                performAsyncOperation("Task 4") { result4 ->
                    println(result4)
                }
            }
        }
    }
}

// 비동기 작업을 수행하고, 완료 후 콜백을 호출하는 함수
fun performAsyncOperation(taskName: String, callback: (String) -> Unit) {
    Thread {
        Thread.sleep(1000)
        callback("$taskName completed") // 콜백 함수 호출
    }.start()
}

이렇게 depth 가 깊어지면 코드 가독성도 떨어지고, 예외 처리 및 분석도 어려워집니다.

2.2 suspend 와 resume

그래서 코루틴에서는 이런 문제를 해결하기 위해, suspend, resume 을 이용하여 이 문제를 해결했는데요.
Blocking IO가 발생할 수 있는 지점(suspend point)에서 코루틴은 중단될 수 있고, 다시 해당 지점에서 재개되는 방식으로 동작합니다.

img

(출처 : https://www.droidcon.com/2022/09/22/design-of-kotlin-coroutines)

아래 예제를 보면 Blocking IO 가 발생할 수 있는 fetchData 함수가 있고, 그렇지 않은 localProcessData 함수가 있습니다.
이 예제의 실행 결과는 어떻게 될까요?

fun main() = runBlocking {
    launch {
        val result = fetchData() // suspend point
        println("[${Thread.currentThread()}] Data: $result")
    }
    launch {
        val result = localProcessData()
        println("[${Thread.currentThread()}] Data: $result")
    }
    println("[${Thread.currentThread()}] Finished")
}

// 네트워크 요청을 모방하는 suspend 함수
suspend fun fetchData(): String {
    delay(1000)
    return "Fetched data"
}

fun localProcessData(): String {
    return "Local processed data"
}

/*
실행 결과

[Thread[#1,main,5,main]] Finished
[Thread[#1,main,5,main]] Data: Local processed data
[Thread[#1,main,5,main]] Data: Fetched data
*/

이 처럼 코루틴은 한정된 범위에서 비동기 & 논블로킹을 활용하여 스레드 유휴시간을 활용할 수 있는 특성이 있습니다.

3. Java Virtual thread

전통적인 자바의 스레드는 JVM 상에서 OS 의 스레드와 1대1 매칭되는 구조로 동작했습니다.
이 때문에 스레드 생성/관리/소멸 비용이 높고, 대규모 병렬 처리 작업이 효율적이지 않았습니다.
특히 (Spring MVC 와 같은)요청당 스레드 방식의 시스템에서 블로킹 IO 가 자주 발생하면, 처리량이 감소했는데요.

이를 해결하기 위해 Java 8 에서 ForkJoinPool(with ForkJoinTask), CompletableFuture 등의 비동기 프로그래밍 지원이 시작되었습니다.

3.1 ForkJoinPool(with ForkJoinTask)

아래는 ForkJoinPool 을 활용하는 예시 코드인데요.
무거운 작업 예시인 1 ~ 1,000,000 까지 더하는 작업을 10,000 단위로 분할 정복하여 빠르게 해결할 수 있습니다.

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class SumTask extends RecursiveTask<Long> {
    private final long start;
    private final long end;
    private final long THRESHOLD = 10_000; // 작업 분할 기준값

    public SumTask(long start, long end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        long length = end - start;

        // 작업 분할 기준값 이하면, 계산 실행
        if (length <= THRESHOLD) {
            return sum();
        }

        // 절반 값 계산
        long mid = start + (length / 2);

        // taks1(시작 ~ 절반) => 비동기 실행
        SumTask task1 = new SumTask(start, mid);
        task1.fork(); // 비동기 실행

        // task2(절반 ~ 끝) => 현재 스레드에서 동기 실행
        SumTask task2 = new SumTask(mid, end);
        long rightResult = task2.compute(); // 현재 스레드에서 실행
        long leftResult = task1.join(); // taks1 완료 될때까지 기다린 후 결과 가져옴

        return leftResult + rightResult;
    }

    private long sum() {
        long sum = 0;
        for (long i = start; i < end; i++) {
            sum += i;
        }
        return sum;
    }

    public static void main(String[] args) {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        // ForkJoinPool을 사용하여 SumTask 실행 및 결과 출력
        long totalSum = forkJoinPool.invoke(new SumTask(1, 1_000_000));
        System.out.println("Total sum: " + totalSum);
    }
}

3.2 CompletableFuture

다음으로 아래는 CompletableFuture 의 supplyAsync 를 이용해서 비동기적으로 작업을 실행하며, callback 을 주입하고
해당 작업이 완료되면 callback 이 호출되는 방식입니다.

public class CompletableFutureExample {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "Hello";
        }).thenApply(result -> {
            return result + " World";
        });

        System.out.println(completableFuture.get()); // "Hello World" 출력
    }
}

이렇게 Java 에서도 경량 스레드와 비슷한 컨셉의 ForkJoinTask 와 비동기 & Callback pattern 를 통해 성능을 끌어올릴 수 있었습니다.
하지만 로직이 복잡해지면 이런 코드를 작성, 변경, 디버깅하기가 쉽지 않은데요.

3.3 Virtual thread(feat. Project loom)

이를 해결하기 위해 Project loom 에서는 복잡하지 않고, 기존 코드와 호환성이 좋은 가상 스레드를 개발했습니다.

https://miro.medium.com/v2/resize:fit:720/format:webp/1\*zMa\_6m1aAGbKTS8w8s4wkw.png

가상 스레드는 기본적으로 플랫폼 스레드(캐리어 스레드)에 park 되어 동작되는데요.
만약 가상 스레드에서 블로킹 IO 가 발생하면, 플랫폼 스레드에서 unpark 되고 다른 가상 스레드가 해당 플랫폼 스레드에 park 되죠.

그리고 블로킹 IO 가 발생했던 가상 스레드는 작업이 끝나면 현재 유휴 상태인 플랫폼 스레드에 다시 park 되는 방식으로 동작합니다.

아래 코드는 가상 스레드와 일반 스레드를 비교하는 예시인데요.
runnable 메서드에는 블로킹 지점(Thread.sleep)이 있습니다.

public class Main {
    public static void main(String[] args) {
        System.out.println("Thread -------");
        runWith(
                Executors.newThreadPerTaskExecutor(
                        Executors.defaultThreadFactory()
                )
        );

        System.out.println("Virtual thread -------");
        runWith(
                Executors.newVirtualThreadPerTaskExecutor()
        );
    }

    private static void runWith(ExecutorService service) {
        var start = System.currentTimeMillis();
        var futures = new ArrayList<Future<Runnable>>();

        for (int i = 0; i < 1000; i++) {
            futures.add(service.submit(Main::runnable));
        }

        // 모두 끝날때까지 기다림
        futures.forEach(future -> {
            try {
                future.get();
            } catch (InterruptedException | ExecutionException e) {
                throw new RuntimeException(e);
            }
        });
        var end = System.currentTimeMillis();

        System.out.println((end - start) + "ms");
    }

    private static Runnable runnable() {
        return () -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        };
    }
}

/*
실행 결과

Thread -------
70ms
Virtual thread -------
12ms
*/

4. 정리

지금까지 살펴본 내용을 정리하면 다음과 같습니다.

  • 코루틴
    • Kotlin 언어 차원에서 제공하는 경량 스레드 기술
    • 비동기 로직 작성 편의성 제공
    • 국소적인 범위(함수)를 제어할 수 있음
  • 가상 스레드
    • JVM 런타임 차원에서 제공하는 경량 스레드 기술
    • 내부적으로 가상 스레드 스케줄링을 통해 블로킹 IO 발생시, 플랫폼 스레드 유휴시간 최적화

결론적으로 코루틴과 가상 스레드는 상호 배타적인 존재가 아니라, 각기 다른 문제를 해결하기 위한 기술이라는것을 알 수 있었습니다.
코루틴을 가상 스레드 위에서 동작시킬 수 있으기 때문에, 오히려 시너지를 낼수도 있습니다.


Reference