백엔드 개발 블로그
[Java] Java 가상 스레드 (Virtual Thread) 본문
Java 가상 스레드는 JDK 19부터 Preview, LTS 버전인 JDK 21부터 정식 탑재된 기능으로서
OS가 제공해주는 스레드를 그대로 사용하지 않고 가상의 경량 스레드를 사용하는 기능이다.
개인적으로는 JDK 8 이후로 Java 언어에는 이렇다 할 큰 변화는 없었다고 생각하는데
Project Loom(Java 언어에 경량화된 비동기 기능을 Java에 추가하기 위한 프로젝트)이 추진되면서
오랜만의 큰 혁신이 다가왔다는 생각이다.
기본 컨셉
- 기존의 코드, 로깅, 디버깅 툴들을 최대한 호환시키면서 경량화된 스레드를 지원한다.
- 개발자는 비즈니스 로직에만 집중하면서 Blocking IO에 대한 성능을 비약적으로 향상시킬 수 있다.
- 기존 스레드는 스레드 수의 제약이 컸던 반면 가상 스레드는 백만개까지도 큰 문제가 없다고 한다.
- 물론 다소의 오버헤드는 있다.
용어
- 플랫폼 스레드(Platform Thread): 기존에 사용하던 스레드로서 실제 OS에서 제공하는 스레드를 이용
- 가상 스레드(Virtual Thread): 신규로 추가된 경량화된 가상 스레드
- 캐리어 스레드(Carrier Thread): 가상 스레드 작업이 실제로 수행되는 플랫폼 스레드.
그래서 얼마나 좋아졌는데요?
성능 테스트가 잘 정리되어 있는 블로그가 있어 링크를 첨부한다.
서버가 수행하는 로직에 따라서 천차만별이기 때문에 정량적인 수치를 딱 짚기는 어렵지만
외부 API 호출 등 Blocking IO가 많고 그 소요시간이 병목인 로직에서는 시스템 자원을 효율적으로 활용할 수 있기 때문에
별도의 코드 수정 없이도 매우 큰 성능 향상을 얻을 수 있다.
주의점
1. ThreadLocal
기존 플랫폼 스레드(OS 스레드)를 사용할 경우 ThreadLocal에 저장되는 객체의 개수가 스레드 개수만큼 제한되며 자주 재사용되는 반면
가상 스레드의 경우 스레드가 수만개까지 생성이 될 수 있기 떄문에 메모리 사용량이나 객체의 초기화 비용이 매우 커질 수 있다.
따라서 ThreadLocal에 너무 무거운 객체를 담지 않도록 주의해야하며 사용 후에는 반드시 remove()로 정리해서 빠르게 GC될 수 있도록 해주어야 한다.
Jackson이나 Netty와 같은 유명 라이브러리에서도 비용이 큰 버퍼와 캐시 등을 ThreadLocal에 저장해두고 있기 때문에
가상 스레드의 개수가 너무 많아질 경우 애플리케이션에 악영향이 있을 수 있다고 한다.
참고) 이를 보완하기 위해서 'Scoped Values'란 신규 기능이 JDK 21에 Preview로 탑재되었다.
JEP 문두부터 "많은 수의 가상스레드를 사용하는 경우 ThreadLocal 대신 이거 쓰세요"라고 소개하는 기능이다.
가볍게 훑어보았을 때에는 기존 ThreadLocal에서 mutability와 lifetime을 제한함으로써
좀 더 효율적으로 사용하는 컨셉인 것으로 보이는데 나중에 좀 더 공부해봐야겠다.
2. Pinning
1. Thread-safety 를 위해 synchronized block을 사용하는 경우
2. 혹은 native 메서드나 foreign 함수를 실행할 경우
위 두가지 케이스에서 가상 스레드는 캐리어 스레드에 고정(pinned)되어 blocking이 있다고 해도
다른 가상 스레드로의 switching이 불가능하여 잠재적인 성능 이슈가 있다.
JEP-444 에서는 pinning이 너무 길어지지 않도록 synchronized block을 손보고
native 메서드나 foreign 함수의 경우 ReentrantLock을 이용해서 함수 실행 전후에 Guard를 추가하도록 가이드하고 있다.
3. 스레드 독점 현상 (Monopolization)
Blocking 없이 CPU를 오래 점유하는 가상 스레드가 다수 있을 경우
다른 가상 스레드의 처리가 지연될 수 있다.
이는 가상 스레드에 아직 선점 스케줄링(preemptive scheduling)이 구현되지 않았기 떄문이다.
쉽게 말해서 block이 없으면 현재 돌고 있는 가상 스레드가 아무리 오래 실행되었다고 해도 다른 스레드로 강제로 전환시키지 않는다.
4. DB 커넥션 풀 등 외부의 한정된 자원
가상 스레드가 아무리 효율적이라고 해도 DB 커넥션 풀 사이즈가 적다면 오히려 무수한 connection timeout을 맞이할 수 있다.
또 MSA 환경에서는 내 서버가 아무리 빨라도 저쪽 서버가 받아주지 못하면 더 큰 문제를 야기할 수 있으며 합법적 디도스 공격이 될 수 있다.
가상 스레드 이전의 OS 스레드를 사용할 때에는 최대 스레드 개수가 천연 rate limiter의 역할을 했다면
가상 스레드를 사용할 경우 리미터가 해제된 것과 다름이 없기 때문에 이를 유념해야 한다.
5. 가상 스레드 오버헤드
가상 스레드는 플랫폼 스레드에 비해서 다소의 오버헤드가 있기 때문에
Blocking이 없는 로직의 경우 오히려 더 성능이 느릴 수 있다.
따라서 가상 스레드를 도입하기 전 blocking 로직이 병목인 게 맞는지 검토할 필요는 있다.
Reactive Webflux는 이제 한물갔나요??
가상 스레드는 저수준의 OS 스레드를 경량화한 기능으로서 결국 '스레드'일 뿐
Webflux의 정확한 대체재는 아니다.
- 외부 API 호출 및 DB 요청과 같은 blocking IO가 많지만
- 복잡한 비동기성까지는 필요 없고 선형적인 로직이 주를 이루면서
- 단순히 blocking IO에 대한 성능 이점 때문에 Mono.map만 주구장창 연결하는 WebFlux를 사용했다면
->
가상 스레드가 더 적절한 대안이다.
- 복잡하고 병렬적으로 실행되어야 하는 로직이 있는 등 높은 수준의 비동기성이 필요하거나
- BackPressure 등 Reactive가 제공하는 정교한 기능이 필요하다면
->
WebFlux가 적합할 것이다.
극히 개인적인 의견으로는 예전에 WebFlux를 적용해보면서
MDC는 복잡하고 스택트레이스도 제대로 안나오고 디버깅도 복잡하고 예외 처리도 번거롭고...
여러모로 고통받았던 기억이 있어서 쌍수들고 가상 스레드를 환영하고 싶다.
이와 별개로 Structured Concurrency라는 새로운 Java의 concurrency 기능도 논의가 되고 있으며
JDK 21 현재 Preview 기능으로 도입되고 있다.
가상 스레드 + Structured Concurrency가 합쳐지고 Project Loom이 완성된다면
비로소 Reactive WebFlux, 코틀린 Coroutine과의 싸움이 되지 않을까 싶다.
Oracle의 개발자이자 Project Loom의 주요 개발자인 브라이언 게츠(Brian Goetz)는 2021년 팟캐스트에서 아래와 같이 언급하는데
Loom이 Reactive Programming을 죽일 것이라고 생각한다.
Reactive Programming은 과도기적 기술이었으며
우리가 가진 문제에 대한 반작용에 불과했다는 것을
머지 않은 미래에 모두가 깨달을 것이라고 생각한다.
만약 그 문제가 사라지게 된다면
Reactive는 우리가 원하던 해결책이 아니라는 것이 분명하게 드러날 거다.
Java가 가진 한계점을 극복하기 위해 라이브러리(Reactive)와 개량 언어(Kotlin Coroutine)가 인기를 끌고 있지만
만약 Java가 이런 문제점을 해결한다면 그들의 위치가 애매해진다는 점에는 공감이 간다.
오라클에서 Reactive를 죽이려는 한편 코틀린 진영에서는 가상 스레드 기능을 코루틴에 포함시켜 확장하는 등
Java로 밥벌어먹는 사람 입장에서는 그 미래가 매우 궁금하긴 하다.
(아직 Reactive도 제대로 못써보긴 했지만)
Spring에서는 언제 사용할 수 있나요?
Spring Boot 3.2에서는 spring.threads.virtual.enabled=true
옵션을 통해 정식으로 지원하며
심지어 Spring Boot 2에서도 Embedded Tomcat의 요청을 처리할 executor를 명시적으로 지정해줌으로써 적용이 가능하다.
(Spring Boot 2에서 제대로 최적화가 되었는지는 좀더 확인이 필요)
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
총평
가상 스레드는 확실히 Java 생태계의 패러다임을 바꿀 수 있는 기능이라고 생각하지만
흔들기만 하면 성능 좋은 서버가 탄생하는 마법봉은 아니다.
프로덕션에 적용하기 전 위 주의점에서 설명했던 사항들을 점검해보고 테스트와 시행착오를 거쳐야할 것이다.
- ThreadLocal에 무거운 객체를 담지 말고 사용 후 remove()로 정리할 것
- 사용하는 라이브러리가 가상 스레드 환경에서 잘 작동하는지, CPU와 메모리 사용량에 문제가 없는지 확인
- Pinning과 스레드 독점 현상이 문제가 되진 않을지 검토
- DB 커넥션 풀 사이즈 및 외부 API 등 여러 한정된 자원과의 상호작용을 고려
- 정말 가상 스레드가 필요한지, Blocking 로직이 병목이 맞는지 검토
프로덕션 코드는 아직 Spring Boot 3, JDK 17로도 전환을 못했는데 21은 언제 할 수 있을지 모르겠다.
참고 문서
- JEP 444: Virtual Threads
- JEP 446: Scoped Values (Preview)
- https://findstar.pe.kr/2023/07/02/java-virtual-threads-2/
- https://quarkus.io/blog/virtual-thread-1/
- https://spring.io/blog/2022/10/11/embracing-virtual-threads
- https://spring.io/blog/2023/09/09/all-together-now-spring-boot-3-2-graalvm-native-images-java-21-and-virtual