Virtual Thread: Java 동시성의 혁신
Java는 JDK 21에서 가상 스레드(Virtual Thread)라는 기능을 도입했습니다. 이 기능은 전통적인 스레드 모델의 한계를 해결하여 더 많은 동시성을 효율적으로 처리할 수 있도록 합니다.
전통적인 Java 스레드 이해하기
전통적인 Java 스레드 모델에서는 각 스레드가 OS 커널 스레드와 연결됩니다. Java 스레드를 생성할 때, Java Native Interface(JNI)를 통해 커널 스레드를 할당합니다. 이러한 모델은 특히 많은 스레드를 처리해야 하는 상황에서 높은 오버헤드를 초래합니다.
전통적인 스레드의 주요 특성:
- 스택 크기: 약 2MB
- 생성 시간: 약 1ms
- 컨텍스트 스위칭 시간: 약 100µs
전통적인 스레드는 프로세스보다 가볍지만, 여전히 많은 메모리와 처리 능력을 요구하여 많은 수의 동시 작업을 처리하기에 비효율적입니다.
가상 스레드의 등장
가상 스레드는 JDK 21에 도입되어 Java의 동시성 처리를 개선합니다. 전통적인 스레드와 달리, 가상 스레드는 OS가 아닌 JVM에 의해 관리됩니다. 이는 스택 크기와 생성 오버헤드를 크게 줄여줍니다.
가상 스레드의 주요 특성:
- 스택 크기: 약 10KB
- 생성 시간: 약 1µs
- 컨텍스트 스위칭 시간: 약 10µs
가상 스레드는 플랫폼 스레드(커널 스레드와 유사) 위에서 동작합니다. 이는 하나의 플랫폼 스레드에서 여러 가상 스레드가 실행될 수 있음을 의미하며, 컨텍스트 스위칭 비용을 크게 줄입니다.
가상 스레드의 동작 방식
가상 스레드는 효율적인 스케줄링과 실행 모델을 갖추고 있습니다:
- Task Submission: 작업(runContinuation)은 플랫폼 스레드의 작업 큐에 제출됩니다.
- Work Stealing: ForkJoinPool 스케줄러는 작업을 carrier threads(플랫폼 스레드) 간에 분배합니다.
- Task Execution and Parking: 작업이 I/O 또는 다른 블로킹 작업을 만날 때, park되어 힙 메모리로 반환되고 carrier thread는 다른 작업을 처리할 수 있게 됩니다.
- Unparking: 작업이 준비되면 다시 스케줄링되어 carrier thread에 의해 실행됩니다.
JDK 21의 개선 사항
JDK 21에서는 다음과 같은 여러 개선 사항이 적용되었습니다:
- Park/Unpark Logic: 기존 LockSupport 클래스는 가상 스레드를 인식하도록 수정되어 기존 코드베이스를 변경하지 않고도 효율적인 컨텍스트 스위칭을 가능하게 합니다.
- I/O Operations: NIOSocketImpl 클래스는 가상 스레드를 처리할 수 있도록 로직이 추가되어, 논블로킹 I/O 작업에서도 성능 저하 없이 효율적으로 동작합니다.
- Thread Sleeping: Thread.sleep() 메서드도 가상 스레드를 사용할 수 있도록 업데이트되어, 수면 상태에서도 효율적으로 처리됩니다.
Virtual Threads 적용 및 성능 테스트
1. 프로젝트 설정 및 Virtual Threads 적용 방법
프로젝트 생성
- Spring Initializr 사용: 새로운 프로젝트를 생성할 때 Spring Initializr를 사용하여 Spring Boot 프로젝트를 설정했습니다.
가상 스레드 활성화
- application.yml 파일에서 Spring의 가상 스레드 기능을 활성화했습니다.
2. 성능 테스트 방법
테스트 도구
- NGrinder: 성능 테스트는 NGrinder를 사용하여 부하 테스트로 진행했습니다.
테스트 시나리오
- 전통적인 스레드 모델과 가상 스레드 모델을 비교하기 위해 /, /block, /query 엔드포인트를 테스트했습니다.
성능 테스트 결과 및 분석
비차단 요청 처리 (/) 결과
전통적인 스레드 모델 (Virtual Threads 비활성화)
- 총 Vuser: 30
- TPS: 20,603.4
- 최고 TPS: 24,266.5
- 평균 테스트 시간: 1.03ms
- 총 실행 테스트: 577,803
- 성공한 테스트: 577,803
- 에러: 0
Virtual Threads 활성화
- 총 Vuser: 30
- TPS: 20,483.4
- 최고 TPS: 24,097.0
- 평균 테스트 시간: 1.11ms
- 총 실행 테스트: 574,272
- 성공한 테스트: 574,272
- 에러: 0
결과 분석
- 전통적인 스레드 모델이 더 빠른 이유:
- 컨텍스트 스위칭 최적화: 비차단 요청 처리의 경우, 작업이 매우 짧고 빈번하게 발생합니다. 전통적인 스레드 모델은 OS 수준에서 최적화된 컨텍스트 스위칭을 제공하므로, 매우 짧은 작업에서는 오버헤드가 적습니다.
- 스레드 풀 관리: 전통적인 스레드 풀은 오랜 기간 동안 성능 최적화가 이루어져, 짧은 작업 처리에 있어 효율적입니다. 가상 스레드는 JVM에서 관리되지만, 짧은 작업에서는 전통적인 스레드 모델의 성능을 넘어서기 어렵습니다.
- JVM 오버헤드: 가상 스레드 모델은 JVM에서 추가적인 관리 오버헤드가 발생할 수 있습니다. 비차단 요청 처리와 같은 짧은 작업에서는 이 오버헤드가 성능 저하를 유발할 수 있습니다.
차단 요청 처리 (/block) 결과
전통적인 스레드 모델 (Virtual Threads 비활성화)
- 총 Vuser: 1,000
- TPS: 331.2
- 최고 TPS: 400.0
- 평균 테스트 시간: 1,392.67ms
- 총 실행 테스트: 2,000
- 성공한 테스트: 2,000
- 에러: 0
Virtual Threads 활성화
- 총 Vuser: 1,000
- TPS: 498.5
- 최고 TPS: 650.0
- 평균 테스트 시간: 656.16ms
- 총 실행 테스트: 2,000
- 성공한 테스트: 2,000
- 에러: 0
결과 분석
- 가상 스레드 모델이 더 빠른 이유:
- 경량 스레드: 가상 스레드는 전통적인 스레드에 비해 훨씬 가볍습니다. 따라서 차단 요청 처리와 같은 긴 작업에서 더 많은 스레드를 효율적으로 관리할 수 있습니다.
- 낮은 컨텍스트 스위칭 비용: 가상 스레드는 컨텍스트 스위칭 비용이 매우 낮아, 차단 요청 처리와 같은 상황에서 더 높은 성능을 발휘합니다.
- 효율적인 스케줄링: JVM 내에서 가상 스레드의 효율적인 스케줄링이 가능하여, 차단 작업에서의 성능 향상을 가져옵니다.
데이터베이스 쿼리 (/query) 결과
전통적인 스레드 모델 (Virtual Threads 비활성화)
- 총 Vuser: 정상 작동
- TPS: 정상 작동
- 평균 테스트 시간: 정상 작동
- 총 실행 테스트: 정상 작동
- 성공한 테스트: 정상 작동
- 에러: 없음
Virtual Threads 활성화
결과 분석
- 오류 원인:
- Driver 내부 동기화 사용: MySQL 드라이버가 내부적으로 synchronized를 사용하여 가상 스레드의 이점을 충분히 활용하지 못하고, 특정 스레드에 고정(pinning)되는 문제가 발생했습니다.
- 커넥션 풀 크기를 증가시켜도 동일한 문제가 발생했으며, 이는 드라이버의 동기화 메커니즘으로 인한 것입니다.
- 전통적인 스레드 모델이 정상 작동한 이유:
- 전통적인 스레드 모델은 MySQL 드라이버의 동기화 문제로 인한 성능 저하 영향을 덜 받습니다.
- 기존의 스레드 모델은 커넥션 풀과 드라이버의 동작 방식과 더 잘 호환됩니다.
성능 테스트 결과, 가상 스레드는 특정 상황에서 좋은 퍼포먼스를 보여주지만, 충분한 이해와 테스트 없이 한 번에 적용하기에는 위험할 수 있습니다. 가상 스레드를 도입할 때는 신중한 선택이 필요하다고 생각합니다.
먼저, Virtual Thread는 값싼 일회용품입니다. 생성 비용이 작기 때문에 스레드 풀을 만드는 행위 자체가 낭비가 될 수 있습니다. 필요할 때마다 생성하고, GC(Garbage Collector)에 의해 소멸되도록 하는 것이 좋습니다. 이를 통해 자원을 효율적으로 관리할 수 있습니다.
하지만 CPU 집약적인 작업에는 가상 스레드가 비효율적일 수 있습니다. IO 작업 없이 CPU 작업만 수행하는 경우, 전통적인 스레드 모델보다 성능이 떨어질 수 있습니다. 이는 컨텍스트 스위칭이 빈번하지 않은 환경에서는 기존 스레드 모델을 사용하는 것이 더 이득이기 때문입니다. 따라서, CPU 집약적인 작업에서는 전통적인 스레드 모델을 고려하는 것이 좋습니다.
또한, Virtual Thread 내에서 synchronized, parallelStream 또는 네이티브 메서드를 사용하면 virtual thread가 carrier thread에 park될 수 없는 상태가 됩니다. 이를 Pinned(고정된) 상태라고 하며, 위에 데이터베이스관련 성능테스트에서 확인할 수 있었습니다. 서드파티 애플리케이션이나, synchronized 과정 중 수백 밀리초가 걸리는 연산이 존재하는 경우에는, ReentrantLock으로 대체할 수 있을지 고려해봐야 합니다.
Virtual Thread는 수시로 생성되고 소멸되며 스위칭됩니다. 백만 개의 스레드를 운용할 수 있도록 설계되었기 때문에, 항상 크기를 작게 유지하는 것이 중요합니다. 가상 스레드의 thread local 사이즈가 커질수록 성능 저하(요요현상)가 발생할 수 있습니다. 이는 가상 스레드의 설계 철학과 맞지 않기 때문에, thread local 사용을 최소화하고 크기를 작게 유지해야 합니다.
결론적으로, 현재 Java 진영에서는 이러한 pinning 문제를 해결하기 위해 노력하고 있으며, 가상 스레드는 특정 상황에서 좋은 성능을 보여줍니다. 그러나 충분한 이해와 테스트 없이 한 번에 모든 환경에 적용하기에는 아직 위험 요소가 있습니다. 가상 스레드를 도입할 때는 이러한 제약 사항을 염두에 두고, 신중하게 선택해야 합니다.
특히, IO 작업이 많은 환경에서는 가상 스레드의 이점을 최대한 활용할 수 있지만, CPU 집약적인 작업에서는 전통적인 스레드 모델이 더 나은 선택일 수 있습니다. 이를 통해 각 작업의 특성에 맞는 최적의 스레드 모델을 선택하여 시스템의 성능을 최적화할 수 있습니다.