본문 바로가기
Languages/Java

[Java] 자바21의 가상스레드 간단하게 이해하기

by yoon_seon 2024. 5. 29.

자바 21에 새롭게 가상스레드가 추가되었고 간단하게 가상스레드를 이해해 보기 위해 포스팅하게 되었다.

 

자바의 스레드

가상 스레드를 이해하기 위해 먼저 기초지식인 프로세스와 스레드를 다시 정리하면 아래와 같다.

  • 프로세스 : 프로그램이 CPU 메모리 자원을 할당받아 실행 중인 상태
  • 스레드 : 프로세스 내 실행 흐름 단위. 즉, 프로세스에 소속되어 여러 코드를 동시에 실행시킬 수 있도록 하는 단위

 

그러면 자바코드로 스레드를 만들어서 실행시킨다면 실제로 어떻게 동작하게 되는 걸까?

 

자바에서 스레드를 만들어서 실행하는 코드

public static void main(String[] args) throws Exception {
    for (int i = 0; i < 3; i++) {
        int threadNum = i + 1;

        Thread t = new Thread() {
            @Override
            public void run() {
                printlnWithThread(String.format("스레드 %s번 실행", threadNum));
            }
        };
        t.start();
    }
    //3개의 스레드가 실행될 때 까지 기다리기위해 5초간 대기
    Thread.sleep(5_000L);
}

private static void printlnWithThread(Object obj) {
    System.out.printf("[%s] %s\n", Thread.currentThread().getName(), obj);
}

코드레벨에서 스레드를 생성하게 되면 자바가 동작하고 있는 JVM 위에서 스레드를 표현하는 스레드 인스턴스가 생성되고 JVM에서 표현한 new Thread 인스턴스는 실제 운영체제의 스레드와 매핑된다.

이후 운영체제가 매핑된 스레드를 실행시키고 우리가 작성한 코드가 실행되는 방식으로 동작한다.

즉, 직접 만든 new Thread인 JVM의 스레드는 OS가 관리하는 스레드와 1:1 매핑되는 것이다. 여기서 운영체제가 관리하는 스레드를 Native Thread라고 한다.

 

그럼 스레드를 왜 사용하나?

각 프로세스는 독립된 메모리를 가지고 있기 때문에 프로세스를 변경한다면 모든 메모리가 교체되어야 하지만, 스레드는 한 프로세스에 있기 때문에 스레드를 교체할 경우 프로세스의 공유 자원인 힙영역을 제외한 스레드의 독립된 자원인 스택 영역만 교체하면 되기 때문에 효율적으로 하드웨어 자원을 사용할 수 있다. → 여기서 스레드를 멈추고 다른 스레드를 돌리는 주체는 OS다.

 

스레드도 단점이 존재한다.

물론, 스레드를 생성하는 것 자체에도 비용이 든다는 단점이 있다. new Thread로 JVM 스레드를 만들고 이를 OS 스레드와 매핑하는 과정에서 비용이 발생한다. 이러한 단점을 위해 풀링 방식이 존재한다.

풀링은 미리 Thread를 만들어두고 새로운 작업을 수행할 때 만들어둔 스레드를 가져다 사용하는 방법이다. 대표적인 예시로 자바의 문자열 풀이나, Spring Boot에서 설정할 수 있는 DB 커넥션 풀이 있으며 위와 같은 방식으로 자원을 효율적으로 관리한다.

 

풀링 방식의 예시

public static void main(String[] args) throws Exception {
    try (ExecutorService executorService = Executors.newFixedThreadPool(2)) {
        for (int i = 0; i < 3; i++) {
            int threadNum = i + 1;
            executorService.submit(() -> printlnWithThread(String.format("스레드 %s번 실행", threadNum)));
        }
    }

    Thread.sleep(3_000L);
}

private static void printlnWithThread(Object obj) {
    System.out.printf("[%s] %s\n", Thread.currentThread().getName(), obj);
}
  • Executors.newFixedThreadPool(2)을 통해 스레드 2개를 만들어놓고 사용한다.

 

가상 스레드

위에서 봤던 JVM에서 new Thread로 생성한 스레드를 플랫폼 스레드라한다.

가상 스레드는 높은 처리량의 동시성 애플리케이션을 개발하고 유지보수하기 위한 경량 스레드로, 결국 가상 스레드도 스레드다.

가상스레드는 Native Thread와 1:1 매핑되지 않으며, 여러 개의 스레드를 만들더라도 하나의 Native Thread와 매핑될 수 있다.

따라서 여러 작업을 동시에 수행하면서도 Native Thread를 생성하는 비용을 절감할 수 있다.

Java 런타임의 내부 스케줄러는 각 가상 스레드를 어떤 Native Thread에 매핑할지 결정한다.

특정 가상 스레드가 특정 Native Thread에서 실행 중일 때 블로킹이 발생하면, 해당 Native Thread는 실행하고 있던 가상 스레드를 버리고 다른 가상 스레드로 바꿔서 실행시키게 된다. 이로 인해 기존의 플랫폼 스레드보다 높은 처리량을 가질 수 있게 되었다.

여러개의 가상 스레드가 하나의 플랫폼 스래드에 매핑되고 플랫폼 스레드는 Native Thread와 1:1 매핑된다.

여기서 여러개의 가상 스레드에 매핑된 플랫폼 스레드를 가상 스레드 캐리어라고 한다.

 

