전통적인 자바 스레드
서버 애플리케이션에서 동시성 처리를 위해 스레드를 사용하는 것은 오랜 전통이었습니다. 예를 들어 스프링 프레임워크는 여러 요청을 처리하기 위해 멀티 스레드 모델을 채택한 프레임 워크입니다. 보통은 각 요청을 처리하는 데 하나의 스레드를 할당하는 방식으로 작동됩니다. 그러나 이 방식은 요청이 많아질수록 문제가 발생하게 됩니다.
과거에는 운영 체제 스레드의 제약으로 인해 동시 요청에 대응하기 위해 스레드 수를 늘리는 것이 어려웠습니다. 이는 스레드가 비용이 높고, 시스템에 사용 가능한 스레드 수가 제한되기 때문이죠. 또한 자바의 스레드는 운영 체제 스레드의 Wrapper로 동작하기 때문에 I/O 작업을 만나면 블로킹되어 다른 작업을 수행할 수 없는 경우가 발생합니다. 예를 들어 스프링 애플리케이션 서버에서 요청을 처리할 때, 해당 요청을 처리하기 위해 특정 스레드가 CPU를 사용됩니다. 그러나 이 스레드가 네트워크 요청이나 파일 쓰기와 같은 I/O작업을 만나면 다른 작업을 수행할 수 없는 상태가 됩니다. 이는 시스템의 성능과 응답 시간을 저하시키는 원인이 됩니다.
가상스레드의 목적
해결하고자 하는 문제
1. 높은 처리량(쓰루풋)의 서버 구축: 가상 스레드는 하드웨어 성능을 최대한 활용하여 높은 처리량을 달성하는 서버를 작성하는 데에 중점을 둡니다.
2. Blocking 처리의 문제 해결: 가상 스레드는 Blocking이 발생할 때 내부적으로 스케줄링을 통해 플랫폼 스레드가 대기하지 않고 다른 가상 스레드가 작업할 수 있도록 합니다. 이는 리액티브 프로그래밍의 Non-blocking과 같은 효과를 제공하며, 플랫폼 스레드의 리소스를 효율적으로 활용합니다.
3. 자바 플랫폼과의 조화: 가상 스레드는 자바 플랫폼의 디자인과 조화를 이루는 코드를 생성합니다. 기존의 자바 언어 구조를 유지하면서도 높은 처리량을 달성할 수 있도록 합니다. 실제로 가상스레드는 기존의 스레드를 그대로 받아옵니다. 😊
sealed abstract class BaseVirtualThread extends Thread
permits VirtualThread, ThreadBuilders.BoundVirtualThread {
...
}
4. 디버깅 및 성능 테스트의 용이성: 기존의 Reactive programming은 스레드를 기반으로 하지 않았기 때문에 Webflux 등을 사용할 때 디버깅과 성능 테스트가 어려웠습니다. 하지만 가상 스레드는 기존 스레드 구조를 그대로 사용하므로 기존의 도구를 사용하여 디버깅 및 프로파일링이 용이합니다.
Reactive Programming, MVC, Virtual Thread
MVC 모델은 요청이 들어오면 해당 요청을 처리하기 위해 스레드를 할당하고, 이스레드가 해당 요청을 처리할 때까지 기다리는 방식으로 동작합니다. 이는 블로킹(Blocking) 방식으로, 요청이 처리되기까지 다른 작업을 수행하지 못하는 단점이 있습니다. 반면에 리액티브 프로그래밍은 Non-blocking 방식을 채택합니다. 이는 요청이 처리되는 동안 다른 요청이 기다리지 않고 동시에 처리될 수 있다는 것을 의미합니다. 하지만 리액티브 프로그래밍은 코드 해석과 작성이 정말 어렵습니다...
가상 스레드는 리액티브 프로그래밍이 달성하고자 하는, 리소스를 효율적으로 사용하여 높은 처리량(throughput)을 감당하려는 목적이 동일합니다. 또한 Non-blocking에 대한 처리를 JVM 레벨에서 담당하기 때문에 기존의 플랫폼 스레드를 직접 사용하는 방식보다 효율적으로 스케줄링하여 처리량을 높일 수 있습니다.
가상 스레드 구조
기존 자바 스레드는 OS 스레드를 감싼 형태로 동작합니다. 애플리케이션 코드가 자바 스레드를 사용할 때 실제로는 OS 스레드를 사용하게 되는데, 이러한 스레드는 생성 및 관리 비용이 높아서 스레드 풀을 사용하여 관리해 왔습니다.
반면에 가상 스레드는 OS스레드를 감싸지 않고 JVM이 자체적으로 스레드를 관리합니다. 이를 통해 가상 스레드는 가상 스레드 풀 없이도 사용할 수 있습니다. JVM은 가상스레드를 OS 스레드와 연결하고, 스케줄링합니다. 이 작업을 mount/ unmount라고 하며, 이전에는 플랫폼 스레드라고 불렀던 부분을 Carrier 스레드라고 합니다. (가상 스레드를 실제 OS 스레드로 연결해 준다는 의미를 갖습니다.)
가상 스레드는 기존 스레드와는 달리 내부 스케줄링을 통해 블로킹 시간을 최소화합니다. 블로킹이 발생하면 Carrier 스레드가 다른 가상 스레드로 작업을 전환하여 Non-blocking 이점을 유지할 수 있습니다. 즉 정리하면, 기존 스레드는 블로킹이 발생하면, 무한적으로 기다려야 했는데, 가상 스레드에서는 블로킹이 발생하면 내부 스케줄링을 통해서 실제 작업을 처리하는 Carrier 스레드를 통해서 다른 가상 스레드의 작업을 처리하게 됩니다. 도식화하면 다음과 첨부 그림과 같습니다.
하지만 위와 같은 구조에서는 가상 스레드가 요청에 따라 무한적으로 늘어날 수 있기 때문에 전통적인 플랫폼 스레드와 동일한 비용, 컨텍스트 비용이 발생하면 감당하기 어렵기 때문에, 자원 사용량이 다음과 같이 정의됩니다.
가상 스레드 사용법
1. 프로젝트 생성
2. Main.java 작성
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Main {
public static void main(String[] args) throws Exception{
run();
}
public static void run() throws Exception {
// Virtual Thread 방법 1
Thread.startVirtualThread(() -> {
System.out.println("Hello Virtual Thread");
});
// Virtual Thread 방법 2
Runnable runnable = () -> System.out.println("Hi Virtual Thread");
Thread virtualThread1 = Thread.ofVirtual().start(runnable);
// Virtual Thread 이름 지정
Thread.Builder builder = Thread.ofVirtual().name("JVM-Thread");
Thread virtualThread2 = builder.start(runnable);
// 스레드가 Virtual Thread인지 확인하여 출력
System.out.println("Thread is Virtual? " + virtualThread2.isVirtual());
// ExecutorService 사용
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i <3; i++) {
executorService.submit(runnable);
}
}
}
}
첫 번째 thread 방법은 startVirtualThread를 사용한 방식이고, 두 번째는 Thread.ofVirtual() 방식으로 사용합니다.
virtualThread1, virtualThread2는 똑같이 Thread의 이름을 갖고 사용하기 때문에, 이 스레드가 플랫폼 스레드인지 가상 스레드인지 구분하기 위해서는 isVirtual이라는 메서드를 사용해서 가상 스레드로 동작하는지 아닌지 확인할 수 있습니다.
실제로 위와 같이 low level의 코드를 작성할 일은 전혀 없을 거예요 😆
Spring Boot(MVC) 적용법 (3.2 이상)
오늘 글에서 제일 중요한 부분입니다. 과연 스프링 부트에서 어떻게 적용하는지가 제일 궁금하잖아요 😊
스프링부트에서 다음과 같이 yaml을 작성하면 가상 스레드를 사용할 수 있습니다. 다음과 같이 가상스레드를 사용하면, 내부에서 발생하는 톰캣, was에 대한 처리를 가상 스레드가 처리가 되게끔 해줍니다.
spring:
threads:
virtual:
enabled: true
Spring Boot(MVC) 적용법 (3.x)
// Web request 를 처리하는 tomcat이 virtual Thread를 사용하여 유입된 요청을 처리
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
// Async Task에 Virtual Thread 사용
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor asyncTaskExecutor() {
return new TaskExecutorAdaper(Executors.newVirtualThreadPerTaskExecutor());
}
'Language > JAVA' 카테고리의 다른 글
[이펙티브 자바][아이템 2] 생성자에 매개변수가 많을 때는 빌더를 고려하라 - 컴도리돌이 (0) | 2024.08.22 |
---|---|
[이펙티브 자바][아이템 1] 생성자 대신 정적 팩토리를 고려해라 - 컴도리돌이 (0) | 2024.08.16 |
[JAVA] HashMap에 대해서(Thread Safe, 해시맵 동작 원리, chaining, 해시 충돌(Hash Collision), Fail-Fast ) - 컴도리돌이 (0) | 2024.04.24 |
[Java] 서블릿 컨테이너(Servlet Container)에 대해서 - 컴도리돌이 (0) | 2024.03.04 |
[JAVA 21] Pattern Matching for switch - 컴도리돌이 (2) | 2024.02.27 |