0. 들어가기 전
현재 진행 중인 프로젝트에서 AI 서버와의 통신 성능을 테스트하던 중, 요청이 증가할수록 처리량이 감소하고 처리 시간이 지연되는 현상을 확인했습니다. 기존에 AI 서버와의 통신을 위해 RestTemplate을 사용했으나, 이를 개선하기 위해 Java 버전을 17에서 21로 업그레이드하고, Virtual Thread를 도입하여 처리량을 4배 향상할 수 있었습니다. 이 경험을 통해 학습한 Virtual Thread에 대해 공유하고자 합니다.
Virtual Thread 내용 시작 전에 간단한 Java LTS 버전 차이를 확인하겠습니다.
Java 버전 차이
a. Java 8 (Java 1.8)
- 오라클이 java를 인수한 후 첫번째 LTS 출시 버전
- 32비트를 지원하는 공식적인 마지막 버전
- LocalDateTime과 같은 새로운 날짜, 시간 API 제공
- Unsigned Integer 계산
- 람다식 제공
- Stream API 제공
- Parallel GC 기본 GC로 설정
💡 32비트 vs 64비트
32비트 시스템은 최대 4GB의 메모리만을 사용할 수 있으며, 한 번에 32비트 크기의 데이터를 처리합니다. 반면 64비트 시스템은 이론상 18.4 엑사바이트까지 메모리를 지원하며, 한 번에 64비트 크기의 데이터를 처리할 수 있어 더 높은 성능과 대용량 데이터를 처리할 수 있습니다. 64비트 시스템은 또한 32비트 프로그램을 실행할 수 있지만, 그 반대는 불가능합니다.
💡Unsigned Integer vs Integer
1. Unsigned Integer
- 부호 없는 정수 타입을 의미합니다. 즉, 음수 값을 가질 수 없는 정수 타입입니다.
- 오직 양수와 0만 표현할 수 있습니다.
2. Signed Integer
- 일반적인 정수형으로 음수와 양수 모두 표현 가능합니다.
주요 차이점은 Signed Integer는 1비트를 부호 비트(음수 또는 양수를 구분하는 비트)로 사용하고, Unsigned Integer는 모든 비트를 값으로 사용한다는 점입니다. 이로 인해 Unsigned Integer는 더 큰 양의 값을 저장할 수 있습니다.
32비트 Signed Integer는 -2,147,483,648부터 2,147,483,647까지 값을 표현할 수 있지만,
32비트 Unsigned Integer는 0부터 4,294,967,295까지 값을 표현할 수 있습니다.
사용 예시로는 파일 크기, 메모리 주소 등 음수 값을 가질 필요 없는 값들을 표현할 때 Unsigned Integer를 사용할 수 있습니다.
b. Java 11
- Open JDK와 Oracle JDK 통합
- G1 GC가 기본 GC로 설정
- 람다 지역변수 var 키워드 사용 가능
- 컬렉션, 스트림 등 메서드 추가
c. Java 17
- recode class 키워드 사용 가능
- 난수 생성 API 추가
- 봉인 클래스(Sealed Class) 정식 추가
- Stream.toList() 사용 가능
- NumberFormat,DateTimeFormatter 기능 향상
- Spring Boot 3.0부터는 자바 17 이상을 지원
d. Java 21
- Virtual Thread 정식 추가
- Sequenced Collections 추가
- Record Patterns 사용 가능
1. 기존 Java의 Thread 모델
스프링은 톰캣 서버를 사용하기 때문에, 하나의 요청을 처리하기 위해 하나의 Thread를 생성(thread per request)합니다. 그렇기 때문에 동시 요청이 많다면 스레드의 수 역시 증가하는 형태입니다. 하지만 Java의 스레드는 실제 운영 체제 스레드 하나와 매핑되는 형태(Platform Thread)로 동작하기 때문에, 하나의 스레드가 가지는 스택의 크기와 리소스 양은 매우 큽니다. 쉽게 말하자면, OS가 최소 프로그램 하나를 돌리기 위해 생성하는 스레드를 Java는 내부 스레드 하나를 사용하는 데에 만들고 있는 것입니다. 물론, 내부적으로 적절한 만큼의 메모리를 할당하겠지만 그럼에도 OS 수준에서 사용될 메모리이기 때문에 적지 않다는 것입니다.
a. 효율이 나쁜 이유
위에서 말했듯이, Java의 스레드는 운영체제에 의해 스케줄링이기 때문에 효율이 나쁜것입니다.
스레드들은 작업을 수행하다 보면 I/O 작업 등으로 인해 유휴 상태에 빠지는 경우가 있는데, 이러한 유휴 상태가 되면 CPU를 필요로 하는 다른 스레드를 동작시키기 위해 컨텍스트 스위칭이 발생합니다. 여기서 크게 두가지 문제점을 발견할 수 있습니다.
a-1. 제한적인 Thread의 수
운영체제는 스레드 생성, 유지 비용이 비싸기 때문에 효율이 좋지 않습니다. 같은 하드웨어 자원으로 운영체제 수준의 스레드만을 생성해 낸다면 많은 양의 스레드를 만들기는 힘들 것입니다. 그렇기 때문에 자바에서 요청을 처리하기 위해 아무리 많은 스레드를 생성하려고 해도 운영체제에서 스레드 생성이 더 이상 불가능할 것입니다.
예를 들어 1000개의 스레드 생성이 한계인 서버가 있다면, 단순 계산으로 1000개의 요청까지만 처리가 가능하다는 것입니다. 최대로 생성된 1000개의 스레드들이 요청을 처리하다가 컨텍스트 스위칭을 하려고 봤더니 모든 스레드가 전부 I/O 작업을 수행중이라면 CPU 사용이 필요한 작업이 없으니 CPU는 아무것도 하지 않는 상태가 되는 것입니다. 결국 컨텍스트 스위칭이라는 것도 유휴 시간 동안 작업할 것이 있을 때만 위한 것이기 때문에 일이 없다면 의미가 없습니다.
이를 해결하기 위해 운영체제가 무한한 스레드를 제공한다고 가정해 봅시다.
그럼 모든 요청마다 스레드를 생성할 수 있고, 처리해야 할 요청이 매우 많아졌기 때문에 아무리 CPU가 쉬려도 해도 쉬는 시간을 만들어 내기는 쉽지 않을 것입니다. 하지만 이러한 방법은 운영체제에서 지원하지도 않으며 실제 컴퓨터의 메모리나 CPU의 자원이 한정적이기 때문에 불가능합니다.
이 문제를 해결하기 위해 scale-up 또는 scale-out을 통해 자원을 늘리고 받을 수 있는 요청 수를 늘릴 방법이 있습니다. 하지만 이 방법은 돈이 들어갑니다.
a-2. 컨텍스트 스위칭 비용
Java의 스레드는 OS에 의해 스케줄링되기 때문에 하나의 스레드에서 다른 스레드로 컨텍스트 스위칭을 하기 위해서는 OS레벨에서 동작해야 합니다. 만약 무한으로 스레드 생성이 가능하다고 해도 컨텍스트 스위칭 비용이 비싸면 효율이 나쁠 것입니다.
2. Virtual Thread
위에서 보았듯이, 자바의 기존 스레드 모델은 I/O가 빈번하면서도 동시에 처리할 양이 많은 프로그램을 구현하기에는 크게 효율적이지 못합니다. 이러한 한계점을 극복하기 위해 나온 것이 Virtual Thread입니다.
Virtual Thread란 OS가 아닌 JVM 위에서 스케줄링되는 경량화 스레드를 말합니다. 현재 주어진 자원 내에서 스레드를 최대한 많이 생성하면서도 효율적으로 컨텍스트 스위칭 비용을 줄일 수 있습니다.
a. 구조
Virtual Thread가 도입된 후로 Platform Thread Pool은 스케줄러에 의해 관리됩니다. 기본 스케줄러는 ForkJoinPool을 사용하며, Virtual Thread의 작업 분배를 담당합니다.
Virtual Thread가 가지는 데이터들은 다음과 같습니다.
- CarrierThread : 실제로 작업을 수행시키는 Platform Thread입니다. CarrierThread는 workQueue를 가지고 있습니다.
- Scheduler : ForkJoinPool에 해당합니다.
- runContinuation : Virtual Thread의 실제 작업 내용에 해당합니다.
b. 동작 원리
- 실행될 Virtual Thread의 작업인 runContinuation을 CarrierThread의 workQueue에 push 합니다.
- workQueue에 있는 runContinuation들은 forkJoinPool에 의해 work stealing 방식으로 CarrierThread에 의해 처리합니다.
- 처리되던 runContinuation들은 I/O 또는 sleep으로 인해 interrupt가 발생하거나 작업이 완료되면, work queue에서 pop 되어 park 과정에 의해 다시 힙 메모리로 되돌아갑니다.
간단하게 요약하면 Virtual Thread는 Platform Thread에 의해 실행되는 형태이고 이때 Virtual Thread가 블로킹 상태에 빠지면 Platform Thread는 해당 Virtual Thread가 아닌 다른 Virtual Thread를 수행한다는 것입니다.
이때, Virtual Thread는 Platform Thread와 따로 연관 관계를 갖고 있지 않기 때문에 Platform Thread는 아무 Virtual Thread를 수행할 수 있습니다.
마치 내부적으로 운영체제에서 사용하는 스케줄러 같은 것을 만들어서 기존 스레드 모델의 약점을 보완한 느낌입니다.
Virtual Thread는 JVM 위에서 논리적으로 생성되는 스레드이기 때문에 무한으로 생성이 가능하고 원래의 Platform Thread였다면 I/O가 발생했을 때 컨텍스트 스위칭 비용이 컸겠지만, 내부적으로 Platform Thread가 처리해야 할 Virtual Thread를 변경시키기만 하면 되기 때문에 비용이 적어집니다.
🤔 무조건 Virtual Thread가 좋은 것일까?
Virtual Thread를 사용하면 I/O를 통한 블로킹이 발생했을 때 적은 컨텍스트 스위칭 비용을 통해 다른 스레드의 일 처리가 가능하다는 점이 장점이었습니다. '그럼 I/O가 발생하지 않고 CPU의 연산이 많이 필요한 상황에서는 Virtual Thread가 무조건 좋을까?'라는 궁금증이 듭니다.
결론은 오히려 Virtual Thread를 생성하고 동작시키기 위한 오버헤드로 성능이 저하될 수 있습니다. Virtual Thread는 Platform Thread 보다 경량화된 스레드이기 때문에 CPU 작업에서는 Platform Thread가 성능상 우위를 보입니다.
또, 요청이 많지 않아서 블로킹이 발생한 시간 동안 다른 요청을 처리할 만큼의 스레드가 부족한 것이 아니라면..?
오히려 Virtual Thread를 도입하기 위해 코드가 복잡해지거나, 러닝 커브가 발생하는 문제가 발생할 수도 있습니다.
즉, Virtual Thread는 잦은 I/O로 인해 발생하는 CPU 사용 효율이 낮아지는 상황에서 적합한 것이지, 사용한다고 무조건 성능 향상이 일어나는 것은 아닙니다.
c. 사용 시 주의할 점
c-1. 스레드 풀 사용 금지
Virtual Thread는 라이플 사이클 동안 하나의 작업만 수행하도록 설계되어 있어 미리 여러 개를 만들어 놓고 돌려 사용하면 안 됩니다. Platform Thread에 의해 사용되고 없어지므로 따로 신경 쓸 필요도 없습니다. Virtual Thread가 필요할 때에는 매번 새롭게 만들어 주어야 하고, 요청 수를 제한하기 위해 스레드 풀링을 사용해야 한다면, 세마포어 등을 사용하는 게 좋습니다.
예를 들면, Virtual Thread는 제한이 없기 때문에 많이 만들 수 있지만, 내부적으로 DB를 사용한다면 DB 커넥션이 부족한 상황이 발생할 수 있습니다. 이럴 경우 Virtual Thread의 수를 제한할 필요가 있는데 Thread pool로 해결하려 하지 말고 Connection pool이 적합합니다.
🤔 세마 포어란?
세마포어(semaphore)는 특정 리소스의 접근을 제한하는 동기화 도구로, Virtual Thread의 수가 DB 커넥션 수를 초과하지 않도록 제어할 수 있습니다. 세마포어를 사용하여 Virtual Thread가 DB 커넥션을 과도하게 요청하지 않도록 제어할 수 있습니다.
c-2. ThreadLocal의 사용을 조심
Virtual Thread 별로 값비싼 리소스를 생성하면 성능이 크게 저하될 수 있습니다. 때문에 ThreadLocal 대신 많은 Virtual Thread에서 효율적으로 공유할 수 있는 캐싱 전략을 사용하는 것이 좋습니다.
예를 들어 ThreadLocal을 사용해서 각 가상 스레드가 자신만의 데이터베이스 연결을 생성한다면, 수많은 가상 스레드가 생성될 때마다 각각의 DB 연결이 새로 생성되므로 메모리 사용량이 급증합니다. 반면, 공유 캐시를 사용하여 모든 가상 스레드가 같은 DB 연결 풀을 공유한다면, 연결을 중복해서 생성하지 않고 리소스를 효율적으로 재사용할 수 있습니다.
c-3. Virtual Thread가 CarrierThread에 고정(Pinning)되어 언마운트(unpark)할 수 없는 경우
- synchronized 키워드가 사용된 코드를 실행할 때
- 네이티브 메소드 또는 외부 함수를 실행할 때
'Programming > Spring' 카테고리의 다른 글
[Spring] OAuth 없이 소셜 로그인 구현 (0) | 2024.11.27 |
---|---|
[Spring] API 공통 응답 포맷 (0) | 2024.11.17 |
[Spring] 동시성 처리 (18) | 2024.11.15 |
[Spring] Security + JWT + OAuth2를 이용한 로그인 구현 (5) - OAuth2.0 로그인 관련 클래스 생성 (0) | 2024.11.12 |
[Spring] Security + JWT + OAuth2를 이용한 로그인 구현 (4) - OAuth란? (0) | 2024.11.06 |