가상 스레드의 주요 특징

  • 가상 스레드도 자바 스레드다.
  • 가상 스레드는 원래 존재했던 플랫폼 스레드보다 가볍다.
    • 가볍다 : 더 많은 가상스레드가 동시에 돌 수 있지만 더 적은 용량을 차지함
  • 가상 스레드의 코드가 실행되려면 플랫폼 스레드에 매핑되어야 한다.
  • 가상 스레드의 코드가 실행되다 Blocking I/O가 발생하면 다른 가상 스레드로 바꿔서 코드가 실행된다.
public static void main(String[] args) throws Exception {
    // 빌더 사용
    Thread t = Thread.ofVirtual()
        .start(() -> printlnWithThread("가상 스레드"));
    t.join();

    // 빌더 미사용
    Thread t2 = Thread.startVirtualThread(() -> System.out.println("ABC"));
    t2.join();

    try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
        Future<?> future = executorService.submit(() -> printlnWithThread("ExecutorService, 가상 스레드"));
        future.get();
    }
}

private static void printlnWithThread(Object obj) {
    System.out.printf("[%s] %s\n", Thread.currentThread().getName(), obj);
}
  • Thread.ofVirtual()를 통해 빌더 형식으로 실행 가능하다.
  • 빌더 없이 실행하려면 new ThreadThread.startVirtualThread()으로 실행 가능하다.
  • 예제에서는 join 메서드로 블로킹 처리했다.
  • new ThreadExecutorService로 사용하려면  new ThreadExecutors.newVirtualThreadPerTaskExecutor() 메서드를 사용할 수 있다.

가상 스레드에 대한 오해

  • 가상스레드는 여러 요청을 동시에 처리해 전체 처리량을 늘리는 것이다. 단일 요청을 놓고 보면 플랫폼 스레드보다 느릴 수 있다.
  • 가상 스레드는 생성 비용 자체가 저렴해서 풀링 방식을 권장 사용하지 않는다. 필요할 때 생성해서 사용한다. 이 부분은 가상스레드의 등장 목적에서 잘 드러난다.

가상 스레드의 등장 목적

  • 요청이 한번 들어왔을 때 하나의 스레드가 담당하는 스타일을 하드웨어를 극한으로 사용하여 스케일 하기 위해 등장했다.
  • 기존 스레드 API와 호환성을 중요하게 생각하고 기존의 스레드 기반으로 동작하던 디버그 툴, 트러블 슈팅 툴 등을 그대로 사용할 수 있게 하는 것이 목적이다.
  • 애당초 기존의 스레드를 대체하거나 완전히 새로운 것을 만드는 게 목표가 아니다.

 

Spring + 가상스레드

아래의 옵션으로 활성화 할 수 있다. (Spring Boot 3.2 이상, Java 21 이상)

단순히 요청을 받는 톰캣 뿐만 이라 스케줄러나 @Async 어노테이션, 여러 리스너에서도 가상스레드가 동작하게 된다.

 

주의사항 

1. DB 커넥션 소진 가능성

가상 스레드는 스레드 풀, 풀링을 사용하지 않기 때문에 DB 커넥션 풀이 소진되서 요청이 거절될 수 있다. 기존에는 스레드 풀이 작동해서 요청을 작게 받고 DB 커넥션 풀로인해 DB로는 적당한 TPS가 들어가는 형태였는데, 가상스레드를 사용하면 풀링을 사용하지 않아, 250개의 요청이 들어오면 250개 요청 전부 DB에 전송되서 커넥션풀이 소진될 가능성이 있다.

 

2. 가상 스레드 Pinning 현상

Pinning 현상은 가상 스레드가 특정 플랫폼 스레드에 고정되어 버리는 현상을 의미한다.

가상 스레드가 특정 플랫폼 스레드에 고정되어 버리면 다른 가상스레드를 실행시킬 수 없게되고, 가상 스레드를 안쓰니만 못하는 상황이 되어버린다.

이런 Pinning 현상은 보통 synchronized 코드를 실행하거나, native 코드(자바에서 C++로 작성된 native 메서드)를 실행한 경우 발생한다.

실제로 몇몇 프레임워크나 라이브러리에서는 이런 문제 때문에 가상 스레드와의 호환성을 지원하기 위한 과정으로 synchronized 를 다른 Lock으로 대체하는 사례도 있다고 한다..

 

3. Thread Local 문제

Thread Local에는 기존 풀링 방식에서 객체를 캐싱해서 사용하여 비싼 객체의 생성 비용을 아낄 수 있었지만 가상 스레드는 풀링 방식을 사용하지 않기 때문에 성능에 악 영향이 끼칠 수 있다.

 

4. 가상 스레드 + Spring Webflux

  • Spring Webflux :  Reactive Prograaming을 지원하는 프레임워크로 MVC와 같은 Thread-per-request에서 벗어나 적은 수의 스레드와 함께 non-blocking으로 동시성을 극대화하는 프레임워크

Spring Webflux의 핵심은 적은 수의 스레드를 풀링한다는 점이다.

예를 들어, MVC가 200개의 스레드 풀을 사용하는 반면, Webflux는 CPU 코어 수에 맞게 적은 수의 스레드를 사용하며, 모든 I/O 작업을 non-blocking 방식으로 처리해 적은 수의 스레드를 최대한 활용한다.

적은 수의 스레드를 풀링하는 방식은 플랫폼 스레드에서 효과적이지만, 가상 스레드를 바로 적용하기에는 제한이 있다.

결론적으로, 가상 스레드가 출시된 지 얼마 되지 않아, 여러 라이브러리와 프레임워크에서 이를 지원하는 것이 필요한 상황이다.

 

따라서, 위 주의사항을 염두에 두고 가상 스레드의 장단점을 잘 이해하여 사용한다면 더 많은 처리량을 확보할 수 있을 것이다.

댓글