자바21의 가상 스레드

최창효·2024년 3월 24일
post-thumbnail

가상 스레드란 무엇인가

위 설명은 오라클에서 얘기하는 가상 스레드의 정의입니다. 가상 스레드는 한마디로 높은 처리량을 가져다주는 경량 스레드입니다.

그럼 경량 스레드는 뭘까요? 운영체제가 아닌 프로그램 레벨에서 구현된 스레드를 경량 스레드라 합니다.
(운영체제 레벨에서 구현된 스레드, 프로그램 레벨에서 구현된 스레드에 대한 내용은 아래 글을 읽다보면 자연스럽게 이해하실 수 있을 겁니다)

가상 스레드는 OS스레드를 그대로 사용하지 않고 JVM내부 스케줄링을 통해 수십에서 수백만개의 스레드를 동시에 사용할 수 있게 해주는 기술입니다.

가상 스레드는 어떤 문제를 해결하고자 등장한건가

기존의 Java Thread

  • 전통적인 자바의 스레드는 OS Thread를 Wrapping하여 Platform Thread라는 이름으로 사용합니다. 이 Platform Thread는 OS Thread와 1:1로 매핑되는 구조를 가지고 있습니다.
  • 따라서 자바 애플리케이션에서 스레드를 사용하는 작업은 사실상 OS Thread를 사용하는 것과 마찬가지였습니다.
  • 문제는 OS Thread는 생성 개수가 제한적이고 생성 및 유지하는 비용이 비싸다는 겁니다. 기본적으로 Web Request의 처리방식은 Thread Per Request(하나의 요청을 하나의 스레드가 할당받아 처리하는 구조)였기 때문에 스레드가 중요했습니다.
  • 전통적인 자바는 생성할 수 있는 스레드의 개수가 한정적이다는 단점을 보완하기 위해 생성된 스레드를 효율적으로 사용할 수 있는 Thread Pool이라는 개념을 도입했습니다.

또한 전통적인 자바의 스레드는 Blocking I/O에 대한 문제가 있었습니다. 스레드에서 I/O작업을 처리하면 해당 스레드가 Block되는데 이 기간동안 다른 작업을 수행하지 못하게 됩니다.

Reactive Programming

  • 이를 극복하기 위해 자바는 Reactive Programming을 도입했습니다. webflux를 이용하면 스레드를 Non-blocking하게 다룰 수 있습니다.
  • 하지만 webflux는 코드를 작성하고 이해하기 어렵습니다. 자바의 디자인은 기본적으로 '스레드 중심'으로 설계되어 있어 Exception Stack Trace, Debugger등의 작업이 모두 스레드를 기반으로 일어납니다. 하지만 Reactive Programming은 여러 스레드를 거쳐 작업이 진행되기 때문에 하나의 작업에 대한 예외나 디버깅 확인이 어려워 집니다.
  • 또한 Reactive하게 동작하는 별도의 라이브러리(R2DBC와 같은)의 도움이 필요하다는 단점이 존재했습니다.

가상 스레드 구조

  • Virtual Thread를 실제로 실행하고 있는 Platform Thread를 Carrier Thread라고 부릅니다. Carrier Thread는 ForkJoinPool에 저장되어 있으며 마찬가지로 OS Thread와 1:1로 매핑되어 있습니다.
  • 애플리케이션에 의해 수많은 가상 스레드가 생성될 수 있습니다. 이 가상 스레드는 OS Thread와 매핑된 Platform Thread에 mount되어 작업을 수행하게 됩니다.
  • Carrier Thread가 처리하던 Virtual Thread에서 I/O Blocking이 발생하면 Carrier Thread는 더 이상 해당 작업을 기다리지 않고 블록 상태의 스레드를 unmount한 뒤 다른 Virtual Thread를 mount해 작업을 진행합니다. 이를 통해 I/O Block으로 인해 Platform Thread가 낭비되는 시간을 줄였습니다. 이러한 작업은 스케줄러에 의해
  • Platform Thread는 미리 할당된 Stack영역을 사용하지만 Virtual Thread는 생성 시 Heap영역에 메모리를 할당받습니다.

가상 스레드의 장점

  • I/O Blocking에 따른 대기시간 낭비를 줄여줄 수 있습니다.
  • 플랫폼 스레드를 사용할 때보다 컨텍스트 스위칭의 부하가 적습니다.
  • reactive programming과 달리 복잡하지 않게 사용이 가능합니다. Virtual Thread는 Thread를 상속받고 있기 때문에 기존의 Thread기능을 사용할 수 있으며 사용하는 방법도 기존의 Thread와 유사합니다.

유의사항

Pinning현상이 발생할 수 있다

  • Pinning은 Virtual Thread가 Carrier Thread에 고정되어 버리는 현상을 말합니다. 원래 Virtual Thread에서 I/O Blocking이 발생하면 Carrier Thread는 다른 Virtual Thread를 mount해 대기시간에 대한 낭비를 줄여야 합니다. 하지만 Pinning이 발생하면 I/O Blocking이 발생해도 다른 Virtual Thread를 실행시키지 못해 Virtual Thread를 효율적으로 사용하지 못합니다.
  • Synchornized 블록 내에서 I/O Blocking이 발생하거나, Native Method를 실행할 경우 Pinning이 발생하게 됩니다.
    • Virtual Thread를 사용한다면 Synchronized를 ReenteranceLock으로 대체해 Pinning을 피하는 걸 권장하고 있습니다.

ThreadLocal을 사용할 때 주의해야 한다

  • ThreadLocal을 잘못 사용할 경우 메모리 부족 현상을 겪을 수 있습니다. Virtual Thread는 많으면 수십~수백만개까지 생성해서 사용할 수 있습니다. 이처럼 많은 양의 Thread를 위한 ThreadLocal을 운용하게 된다면 그 ThreadLocal역시 메모리에 큰 부담이 될 가능성이 큽니다.

I/O작업이 아닌 CPU기반의 작업일 경우 큰 효과를 보기 어렵다

  • Virtual Thread의 핵심은 I/O작업으로 인해 발생하는 Blocking 대기시간을 줄여주는 겁니다.
  • 따라서 Virtual Thread는 DB연동이 많거나 외부 API연동이 많은 서비스에 활용하기 좋은 기술입니다.

ThreadPool에 넣고 사용할 필요 없다

  • Pooling은 기본적으로 비용이 비싼 자원을 지속적으로 사용하기 위한 기술입니다. 하지만 Virtual Thread는 값싼 작업이기 때문에 풀에 넣고 재사용하기보다 필요할 때마다 새롭게 생성해서 쓰는 게 더 좋습니다.

사용법

환경 세팅

JDK21이상, SpringBoot3.2 이상이면 spring.threads.virtual.enabled속성을 true로 설정해주면 됩니다

#application.yml
spring:
  threads:
    virtual:
      enabled: true

간단 예시

// startVirtualThread 사용
Thread.startVirtualThread(() -> System.out.println("hello"));

// ofVirtual 사용
Thread thread = Thread.ofVirtual()
        .name("myVirtual")
        .start(() -> System.out.println("hello"));

// ExecutorService 사용
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
	executorService.submit(() -> System.out.println("hello"));
}

References

profile
기록하고 정리하는 걸 좋아하는 백엔드 개발자입니다.

0개의 댓글