티스토리 뷰

Java 서버 애플리케이션을 설계하다 보면 멀티스레딩은 피할 수 없는 주제가 됩니다. 요청 처리량을 높이기 위해 스레드를 늘리는 접근은 자연스럽지만, 그 선택이 항상 성능 향상으로 이어지지는 않습니다. 특히 최근 Java 21을 기점으로 Virtual Thread가 정식 기능으로 자리 잡으면서, 기존의 ThreadPoolExecutor 기반 설계와 어떤 기준으로 비교해야 하는지에 대한 고민이 다시 필요해졌습니다.

 

이 글에서는 멀티스레딩 자체를 설명하기보다는, Executor 선택이 시스템의 비용 구조와 성능 특성에 어떤 영향을 주는지를 중심으로 정리합니다. 특정 기술을 옹호하기보다는, 공식 문서를 기준으로 확인된 사실을 정리하고, 실무에서 판단 기준으로 삼을 수 있는 지점을 개인적으로 정리하는 데 목적이 있습니다.

 


 

Java에서 멀티스레딩 설계가 어려운 이유

 

Java는 초창기부터 스레드 모델을 언어 차원에서 지원해왔고, ExecutorService를 중심으로 한 추상화 역시 비교적 안정적으로 발전해 왔습니다. 그럼에도 불구하고 멀티스레딩 설계가 여전히 어려운 이유는, 단순히 API 사용법 때문이라기보다는 비용 모델이 명확히 드러나지 않기 때문인 경우가 많습니다.

 

플랫폼 스레드는 운영체제 스레드와 1:1로 매핑됩니다. 이 구조는 예측 가능성과 디버깅 측면에서는 장점이 있지만, 스레드 생성과 컨텍스트 스위칭 비용이 무시할 수 없는 수준이라는 점은 오래전부터 알려져 왔습니다. 그래서 실무에서는 자연스럽게 스레드를 무한히 생성하지 않고, 제한된 개수의 스레드를 재사용하는 방식으로 설계해 왔습니다.

 

이 지점에서 ThreadPoolExecutor는 사실상 표준에 가까운 선택이었습니다. 다만 이 선택이 “항상 최적”이었는지에 대해서는, 최근의 JVM 변화와 함께 다시 생각해볼 여지가 생겼습니다.

 


 

ThreadPoolExecutor 기반 설계가 만들어온 안정성

 

ThreadPoolExecutor는 스레드 생성 비용을 제어하고, 시스템 자원을 예측 가능한 범위 안에서 사용하도록 돕습니다. 코어 스레드 수, 최대 스레드 수, 큐 전략을 통해 처리량과 지연 시간을 조정할 수 있고, 이는 오랜 기간 실무에서 검증된 방식입니다.

 

공식 문서에서도 Executor 프레임워크는 작업 제출과 실행 정책을 분리함으로써 애플리케이션 구조를 단순화하는 목적을 가진다고 설명합니다. 실제로 이 구조는 멀티스레딩 문제를 코드 레벨에서 어느 정도 격리해 주었고, 운영 환경에서의 튜닝 포인트도 비교적 명확했습니다.

 

다만 이 접근은 항상 “스레드는 비싸다”는 전제를 깔고 있습니다. 따라서 스레드 수를 늘리는 대신 큐잉을 허용하고, 결과적으로는 요청 지연이 발생하는 구조를 받아들이는 경우도 많았습니다. 이는 CPU 연산 중심 작업보다는, I/O 대기가 많은 서버 애플리케이션에서 특히 두드러졌습니다.

 


 

Virtual Thread의 등장 배경과 JVM의 변화

 

Virtual Thread는 이러한 전제를 다시 질문하게 만듭니다. JEP 425와 이후 정식 반영된 문서에서는, Virtual Thread의 목적을 높은 처리량을 단순한 코드 모델로 달성하는 것으로 설명합니다. 즉, 스레드 수를 제한하고 큐를 조정하는 대신, 스레드를 가볍게 만들어 “많이 만들어도 괜찮은 구조”를 지향합니다.

 

중요한 점은 Virtual Thread가 단순한 라이브러리 기능이 아니라, JVM 스케줄링 모델 자체에 변화를 준다는 점입니다. Virtual Thread는 플랫폼 스레드 위에서 사용자 모드 스케줄링으로 동작하며, 블로킹 I/O 시에도 플랫폼 스레드를 점유하지 않도록 설계되었습니다.

 

공식 문서에서는 이를 통해 스레드 수와 동시성 수준을 거의 동일하게 가져갈 수 있다고 설명합니다. 이는 기존 Executor 설계에서 당연하게 받아들였던 “스레드 수 제한”이라는 개념을 다시 생각하게 만듭니다.

 


 

Virtual Thread Executor의 비용 구조를 바라보는 관점

 

