Java 17에서 21로, 가상 스레드는 무엇을 바꿨을까¶
가상 스레드는 코드를 더 빨리 실행하는 스레드가 아닙니다. 입출력을 기다리는 요청과 운영체제 스레드를 일대일로 묶지 않아, 읽기 쉬운 동기식 코드를 더 많은 동시 요청에 사용할 수 있게 합니다.
서버 요청 하나가 데이터베이스 응답을 100ms 기다리고 실제 CPU 계산은 2ms만 한다고 하자. 전통적인 요청별 플랫폼 스레드 모델에서는 기다리는 동안에도 그 운영체제 스레드가 요청에 묶여 있다. 동시에 기다리는 요청이 많아지면 스레드 스택 메모리와 스케줄링 비용 때문에 풀 크기를 제한해야 하고, 그보다 많은 요청은 큐에서 기다린다.
Java 21에서 정식 기능이 된 가상 스레드(virtual thread)는 이 대기의 표현 비용을 줄인다. 요청 코드는 평소처럼 데이터베이스 호출의 다음 줄에서 결과를 사용할 수 있지만, JVM은 지원되는 입출력 대기 동안 실제 CPU를 실행하는 플랫폼 스레드를 다른 요청에 돌려준다.
Java 17과 21의 차이는 “스레드 풀을 크게 만든 것”이 아니다¶
Java 17에서 일반적인 자바 스레드는 플랫폼 스레드(platform thread)다. 운영체제 스레드와 긴밀하게 일대일 대응하고, 각 스레드는 네이티브 스택을 가진다. 그래서 보통 크기가 정해진 작업자 풀을 만들고 요청을 나눠 맡긴다. 풀이 가득 차면 새 요청은 기다리거나 거절된다.
Java 21은 Thread.ofVirtual()과 Executors.newVirtualThreadPerTaskExecutor()로 작업마다 가상 스레드를 시작할 수 있다. 가상 스레드의 실행 상태는 JVM이 관리하고, 실제 명령을 실행할 때만 캐리어 스레드(carrier thread)라고 부르는 플랫폼 스레드 위에 올라간다. 가상 스레드 수와 캐리어 수가 같을 필요가 없다.
Java 21의 변화가 가상 스레드 하나뿐인 것은 아니다. 레코드 패턴, switch 패턴 매칭과 순서가 있는 컬렉션도 정식 기능이 됐다. 다만 요청 처리 방식을 바꾸는 서버 관점에서는 가상 스레드가 가장 직접적인 변화 가운데 하나다.
탑재와 분리는 어떻게 일어날까¶
가상 스레드가 캐리어 위에서 코드를 실행하는 상태를 탑재(mount)라고 한다. 소켓 읽기처럼 JVM이 분리할 수 있는 대기 작업을 만나면 현재 호출 스택의 실행 상태를 보존하고 캐리어에서 내려온다. 이를 분리(unmount)라고 한다. 비워진 캐리어는 실행 가능한 다른 가상 스레드를 탑재한다.
입출력이 준비되면 기다리던 가상 스레드가 다시 실행 가능한 상태가 된다. 이전과 같은 캐리어로 돌아가야 할 이유는 없다. 사용 가능한 캐리어에 탑재되어 블로킹 호출의 다음 줄부터 계속 실행한다.
가상 스레드의 중단·재개 가능한 실행 상태를 설명할 때 컨티뉴에이션(continuation)이라는 말을 쓴다. 호출 스택이 하나의 운영체제 스레드 네이티브 스택에만 고정되지 않고, 필요할 때 힙의 스택 조각으로 보존될 수 있다는 개념이다. 애플리케이션은 이 내부 구조를 직접 다루지 않고 일반 Thread API와 순차 코드를 사용한다.
블로킹 코드가 그대로인데 왜 더 많은 요청을 다룰 수 있을까¶
플랫폼 스레드 모델에서는 소켓 결과를 기다리는 자바 스레드와 운영체제 스레드가 함께 멈춘다. 가상 스레드 모델에서는 JDK가 지원하는 소켓·대기 연산에서 가상 스레드만 대기 상태로 남고 캐리어는 풀려난다. 블로킹 호출이 사라진 것이 아니라, 기다리는 자바 작업과 운영체제 스레드의 일대일 결합이 사라진 것이다.
그래서 JDBC처럼 동기식 API로 작성된 서버 코드를 이해하기 쉬운 순서 그대로 유지할 수 있다. try/catch, 지역 변수, 스택 추적과 디버거가 업무 흐름을 따라간다. 비동기 콜백으로 요청 상태를 여러 단계에 나누는 부담을 줄이는 것이 가상 스레드의 중요한 목적이다.
가상 스레드는 풀에 재사용하지 않는다. 작업마다 만들고 끝나면 버린다. 값싼 가상 스레드를 다시 풀로 제한하면 플랫폼 스레드 시대의 큐와 스레드 로컬 상태 누수 문제를 되살린다. 제한해야 할 대상은 가상 스레드 개수 자체보다 데이터베이스 연결과 외부 API 요청률처럼 실제로 부족한 자원이다.
Java 21에서는 캐리어 고정 구간을 확인해야 한다¶
고정(pinning)은 가상 스레드가 기다리면서도 캐리어에서 내려오지 못하는 상태다. Java 21에서는 synchronized로 보호된 구간 안에서 블로킹하거나 네이티브·외부 함수 호출 경계에서 대기할 때 대표적으로 발생할 수 있다. 짧은 synchronized 계산 자체를 모두 없앨 필요는 없다. 잠금을 잡은 채 네트워크나 파일 작업을 오래 기다리는 경로가 문제다.
고정된 가상 스레드가 많아지면 사용할 수 있는 캐리어가 줄고, 실행 가능한 다른 가상 스레드가 대기한다. Java Flight Recorder의 가상 스레드 고정 사건과 -Djdk.tracePinnedThreads 진단을 사용해 추측이 아니라 실제 스택을 찾는다. 이후 JDK의 JEP 491은 모니터 잠금으로 인한 고정을 개선했지만, Java 21을 운영한다면 Java 21의 동작을 기준으로 봐야 한다.
CPU 계산도 가상 스레드를 분리하지 않는다. 이미지 변환이나 큰 암호화 계산을 수십만 가상 스레드로 실행해도 동시에 계산하는 한계는 CPU 코어다. 오히려 실행 가능한 작업과 요청 상태만 늘 수 있다.
가상 스레드가 없애지 않는 상한¶
데이터베이스가 동시에 안정적으로 처리할 수 있는 연결이 50개라면 가상 스레드 10만 개를 만들어도 연결 50개만 질의를 실행한다. 나머지는 연결 풀에서 기다린다. 외부 API의 요청률 제한, CPU 코어, 메모리에 보관하는 요청 본문과 응답 버퍼도 그대로다.
따라서 가상 스레드 도입 뒤에는 스레드 풀 사용률보다 다음을 본다.
- 동시에 살아 있는 요청 수와 요청 한 건이 보관하는 메모리
- 데이터베이스 연결 풀 대기와 실제 질의 동시성
- 외부 API의 진행 중 요청 수, 시간 제한과 요청률 제한
- CPU 사용률과 실행 가능한 가상 스레드의 증가
- 캐리어 고정 사건과 긴 네이티브 호출
- 사용자 취소와 시간 초과 뒤에도 남아 있는 하위 작업
진입 요청을 무제한으로 받아 연결 풀 앞에 쌓지 않는다. 세마포어, 요청률 제한과 부하 차단으로 희소 자원마다 동시 실행 상한을 둔다. 가상 스레드는 대기 비용을 줄였지 과부하를 처리 능력으로 바꾸지 않는다.
ThreadLocal 호환성은 편리하지만 공짜가 아니다¶
Java 21의 가상 스레드는 ThreadLocal을 지원해 요청 식별자나 보안 맥락을 사용하는 기존 코드와 호환되기 쉽다. 하지만 플랫폼 스레드 풀 몇십 개를 전제로 큰 버퍼나 클라이언트 객체를 캐시하던 사용 방식은 위험하다. 가상 스레드마다 그런 값을 만들면 동시 요청 수만큼 메모리가 늘어난다.
범위 값(Scoped Value)은 불변 맥락을 명확한 코드 범위 안에서 전달하기 위한 대안이다. 다만 Java 21에서는 미리 보기 기능이므로 가상 스레드 정식 기능과 같은 안정성 수준으로 취급하지 않는다.
구조적 동시성은 관련 작업의 생명주기를 묶는다¶
상품 정보와 재고 API를 동시에 호출한다고 하자. 둘 중 하나가 실패했는데 다른 호출은 계속 실행되고, 부모 요청은 이미 시간 초과됐는데 하위 작업이 남으면 자원을 낭비한다. 구조적 동시성은 관련된 하위 작업을 하나의 코드 범위에 두고 성공·실패·취소를 부모 작업과 연결하는 개념이다.
가상 스레드는 하위 작업을 싸게 표현하고, 구조적 동시성은 그 작업들의 생명주기를 이해하기 쉽게 만든다. 하지만 Java 21의 StructuredTaskScope는 미리 보기 API다. 개념은 적용할 가치가 있어도 장기 운영 코드에 사용할 때는 다음 JDK에서 API가 바뀔 가능성과 업그레이드 정책을 함께 정해야 한다.
CompletableFuture·리액티브·가상 스레드는 서로 다른 문제를 푼다¶
CompletableFuture는 몇 개의 독립적인 비동기 계산을 단계로 조합할 때 유용하다. 실행기, 예외와 취소 전파를 명시해야 하고 연결이 길어지면 실제 업무 순서를 따라가기 어려울 수 있다.
리액티브 스트림은 비동기 데이터 흐름과 역압을 자료형과 연산 연결 안에서 표현한다. 지속적인 스트림, 많은 연결과 생산자·소비자의 속도 조절이 핵심인 시스템에 강하다. 대신 호출 스택과 디버깅, 블로킹 라이브러리 통합이 복잡해질 수 있다.
가상 스레드는 요청·응답형 서버에서 동기식 블로킹 코드의 읽기 쉬움을 유지하면서 많은 입출력 대기를 다루는 선택이다. 역압이 자동으로 생기지 않고 CPU 계산을 빠르게 하지도 않는다. 무엇이 더 최신인지가 아니라 현재 문제의 중심이 몇 개의 비동기 작업 조합인지, 지속적인 스트림 제어인지, 많은 독립 요청의 입출력 대기인지로 고른다.
도입 판단은 현재 스레드가 무엇을 하는지에서 시작한다¶
스레드 덤프와 추적에서 플랫폼 스레드 대부분이 JDBC·HTTP 호출을 기다리고, 스레드 풀 대기 때문에 요청이 지연된다면 가상 스레드가 직접적인 후보가 된다. 반대로 CPU가 이미 포화거나 데이터베이스 연결 풀과 잠금이 병목이면 가상 스레드만 바꿔도 처리량은 늘지 않는다.
처음에는 한 요청 경로를 가상 스레드로 옮기고, 같은 부하에서 처리량·p95·p99·메모리·연결 풀 대기·고정 사건을 비교한다. 성공 기준은 “스레드를 많이 만들었다”가 아니다. 코드는 단순해졌고, 실제 희소 자원의 상한을 넘지 않으면서 동시 입출력 요청의 대기가 줄었는가다.