| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- mysql
- spring boot
- 성능 최적화
- NIO
- monitoring
- helm
- DevOps
- CloudNative
- Kubernetes
- netty
- Kotlin
- webflux
- Java
- redis
- 트랜잭션
- kafka
- 백엔드개발
- SpringBoot
- 데이터베이스
- 동시성제어
- 성능최적화
- docker
- GitOps
- prometheus
- JPA
- selector
- 백엔드
- jvm
- RDBMS
- grafana
- Today
- Total
유성
Java 가상 스레드 해부 (Netty & Webflux를 대체할 수 있을까?) 본문
지난 몇 년간 Java 생태계는 성능을 위해 비즈니스 로직을 Mono, FLux 같은 껍데기 속에 가두어 왔다.
전통적인 Java 방식은 Blocking I/O 모델을 기반으로 하기 때문이고
이를 보완하기 위해 Event Loop 형식의 Netty, 이것을 활용할 수 있는 Webflux 등이 생겨나게 되었다.
물론 I/O 성능을 획기적으로 개선시켜왔으나, Blocking이 발생하는 부분에 대해서 '워커 스레드 사용' 또는 맞춤형 라이브러리를 사용해야 하는것과 기술 부채를 발생시킬 여지가 숙제로 남아있다.
하지만 자바 21에서 정식 도입된 가상 스레드는 이러한 구조를 바꾸어놓았다.
이 글에서는 가상 스레드가 어떻게 "가상화" 라고 불리는지, 그리고 정말로 리액티브 스택을 대체할 수 있는지 그 내부를 해부해 보고,
가상 스레드가 Netty & Webflux를 대체할 수 있는지 알아보자.
Java에서의 스레드란?
가상스레드를 설명하기 앞서 간단히 알아보면 다음과 같다.
스레드는 실행의 최소 단위(CPU 제어)로 어떤 일을 할지 저장한 "스택"과 데이터를 저장하는 "힙"을 가지고 있다.
하나의 스레드는 대략 1MB로 상당히 많은 리소스를 차지한다.
1. 가상 스레드의 본질: 캐리어 스레드 위의 '얇은 가상화'
가상 스레드는 OS가 제어하는 실행 단위가 실제로 수만 개씩 생성되는 구조가 아니다.
실제 실행을 담당하는 물리적인 단위는 기존 Java 스레드와 동일한 '캐리어 스레드'이다.
캐리어 스레드는 OS가 관리하는 최소 실행 단위이며, 자체적인 콜 스택과 힙 영역을 가진다.
가상 스레드는 이 캐리어 스레드 위에서 실행되는 논리적인 작업 단위에 불과하다.
즉, 소수의 캐리어 스레드(M)가 수많은 가상 스레드(N)를 이벤트 루프처럼 번갈아 가며 실행하는 M:N 스케줄링 구조를 띈다.
(캐리어 스레드 = 전통적 스레드 정확히 일치한다)
전통적 스레드 vs 가상 스레드 (Blocking의 차이)
전통적인 방식과 가상 스레드의 결정적인 차이는 Blocking이 발생했을 때 커리어 스레드를 점유하느냐에 있다.
val executor = Executors.newSingleThreadExecutor() // 1개의 캐리어 스레드라고 가정
executor.submit { println("job 1") }
executor.submit {
println("job 2")
Thread.sleep(1000) // Blocking 발생
}
executor.submit { println("job 3") }
- 전통적인 스레드 방식: job 2가 실행되다 sleep 상태에 들어가면, 해당 스레드 자체가 '정지' 상태가 된다. 따라서 뒤에 대기중인 job 3은 앞선 작업이 끝날 때까지 실행되지 못하고 밀리게 된다. 이것이 전형적인 Blocking 방식이다.
- 가상 스레드 방식: job 2에서 Blocking이 발생하는 순간 가상 스레드는 캐리어 스레드로부터 즉시 "분리" 된다. 덕분에 캐리어 스레드는 멈추지 않고 곧바로 job 3을 실행할 수 있다. 캐리어 스레드가 단 1개 뿐이라도 논리적으로 Non-Blocking처럼 막힘없이 동작하는 것이다.
이 방식은 하나의 호스트 OS 위에 여러 개의 컨테이너를 띄우는 Docker의 가상화 기술과 닮아 있다.
하지만 가상 스레드는 OS 스레드를 새로 만드는 것이 아니라, 이미 존재하는 캐리어 스레드의 점유권을 매우 빠르게 교체하는 수준이기에 '얇은 가상화'라고 부른다.
이러한 얇은 가상화를 가능하게 하며, 작업을 중단했다가 나중에 중단된 시점부터 다시 재개할 수 있도록 상태를 저장하는 핵심 기술이 바로 '스택 스냅샷(Stack Snapshot)'이다.
2. 내부 동작: 스택 스냅샷(Continuation)의 마법
가상 스레드가 Blocking I/O를 만났을 때 성능을 보존하는 비결은 스냅샷에 있다.
가상 스레드도 스택을 가지고 있으며, 특이한 점은 Context Switching 이 발생할 때 그 스택을 그대로 캡쳐해서 Heap에다가 넣어버린다.
그 후 실행이 필요하면 Heap에 있던 스택 스냅샷을 가져와 캐리어 스레드 위에 올린다.
각 상태를 보면 다음과 같다.
- Freeze (캡처): 실행 중 블로킹이 발생하면, JVM은 캐리어 스레드 스택에 쌓여있던 가상 스레드의 프레임(로컬 변수, 복귀 주소 등)을 빼서 힙(Heap) 메모리에 저장
- Yield (양보): 캐리어 스레드는 짐을 내려놓고 즉시 다른 일을 하기 위해 떠남
- Thaw (복구): I/O가 완료되면 힙에 있던 스냅샷을 다시 캐리어 스레드의 스택 위로 복사. CPU는 아무 일도 없었다는 듯 다음 줄부터 실행을 재개
이러한 과정을 가능하게 해주는 것
아쉽게도 이러한 과정은 Java에서는 할 수 없으므로 Continuation 클래스가 JVM 네이티브 엔진과 협업하여, 스택 포인터(SP)를 조작하는 intrinsic 방식을 사용하기에 가능한 일이다. (어셈블리어 또는 C++이 사용된다.)
CPU관점에서 보면 캐리어 스레드 1개를 실행시키는 것으로 생각을 하고있으나,
JVM관점에서는 여러개의 (가상)스레드를 캐리어 스레드 위해서 한번에 실행시키는 것이 된다.
내부적인 처리 방식 차이
- 멀티 스레드: CPU가 스레드를 바꿀 때마다 커널이 개입하여 TCB를 갈아끼우는 방식
- 가상 스레드: CPU는 하나의 캐리어 스레드를 실행하고 JVM이 그 위에서 스택 스냅샷을 갈아끼우는 방식
1) 컨텍스트 스위칭의 효율화
전통적인 멀티 스레드는 커널이 개입하여 TCB를 교체해야 하므로 커널 모드 전환 비용이 크고 무겁다.
반면, 가상 스레드는 OS가 캐리어 스레드 하나를 계속 실행하는 것으로 인식하게 만든다. 실제 교체 주체는JVM(유저 모드)이며, 커널 개입 없이 메모리 복사만으로 처리가 끝나 훨씬 빠르다.
2) 메모리 점유 및 확장성
OS 스레드는 보통 1MB의 고정 스택을 점유하여 생성이 제한적이다. 하지만 가상 스레드는 실행 시에만 캐리어 스레드의 스택을 빌려 쓰고, 대기 시에는 실제 사용량(수백 바이트)만큼만 힙에 보관한다. 이 가변 스택 구조 덕분에 최소한의 메모리로 수십만 개의 스레드를 동시에 운용할 수 있다.
3. 가상 스레드 vs Netty (Webflux)
자 그럼 "이제 Netty를 쓰지 않아도 되는가?" 에 대해서는 거의 그렇다고 생각한다.
기술 리스크, 기술 부채, 가독성을 생각하면 오히려 가상 스레드가 적합하지 않는지 고민이 된다.
일반적인 API 서버의 경우 충분히 대체할만 하고 이에 따라 Spring Boot도 3.2부터 Tomcat과 Jetty에 설정시 가상 스레드 사용하도록 전환된다는 점을 밝혔다.
(NIO 비동기 네트워크 통신과 가상 스레드를 결합해 Tomcat 성능을 크게 개선한 것으로 보인다.)
단, 메모리 형태에 따라서 검토의 여지는 충분히 있다.
한가지 예로 커넥션이 수백만 개의 연결을 유지해야 하는 푸시 서버는 여전히 Netty가 유리한 것으로 보인다.
100만개의 스냅샷 을 저장하는 것보다 스택 정보 없이 최소한의 상태 객체만 100만개를 유지하는 것이 메모리 관점에서는 훨신 가볍기 때문에 '만약 스냅샷의 비용이 Netty의 메모리 비용보다 훨신 크다면' Netty를 사용하는게 맞는 것으로 보인다.
4. 얇은 가상화가 마주한 현실적 제약
가상화 레이어가 얇다는 것은 그만큼 기존 자바의 제약에 노출되어 있다는 뜻이기도 하다.
- Pinning 현상: synchronized 블록 내에서 I/O를 수행하면 가상 스레드가 캐리어 스레드에 '고정'되어 버린다. 스택스냅샷을 떠서 Heap으로 옮겨야 하는데 스냅샷을 뜨는 접근이 막혀 발생한다. 캐리어 스레드도 해당 모니터락에서 자유롭지는 않다. (해결책: 외부 모니터 락인 ReentrantLock 의 사용)
- ThreadLocal의 메모리 폭증: 가상 스레드가 100만 개면 ThreadLocal도 100만 개이다. 힙 사이즈를 작게 유지해야 하는 가상 스레드에게 ThreadLocal은 메모리 폭탄이며, 이를 위해 자바는 참조 방식인 Scoped Values를 대안으로 가지고있다.
5. 마치며
단순히 가상 스레드가 Netty보다 좋다. 라는 식의 글을 쓴것은 아니다.
지난 수년간 우리는 확장성을 위해 리액티브라는 복잡한 껍데기를 뒤집어써야 했으나,
이제는 가장 자바다운 코드로 Netty와 비슷한 성능을 낼수있는 선택지가 생겼다는 점으로 작성했다.
결국 개발의 본질은 비용 대비 효율이다.
가상 스레드는 하드웨어 자원보다 귀한 '개발자의 시간'과 '유지보수의 편의성' 을 챙기면서도 확장성을 놓지지 않는, 실용주의적 자바 생태계의 새로운 표준이 될 것으로 확신한다.