Virtual Thread의 가장 큰 장점으로 자주 언급되는 부분은 “스레드 생성 비용이 매우 낮다”는 점입니다. 이는 사실이지만, 이 문장을 그대로 받아들이기보다는 맥락을 함께 보는 것이 중요하다고 느꼈습니다.

 

Virtual Thread는 OS 스레드를 직접 생성하지 않기 때문에 생성 비용 자체는 확실히 낮습니다. 하지만 이는 블로킹 I/O를 전제로 한 설계에서 가장 큰 효과를 발휘합니다. 공식 문서에서도 Virtual Thread는 CPU 바운드 작업의 성능을 높이기 위한 목적이 아니라고 명시하고 있습니다.

 

또한 모든 코드가 자동으로 Virtual Thread에 적합해지는 것은 아닙니다. synchronized 블록이나 네이티브 호출로 인해 발생하는 pinning 이슈는 여전히 고려 대상입니다. 이 부분은 “Virtual Thread를 쓰면 모든 것이 해결된다”는 식의 접근이 위험한 이유이기도 합니다.

 


 

Thread Executor와 Virtual Thread Executor의 성능 비교 관점

 

성능 비교를 할 때 가장 경계해야 할 부분은, 벤치마크 결과를 일반화하는 것이라고 생각합니다. 공식 문서와 여러 JEP 설명에서도, Virtual Thread는 특정 유형의 워크로드에서 이점을 가진다고 명확히 선을 긋고 있습니다.

 

I/O 대기가 많고, 요청당 처리 시간이 짧으며, 동시 요청 수가 많은 서버 애플리케이션에서는 Virtual Thread가 구조적으로 유리할 가능성이 큽니다. 반면 CPU 연산 비중이 높고, 스레드 간 경쟁이 많은 환경에서는 기존 ThreadPoolExecutor 기반 설계가 더 예측 가능한 결과를 줄 수 있습니다.

 

결국 성능이라는 것은 “빠르다 / 느리다”의 문제가 아니라, 어떤 병목을 어떤 비용으로 해소하느냐의 문제로 보는 것이 더 적절해 보입니다.

 


 

실무에서 Virtual Thread 도입을 고민할 때의 기준

 

실무 관점에서 보면, Virtual Thread는 기존 설계를 완전히 대체하기보다는 선택지를 하나 더 늘려준 도구에 가깝다고 느껴집니다. 이미 잘 튜닝된 ThreadPool 기반 시스템을 운영하고 있다면, 무조건적인 전환이 반드시 이득이 된다고 보기는 어렵습니다.

 

다만 신규 시스템이거나, 동기식 코드 모델을 유지하면서 동시성 수준을 높여야 하는 상황이라면, Virtual Thread는 분명히 매력적인 선택지입니다. 특히 코드 복잡도를 크게 늘리지 않으면서도 처리량을 확보할 수 있다는 점은, 장기적인 유지보수 관점에서 의미가 있습니다.

 

이 과정에서 중요한 것은, 기술 자체보다도 어떤 문제를 해결하려는지 명확히 정의하는 것이라고 생각합니다.

 


 

Executor 선택을 통해 다시 보게 된 멀티스레딩의 본질

 

Thread Executor와 Virtual Thread Executor를 비교하다 보니, 결국 다시 기본적인 질문으로 돌아오게 됩니다. “이 시스템의 병목은 어디에 있는가”, “우리는 어떤 비용을 감수하고 어떤 단순함을 얻고 싶은가”라는 질문입니다.

 

멀티스레딩은 여전히 복잡하고, 어떤 추상화도 모든 문제를 해결해주지는 않습니다. 다만 Virtual Thread의 등장은, 오랫동안 당연하게 받아들였던 설계 전제를 다시 점검해볼 계기를 제공했다고 느꼈습니다.

 

개인적으로는 특정 기술을 빠르게 도입하는 것보다, 공식 문서를 통해 의도를 이해하고, 현재 시스템의 특성과 맞는지를 차분히 검토하는 과정이 개발자로서 더 중요한 성장 포인트가 아닐까 생각하게 됩니다.

 


 

마무리하며

 

Java의 멀티스레딩 모델은 계속해서 진화하고 있지만, 그 변화의 속도보다 중요한 것은 기술을 선택하는 태도라고 느낍니다. ThreadPoolExecutor든 Virtual Thread Executor든, 모두 JVM이 제공하는 도구일 뿐이고, 정답은 항상 시스템의 맥락 안에 있습니다.

 

이 글이 특정 기술을 선택하라고 설득하기보다는, Executor 선택을 다시 한 번 차분히 고민해보는 계기가 되었으면 합니다. 저 역시 실무에서의 경험과 공식 문서를 계속해서 비교하며, 이 판단 기준을 조금씩 다듬어가고 있습니다.