
현재 블로그에서 가장 인기있는 글이 가상쓰레드 관련 글이다보니.. 도움이 되고자 따로 정리를 하고 공유합니다.
1. 개요, 배경
먼저, 가상쓰레드 배경에 대해 파악하려면 Blocking I/O가 무엇을 의미하는 지 알아야합니다.
Blocking I/O란 어떤 요청을 보냈을 때 요청에 대한 응답이 올 떄까지 아무것도 안하고 기다리는 것을 말합니다. 아무것도 안하고 기다리다보니 그 기다리는 시간동안에 아무것도 할 수가 없기에, 이러한 요청이 많아지면 많아질수록 성능저하로 이어지고 있었습니다.
이러한 배경속에서 Project Loom이라는 프로젝트를 자바에서는 시작했고, 그 결과 가상 스레드가 탄생했습니다.
플랫폼 스레드, 가상스레드
플랫폼 스레드는 운영체제가 직접관리하는 스레드입니다. 스레드 1개당 메모리도 어느정도 고정되어있고, 후술할 가상 스레드보다는 상대적으로 큰 메모리를 점유하고 있습니다. 때문에 한 번에 너무 많은 일을 할당하게 되면 성능이 저하 될 수 있습니다.
가상 스레드는 운영체제가 직접 관리하지 않습니다. JVM이 관리하는 스레드이며 매우 경량화 되어있습니다. 메모리를 동적으로 구성하여 필요할 때에만 메모리를 점유합니다. 그렇기에 많은 가상 스레드를 생성할 수 있습니다.
실제 작업은 플랫폼 스레드가 합니다. 가상 스레드는 실제 작업이 필요할 때만 플랫폼 스레드에 잠깐 올라가서 일을 하고, Blocking I/O 등으로 기다려야 할 떄는 플랫폼 스레드에서 내려와서 대기합니다. 이런식으로 플랫폼 스레드는 가상 스레드가 내려오면 다른 가상 스레드를 태우는 식으로 작업을 합니다.
가상 스레드 장점
- 높은 확장성
- 수백만 개의 가상 스레드를 만들 수 있습니다. 따라서 적은 플랫폼 스레드 수로 많은 가상스레드를 만들어 일을 효율적으로 처리할 수 있습니다.
- 낮은 자원 소모
- 각 가상스레드가 차지하는 메모리가 매우 작아서 컴퓨터 메모리를 효율적으로 사용할 수 있습니다.
- 개발 용이성
- 가장 큰 장점 중 하나는 동기식코드 작성이 가능하다는 것입니다. 굳이 비동기 코드나 리액터, 코루틴 같은 기술들을 러닝하지 않고 동기식으로 코드 작성을 할 수 있습니다.
이런 장점 떄문에 가상 스레드는 I/O 작업이 많은 곳에서 효과를 발휘합니다. MSA구조로 타 서비스들끼리 통신을 해야하거나, 웹 서버 요청을 받는데 쓰이거나, 데이터베이스 연결 풀 관리에도 쓰입니다.
2. 사용법
가상 스레드를 사용하는 방법에는 크게 3가지로 나뉩니다.
Thread.Builder를 이용해서 생성Executors.newVirtualThreadTaskExecutor()를 사용CompletableFuture와 연동
Thread.Builder를 이용한 가상 스레드 생성 및 실행
public static void main(String[] args) throws InterruptedException {
Thread myVirtualThread = Thread.ofVirtual()
.name("my virtual thread") // 스레드 이름 붙이기
.start(() -> {
System.out.println("가상 스레드 이름: " + Thread.currentThread().getName());
try {
Thread.sleep(1000); // 1초 대기 (네트워크 I/O, 파일 I/O 등 블로킹 작업을 시뮬레이션)
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("가상 스레드 작업 완료: " + Thread.currentThread().getName());
});
myVirtualThread.join(); // 가상스레드가 끝날 때까지 메인 스레드 대기
System.out.println("메인 스레드 작업 완료");
}
이런 식으로 구성할 수 있습니다.
Thread.ofVirtual(): 가상 쓰레드 생성Runnable: '무엇을 할지'를 정의하는 인터페이스start(): 이 메서드가 호출되면 가상 스레드는 작업 시작join(): 만약 여러 작업을 진행하고 있으면 해당 메서드를 통해 가상스레드가 작업을 마칠 때까지 기다립니다. 위 예제에서는 1초대기를 기다립니다. 만약 join()이 없으면 '작업 완료' 가 콘솔에 찍히지 않고 종료될 수 있습니다.
Executors.newVirtualThreadPerTaskExecutor()를 이용한 가상 스레드 풀
여러 작업을 동시에 처리해야할 때, 매 번 스레드를 만드는 것은 번거로울 수 있습니다. 때문에 스레드 풀을 사용합니다. 작업 하나당 가상 스레드 1개를 생성할 수 있는 특징을 가지고 있습니다. 전통적인 스레드 풀은 제한된 수의 스레드를 재활용했지만 가상 스레드는 필요할 때마다 가상 스레드를 무한정 생성합니다.
public void createVirtualThreadPool() throws InterruptedException {
// try-with-resources 구문을 사용하여 가상 스레드 풀 생성, ExecutorService를 자동으로 닫을 수 있음.
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 5; ++i) {
final int taskId = i;
// 가상 스레드 풀에 작업 제출
executorService.submit(() -> { // 각 submit마다 새로운 가상 스레드가 생성
Thread.currentThread().setName("task-" + taskId);
System.out.println("Task " + taskId + " is running in thread: " + Thread.currentThread().getName());
try {
// 블로킹 작업 시뮬레이션
Thread.sleep(100 + (long)(Math.random() * 500)); // n초 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Task " + taskId + " was interrupted.");
}
System.out.println("task " + taskId + " completed in thread: " + Thread.currentThread().getName());
});
}
}// try 구문을 벗어나면 자동으로 executorService.close()가 호출되어 가상 스레드 풀을 종료합니다.
System.out.println("모든 가상 스레드 작업제출 완료, 메인 스레드 작업 종료 대기 ...");
TimeUnit.SECONDS.sleep(2); // 메인 스레드가 종료되기 전에 모든 가상 스레드가 완료될 때까지 대기
System.out.println("메인 스레드 작업 완료");
}
위 코드는 5개의 작업을 ExecutorService에 제출합니다. 동시에 많은 독립적인 작업을 수행할 때 좋습니다. 참고로 Thread.currentThread().isVirtual();를 통하여 현재 스레드가 가상 스레드인지 확인할 수도 있습니다.
CompletableFuture와 가상 스레드의 연동
CompletableFuture는 자바에서 비동기 작업을 처리합니다.
가상 스레드와 함께 동작할 때 강력해지는데, 위에서 언급한 ExecutorService를 인자로 넘기면 해당 태스크들을 비동기로 실행하며, thenApply(), thenAccept(), thenCompose() 등의 메서드를 이용하여 결과를 가지고 다음 작업을 이어나갈 수 있게 해줍니다.
public void createCompletableFutureThread() {
try(ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
System.out.println("CompletableFuture 가상 스레드 작업 시작");
CompletableFuture<String> future = CompletableFuture.supplyAsync(()-> {
Thread.currentThread().setName("future1");
System.out.println("작업 1 시작 - 스레드 이름: " + Thread.currentThread().getName());
try {
Thread.sleep(800); // 800ms 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("작업 1 중단됨");
}
return "Data from Service 1";
}, executorService);
CompletableFuture<String> combindFuture = future.thenApplyAsync(result -> {
Thread.currentThread().setName("future2");
System.out.println("작업 2 시작 - 스레드 이름: " + Thread.currentThread().getName());
try {
Thread.sleep(500); // 500ms 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("작업 2 중단됨");
}
return result + " Data from Service 2";
}, executorService);
String finalResult = combindFuture.join();
System.out.println("최종 결과: " + finalResult);
} // try-with-resources 구문을 사용하여 ExecutorService를 자동으로 닫습니다.
System.out.println("메인 스레드 종료");
}
//결과
CompletableFuture 가상 스레드 작업 시작
작업 1 시작 - 스레드 이름: future1
작업 2 시작 - 스레드 이름: future2
최종 결과: Data from Service 1 Data from Service 2
메인 스레드 종료
여기서 중요한 점은 작업의 순서를 지정하여 처리할 수 있고, 가상 스레드 위에서 실행되고 있다는 점입니다.
위의 방식이 왜 효율적인지 이해가 안 될 수도 있습니다.
동작방식을 설명하자면 future1이 플랫폼 스레드에서 작업을 진행하다가 800ms를 대기하게 되면, 잠시 플랫폼 스레드에서 떼어져 있고 800ms를 가상 스레드가 대기하는 동안 플랫폼 스레드는 다른 일들을 처리합니다.
800ms가 지나고 해당 작업을 가진 가상 스레드가 다시 플랫폼 스레드 위로 올라가 다음 작업을 준비하게끔하고, future2라는 가상스레드가 플랫폼 스레드 위에 올라가서 작업을 시작합니다.
다시 500ms를 기다려야하니까 플랫폼 스레드에서 내려와서 500ms를 대기하고 (그와중에 플랫폼 스레드는 다른일 처리) 500ms를 대기한 뒤, 다시 가상 스레드가 플랫폼 스레드 위로 올라가서 다음 일을 처리하게 됩니다. 이렇듯 대기할 때 blocking없이 일을 처리하게 되므로 효율적으로 일을 처리할 수 있다고 할 수 있습니다.
생명 주기
가상 스레드도 플랫폼 스레드와 비슷한 생명주기를 가집니다.
- 생성(New):위에서
Thread.ofVirtual().unstarted(...)나,newVirtualPerTaskExecutor()에 작업을 제출할 때에는 스레드 객체가 생성되지만 실행되지는 않습니다. - 실행가능(Runnable):
start()메서드를 호출하거나 ExecutorService에 작업을 제출하면, 가상 스레드는 실행될 준비가 된 상태입니다. 이때 JVM이 가상 스레드를 적절한 플랫폼 스레드에 마운트(mount)해서 실행합니다. - 실행중(Running): 플랫폼 스레드 위에서 가상 스레드의 코드가 실행되고 있는 상태입니다.
- 블록(Blocked): I/O작업(네트워크 통신, 파일 읽기 등)을 기다리거나, 락(Lock)을 획득하기 위해 기다려야 할 때, 가상 스레드는 플랫폼 스레드에서 언마운트(unmount)되어 대기상태로 돌아갑니다. 이때 플랫폼 스레드는 다른 가상 스레드를 실행할 수 있습니다.
- 종료(Teminated): 가상 스레드의 작업이 모두 완료되거나, 예외가 발생하여 중단될 떄 종료됩니다.
가상 스레드와 플랫폼 스레드
스레드 모델
- 플랫폼 스레드(1:1 스케줄링)
- 운영체제가 직접 관리하고, 스케줄링 하는 스레드
- 플랫폼 스레드가 많아질수록 운영체제에 부담을 줍니다. 운영체제는 스레드마다 컨텍스트를 저장하고 관리해야하기 때문입니다.
- 위와 같은 조건 때문에 동시에 만들 수 있는 플랫폼 스레드의 개수는 제한적입니다.(보통 수천 개)
- 가상 스레드(M:N 스케줄링)
- M개의 가상 스레드가 N개의 플랫폼 스레드에 M:N으로 대응합니다.
- 운영체제가 아닌 JVM이 직접 관리하고 스케줄링합니다.
- 플랫폼 스레드에 연결될 수 있는 가상 스레드가 여러개이기에 플랫폼 스레드 자체를 적게만들 수 있어서 운영체제에 부담이 덜합니다.
- JVM은 수백만 개의 가상 스레드를 만들고 관리할 수 있습니다.
스택
스레드 스택은 스레드가 작업을 수행하는 동안 필요한 지역 변수, 메서드 호출 정보 등을 저장하는 메모리 공간입니다.
- 플랫폼 스레드 스택
- 보통 정해진 크기의 스택을 미리 할당 받습니다. 처음부터 꽤 크게 잡혀 있어서 메모리 낭비가 있을 수 있습니다.
- 가상 스레드 스택
- 매우 작은 크기의 스택을 할당 받아서 사용합니다. 작업하다가 더 많은 공간이 필요해지면, 그때그때 필요한 만큼만 메모리를 할당 받습니다.즉, 동적할당을 받습니다.
- 때문에 메모리를 효율적으로 사용할 수 있습니다. 많은 가상 스레드를 생성해도 전체 메모리 사용량이 크게 늘어나지 않습니다.
운영체제 스케줄링
- 플랫폼 스레드와 OS 스케줄링
- 플랫폼 스레드는 운영체제와 직접적으로 상태에 대해 보고하고, 운영체제는 해당 스레드를 언제 CPU에 할당할 지 스케줄링합니다.
- 스레드 간의 전환(컨텍스트 스위칭)은 운영체제 커널이 담당하며, 이 과정이 상대적으로 무겁습니다.
- 가상 스레드와 OS 스케줄링
- 가상 스레드의 스케줄링은 JVM이 담당합니다.
- 컨텍스트 스위칭 개념으로 동작하는게 아닌, 가상 스레드를 플랫폼 스레드에서 mount시키거나 unmount시키는 방식으로 작업을 진행합니다. 때문에 컨텍스트 스위칭에 대한 부담이 줄어듭니다.
Pinned Thread(고정된 스레드)
가상 스레드의 핵심 효율성은 블로킹 시 플랫폼 스레드에서 가상 스레드가 분리될 수 있다는 점인데, 몇 가지 경우에 대해서는 가상 스레드가 특정 플랫폼 스레드에 고정(Pinned)되어 분리되지 못하는 상황이 발생합니다.
- 원인
- 네이티브 코드 호출(JNI): 자바가 아닌 C/C++같은 다른 언어로 작성된 코드(네이티브 라이브러리)를 호출하는 경우, 분리할 수 없습니다.
synchronized블록: 해당 키워드를 사용해서 Lock을 획득하는 경우, 가상 스레드가 분리될 수 없습니다.- 운영체제 수준의 락(monitor)를 사용할 수도 있기 떄문에 이 경우 락 상태를 안전하게 이전하기 위해서 가상 스레드를 고정시키는 경우가 있습니다.
- 일부
ReentrantLock구현: 대부분 가상 스레드에 최적화되어있지만 일부 해당 키워드를 사용한 Lock은 가상스레드가 고정될 수 있습니다.
Context Switching
컨텍스트 스위칭은 CPU가 한 스레드에서 다른 스레드로 작업을 전환할 때 발생하는 비용을 말합니다. 이전 스레드의 상태를 저장하고 새 스레드의 상태를 불러옵니다.
위에거 가상 스레드는 컨텍스트 스위치 개념이 아닌, 마운트/언마운트 개념으로 움직인다 했습니다. 이 경우 발생하는 비용은 매우 가볍습니다. 가상 스레드의 상태를 힙 메모리에 저장하고, 다른 가상 스레드를 로드하기 떄문입니다. 플랫폼 스레드에 비해 저장해야할 정보의 양이 적기 떄문이죠.
가상스레드의 핵심 개념에 대해서 알아보았습니다. 다음에는 사용시 주의점, 예외처리 등에 대한 기술에 대해서 정리해보겠습니다.
'Java' 카테고리의 다른 글
| [java] EnumMap 정리 (1) | 2025.07.15 |
|---|