이 글은 Java Virtual Thread, 가상 스레드가 만들어진 계기를 살펴본다. 가상 스레드 특징들을 공부하고, 도입 시 어떤 점들을 유의해야 하는지까지 간단히 살핀다. Virtual Thread의 자세한 동작 방식은 생략하고, 이후에 따로 글을 작성한다.
자바 스레드 모델 동작 흐름
기존의 Java에서 스레드를 생성하면, 운영체제(OS, Operating System)에 의해 관리되는 커널 스레드에 매핑된다. 스레드를 생성할 때마다 시스템 콜도 필요하고, 메모리도 많이 차지한다. 그래서 흔히들 스레드 생성은 비용이 굉장히 많이 드는 작업이라고들 한다. 그래서 자바에서는 스레드가 필요할 때마다 생성하는 게 아닌, 스레드를 미리 만들어놓고 할당/해제하면서 관리하기 위해 '스레드 풀'을 활용한다.
자바 스레드는 다양한 이름을 가진다. 커널에 의해 생성되는 게 아닌 사용자 레벨의 라이브러리를 통해 만들어져서 '유저 레벨 스레드'로도 불리고, '플랫폼 스레드'라도 불린다. 가상 스레드 도입 후에는 '캐리어 스레드'라는 이름으로 불린다. 해당 포스트에서는 일단 자바 스레드로 부를 예정이다. 아무튼, 기존의 스레드 모델에서는 스레드가 커널 스레드와 1:1 매핑된다는 점만 기억해두자.
Virtual Thread의 등장
Java의 기존 스레드 모델은 스레드 풀만으로는 처리량을 늘리기에 한계가 있었다. 단순히 스레드 수를 늘리자니 컨텍스트 스위칭 비용과 메모리 사용량 등 신경 써야 할 게 많았다.
여기서 처리량을 높이기 위해 비동기 API를 활용할 수도 있다. thread-per-request 방식을 포기하고 thread-sharing 방식을 활용하는 것이다. 이 방식은 코드가 계산을 수행하는 동안에만 스레드를 점유하고, I/O를 기다리는 동안에는 스레드를 놓는 등 소수의 스레드로 처리량을 높일 수 있었다. 다만, 이 경우 기존 코드를 비동기 스타일에 맞춰 변경해야 하고, 이 과정 속에서 코드의 복잡도가 상승한다. 또, 한 개의 요청이 서로 다른 스레드에서 실행될 수 있다 보니 stack trace는 유효한 컨텍스트를 제공하지 못하고, 디버깅도 어려워진다는 단점도 있다. (필자도 비동기 스타일을 처음 경험했을 때 프로그래밍 스타일이 익숙지 않아 서버를 터뜨린 경험이 있다.🥲 참고: [Reactor] 서버 느리게 만드는 API 개발하기 (feat: block))
위와 같은 비동기 스타일은 Java 플랫폼의 철학과도 충돌된다. 애플리케이션의 동시성 단위(비동기 파이프라인)가 더 이상 플랫폼의 동시성 단위(스레드)와 일치하지 않기 때문이다. OpenJDK JEP 444에 따르면 이 Java 플랫폼의 철학을 유지하기 위해 최대한 thread-per-request 방식을 유지하는 것이 바람직하다고 표현한다. 이를 위해서는 스레드를 더 효율적으로 구현해 더 많은 수의 스레드를 사용할 수 있도록 해야 한다.
OS 스레드는 더 효율적으로 구현할 수 없다. 각 언어나 런타임이 스레드 스택을 사용하는 방식이 다르기 때문이다. 하지만 Java 스레드와 OS 스레드와의 1:1 매핑을 끊는다면? 여기서 Virtual Thread이 등장한다. 제한된 수의 OS 스레드에 수많은 가상 스레드를 매핑함으로써, 많은 양의 스레드가 존재하는 것처럼 만들어냈다.
아래 이미지를 보면 커널 스레드는 플랫폼 스레드랑 매핑되어 있고, 플랫폼 스레드는 여러 개의 가상 스레드와 매핑된 것처럼 보인다. 어떤 한 작업을 실행할 경우, 가상 스레드는 플랫폼 스레드 위에서 실행된다. 가상 스레드가 블로킹 작업을 실행해야 한다면, 플랫폼 스레드에서 할당을 해제(unmount)할 수가 있다. 그러면 그동안 다른 가상 스레드가 해당 플랫폼 스레드를 통해 작업을 실행할 수 있게 된다.
참고로, OpenJDK에 기재된 Virtual Thread의 목표는 아래와 같다.
- thread-per-request 방식을 사용하는 애플리케이션의 하드웨어 활용도를 최적화한다.
- java.lang.Thread API를 사용하는 기존 코드에서 최소한의 변경만으로도 가상 스레드를 적용한다.
- 기존 JDK 도구들을 사용해 가상 스레드를 쉽게 트러블 슈팅, 디버깅, 프로파일링 할 수 있게 한다.
반대로, 목표가 아닌 것도 명시되어 있다.
- 기존의 전통적인 스레드 구현 제거한다.
- 기존 애플리케이션을 가상 스레드로 자동 전환한다.
- 자바의 기본적인 동시성 모델을 변경한다.
- 새로운 데이터 병렬 처리 구조(data parellism construct)를 제공한다.
- 대규모 데이터 셋의 병렬 처리는 stream API를 권장한다.
Virtual Thread 도입을 위해 유의할 점
📌 CPU 집약적인 작업에서는 효율이 떨어진다
가상 스레드는 동시 실행해야 하는 작업이 많고, 네트워크 I/O 등으로 인해 블로킹이 많은 상황에서 유용한다. 반대로 CPU 집약적인 작업에는 별다른 이점이 없으므로 parallel stream이나 recursive fork-join task를 고려해 보는 게 좋다.
📌 가상 스레드를 풀로 관리하지 마라
Virtual Thread는 JVM에 의해 관리되므로 시스템 콜이 필요하지도 않고, OS 컨텍스트 스위칭으로부터도 자유롭다. 메모리도 적게 들기 때문에 Virtual Thread를 새로 생성하는 것에 대한 부담도 거의 없다. 생성 비용이 굉장히 낮고 수에 제한도 거의 없기에 풀링(pooling)은 비효율적이고 의미가 없다. 필요할 때마다 새로 생성하고, GC 대상이 되는 게 훨씬 낫다.
📌 Rate Limiting - 처리율 제한
가상 스레드를 도입하면 동시 실행 가능한 작업 수가 크게 늘어난 덕에 애플리케이션의 처리량을 향상한다. 하지만 이로 인해 작업들이 호출하는 외부 서비스에 과부하가 걸릴 수 있다. 기존의 스레드에서는 스레드 풀이 작으면 자연스럽게 동시에 처리되는 요청 수가 제한되었다. DB 연결 같은 경우에는 connection pool이 제한할 것이다. 그 외에는 개발자가 직접 적절한 rate limiting을 구현해야 한다. 아래 코드는 Java tutorial에서 가져온 Semaphore를 이용한 예제이다.
public class RateLimitDemo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService service = Executors.newVirtualThreadPerTaskExecutor();
List<Future<String>> futures = new ArrayList<>();
final int TASKS = 250;
for (int i = 1; i <= TASKS; i++)
futures.add(service.submit(() -> get("https://horstmann.com/random/word")));
for (Future<String> f : futures)
System.out.print(f.get() + " ");
System.out.println();
service.close();
}
private static HttpClient client = HttpClient.newHttpClient();
private static final Semaphore SEMAPHORE = new Semaphore(20);
public static String get(String url) {
try {
var request = HttpRequest.newBuilder().uri(new URI(url)).GET().build();
SEMAPHORE.acquire();
try {
Thread.sleep(100);
return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
} finally {
SEMAPHORE.release();
}
} catch (Exception ex) {
ex.printStackTrace();
var rex = new RuntimeException();
rex.initCause(ex);
throw rex;
}
}
}
📌 Pinning 이슈
가상 스레드 스케줄러는 가상 스레드를 캐리어 스레드(=플랫폼 스레드)에 mount 시켜 실행한다. 가상 스레드가 블로킹 작업을 실행할 때면 가상 스레드는 캐리어 스레드에서 unmount 되어야 한다. 그래야만 캐리어 스레드가 다른 가상 스레드를 실행할 수 있다. 하지만 이 unmount가 불가능한 상황들이 존재한다.
- synchronized 메서드 또는 블록 실행
- 네이티브 메서드나 외부 함수를 호출
이렇게 가상 스레드가 캐리어 스레드에 고정되어 unmount 할 수 없는 것을 핀(pin)이 고정되었다는 표현으로 pin 상태로 부른다. 이게 이슈가 되는 이유는 pin 상태에서 블로킹 작업이 발생하면 캐리어 스레드까지 함께 블로킹되고, 가상 스레드를 실행할 수 있는 캐리어 스레드가 적어지기 때문이다.
따라서 블로킹 호출이 존재한다면 synchronized 대신 ReentrantLock을 사용하도록 권장하고 있다.
현재 코드에서 synchronized를 사용하는지는 IDE의 검색 기능을 활용하여 정말 쉽게 찾을 수 있다. 하지만 외부 라이브러리에서 synchronized를 활용하고 있는지 검사하는 건 굉장히 힘든 일이다. 많은 라이브러리들이 synchronized를 활용하지 않도록 개선하고 있지만, 아직 활용 중인 곳도 많을 것이다. 이때는 아래 JVM 옵션을 활용하면 pin 상태의 스레드가 블로킹될 때, 스택 트레이스를 확인할 수 있다. (단, 경고는 pin 위치마다 한 번만 출력된다.)
-Djdk.tracePinnedThreads=short
-Djdk.tracePinnedThreads=full
📌 ThreadLocal 대신 ScopedValue 사용하기
기존에는 수십~수백 개의 플랫폼 스레드만 존재했지만, 가상 스레드로 전환하면 수천~수십만 개의 스레드가 생성될 수 있다. 따라서 스레드마다 ThreadLocal 인스턴스를 만들면 메모리 사용량과 GC 부담이 커질 수 있다. ThreadLocal의 일부 기능은 ScopedValue로 대체하려는 듯하다. (Java 21 기준으로는 아직 preview 단계다.)
ThreadLocal을 사용하는 위치를 확인하려면 아래와 같은 JVM 옵션을 활용할 수 있다.
-Djdk.traceVirtualThreadLocals
참고
- OpenJDK JEP 444: Virtual Threads
- Baeldung OpenJDK Project Loom
- Baeldung Difference Between Thread and Virtual Thread in Java
- Java tutorial - Virtual Threads - https://dev.java/learn/new-features/virtual-threads/
- 우아한테크세미나 Java의 미래, Virtual Thread 영상
- 우아한기술블로그 Java의 미래, Virtual Thread
'Develop > Java+Kotlin' 카테고리의 다른 글
[Kotest] Kotest 활용 간단 가이드 (0) | 2024.10.27 |
---|---|
[Java] compiler message file broken 에러 (0) | 2024.03.17 |
[Mockito] Invalid use of argument matchers! 에러 (0) | 2023.09.27 |
[Java] UnaryOperator란? (0) | 2023.06.26 |
[Kotest] 오버로딩한 메서드 테스트하기 (feat: slot) (4) | 2023.03.05 |