Java 21에서 VirtualThread가 등장함에 따라서 기존의 스레드 풀로 스레드를 관리하던 것과 달리
경량스레드를 활용해서 더많은 비동기 요청들을 처리할 수 있게 되었다.
그렇다면 생각해볼 문제가 있다. 기존의 반응형 프로그래밍으로 구성되었던 Spring WebFlux같은 반응형 프로그래밍을 사용하지 않아도 될것인가? 에 대한 의문이 생겼고 그에 알아보던 와중 흥미로운 글을 발견해서 이를 보고 얘기해보려고 한다.
https://inside.java/2024/10/28/javazone-virtual-threads/
Are Virtual Threads Going to Make Reactive Programming Irrelevant? – Inside.java
Virtual threads are cheap to create, to a point where you can have as many as you need. It allows for a new API, Structured Concurrency, that brings a new asychronous programming model, simpler than the reactive programming model. The last element y…
inside.java
다음과 같은 블로그를 참고했고 Youtube가 약 1시간정도 되기 때문에 요약해서 정리해보겠다.
일단 초반에 말하고있는 것은 VirtualThread의 등장으로도 아직 해결되지 않은 문제들이 있다. 예를 들어 3가지 일을 동시에 비동기로 처리한다고 가정하자. 이때 a,b,c라는 작업에 대해서 a 작업 수행, b 작업 수행, c 작업 수행이 병렬적으로 처리될 것인데 이때 b작업에서 문제가 생긴다면 어떻게 될까?
정답은 b작업에서 문제가 생겨도 아직 a작업과 c작업은 계속 실행되게 된후 마지막에 가서 오류를 판단할수 있다. 이는 스레드의 병렬성에서 구조적문제가 해결되지 않았는데 현재는 이를 preview API로 남겨놓아 이후 추가될 예정이다.
어쨋든 이런 문제들도 있고 어떤 문제들이 있는지 좀더 자세히 알아보자.
일반적으로 작성한 과거의 패턴이다. 이 경우 각 service에서 실행하는 메소드마다 100ms 가 걸린다면 총 200ms 가 걸린다.
코드가 순차적으로 실행되기에 각 page객체를 만드는것에 대하여 좀 오랜 시간이 걸린다.
2004년에는 이러한 문제를 해결하기 위해 동시성 프로그래밍이 나오게 되었고 그 결과는 다음과 같아졌다.
이렇게 하면 좋은 점은 알다시피 병렬적으로 수행되므로 100ms 로 시간을 2배나 줄일수 있었다.
그렇다면 이렇게 된 코드에는 어떤 다른 문제가 있을까?
동시성 이슈
1. Block threads
위 코드의 문제점은 thread를 블록한다고 말하고있는데 어떤 thread를 블록한다는 것일까?
첫번째로 f1 에사 ES thread가 블록당한다. (즉 스레드 1개 소비한다.)
두번째로 f2 에서 ES thread가 블록당하고 (스레드 1개 또 소비한다.)
세번째로 page를 만드는것에서 get메소드를 통해서 main thread가 블록당한다. ( get 메소드 호출을 통해서 각 스레드의 결과가 준비될때까지 "대기" 한다는 것이다. 이 과정은 스레드를 해당 스레드를 멈추게 하고 다른 작업을 처리하지 못하게 한다. 즉 비동기적으로 실행하려 했던 방식을 최종적으로 이부분에서 3개의 스레드를 블로킹하면서 성능상의 저하가 일어나게 된다.)
즉 플랫폼 스레드 자체를 블록하고 있으므로 스레드 블로킹때문에 좋지 못한 패턴이라고 말하고 있다.
2. Lead to a loose thread
Page객체를 만들때 2개의 스레드를 기다리게 된다. 이때 f1.get() 에서 문제가 생긴다면 어떤 일이 발생할 것인가?
f2.get() 은 절대로 불리지 않고 이때문에 f2.get()에서 스레드를 하나 소비하는중이기 때문에 thread 누수가 발생한다.
이 경우 thread 누수가 지속적으로 발생해 이제 이후에 application에서 thread가 동작하지 않아 어플리케이션이 마비되거나 성능이 느려지는 경우가 발생될 수 있다.
이러한 경우 문제를 확인하는 것이 더욱 어려워지는데 누수를 코드상에서 직접 일어나는지 확인할 방법이 없고 결국 어플리케이션의 실행을 통하여 확인할 수 있기 때문이다.
3. Hard To Debug
마찬가지로 디버그 하기 어렵다. 실제로 순서대로 동작하지 않기 때문에 순서가 뒤죽박죽이다.
하지만 반응형 프로그래밍은 콜백기반으로 동작하기 때문에 개발자는 선언만 해주면 되며, 이후에 반응형 프레임워크가 나머지를 처리하는 방식으로 동작하기 때문에 이러한 문제점들을 해결 할 수 있다.
Platform Thread?
Java에서 Application에 할당되는 Thread인데 java.lang.Thread는 커널스레드(플렛폼스레드) 를 단순히 Wrapping한 클래스이다.
성능은 다음과 같다.
- ~1ms to Start
- ~2MB 의 메모리 stack
- context switching cost ~0,1ms
기존의 관계는 1개의 커널스레드 - 1개의 플랫폼 스레드 가 1:1 매핑되는 관계였으며 1개의 request에 1개의 플랫폼 스레드가 할당되었다. 그리고 대부분의 서버에서 커널 스레드는 4000~5000개 사이로 생성되므로, 최대 어플리케이션의 한계는 즉 4000개의 request 정도만 동시요청을 받을수 있다. 물론 application이 최대 한도로 요청을 받았을 때이며 실제로 다른 리소스또한 필요하기 때문에 실제로 받을 수 있는 요청은 이보다 훨씬 적어진다.
여기서 말하고있는 것은 기존의 어플리케이션은 약 4000개의 동시요청을 처리 할 수 있었는데 한 요청 처리에 1μs의 CPU 시간이 소모된다고 가정하면, CPU는 이론적으로 초당 100만 개의 요청을 처리할 수 있었다는 것이다. 즉 1개의 플랫폼 스레드가 1초동안 블록당하면 100만개의 요청처리를 할수 없어진다는 말과 같아진다.
실제로 spring application또한 1개의 request에 1개의 스레드를 할당하므로 결국에는 스레드 블록이 존재하며 동시에 최대 4000~5000개의 request밖에 받지 못한다.
그렇다면 어떻게 문제를 해결해야 하나?
1. 반응형 프로그래밍을 사용하자. 반응형 프로그래밍은 하나의 스레드가 여러 작은 람다로 분할하여 하나의 스레드에서 여러 요청을 처리할 수 있다. 하지만 CPU의 리소스 또한 생각해야 하니 주의하자.
새로운 코드 스타일로 작성
위와 같은 스타일로 작성하게 된다면 작동 방식은 다음과 같아진다.
1. 이미지를 읽는 것에 대한 요청 발행 -> 블록하지 않음
2. 반응형 프레임워크에 등록
3. Link를 읽는것에 대한 요청 발행 -> 블록하지 않음
4. 반응형 프레임워크에 등록
그림으로 보면 다음과 같은데 요청을 보내고 block하는 것이 아니라 그냥 응답이오면 그때 반응을 하는 형태로 작동한다.
이런식으로 순서에 상관없이 요청이 쌓이게 되는데 요청이 많이 들어올수록 플랫폼 스레드가 쉬지 않고 일할수 있는 토대가 된다.
이는 CompletableFuture의 작동 원리와 관련이 있는데 CompletableFuture를 사용하게 되면 비동기 체이닝 방식과 콜백 처리로 반응형 프로그래밍 방식으로 동작하게 된다. 즉 스레드를 블록하는 것이 아닌라 각 작업을 등록해놓고 응답을 받는 형태로 작동하게 된다. 즉 작업이 위의 그림처럼 "스케쥴링" 방식으로 작업을 이어나가게 된다.
그 때문에 절대적으로 피해야 할것은 람다에 일부 차단 코드를 작성하는 것을 하면 안된다고 말하고 있다. 즉 다른 스레드를 블록하면 안된다.
하지만 이렇게 작동한 것에 대해서도 위의 3가지 문제점을 전부 해결하지는 못하는데 Debug가 불가능하다.
다음과 같이 error를 작성했음에도 불구하고 어떤 에러가 났는지 판단하기 힘들며 어떻게 어디서 문제가 생겼는지 단서를 곧바로 찾기 힘든 문제점들이 있었다.
그렇다면 플랫폼 스레드보다 훨씬 가벼운 스레드를 사용하면 어떨까?
그래서 1000배정도 가벼운 플랫폼 스레드를 대체할 수 있는 스레드를 만들기로 했으며 그게 Loom 프로젝트에서 나온 Virtual Thread라고 설명하고 있다.
하지만 가벼운 것만으로는 필수지만 충분하지 않은데 간단한 프로그래밍 모델을 제공해야 하며, 디버깅과 프로파일링이 가능해야 한다는 문제를 해결해야 했다.
VirtualThread의 동작원리
fork join pool에 있는 플랫폼 스레드에서 VirtualThread를 실행시키면 VirtualThread 가 생성되며, 각 플랫폼 스레드에 마운트된다.
즉 플랫폼 스레드 위에서 실행되는 것이다. 그럼이때 VirtualThread가 Block당하는 경우 ( DB/Network IO)같은 경우에 어떻게 될까?
일단 VirtualThread에서 실행중인지 PlatformThread에서 실행중인지 첫번째로 확인한다.
그리고 이후 VirtualThread에서 실행중이라면 VirtualThread를 Heap메모리로 이동시킨다. 이때 debugging 할수있는 stack 메모리까지 heap 메모리로 같이 이동시키게 된다.
그리고 다음 platform 스레드는 다시 새로운 VirtualThread를 생성할 수 있다. 이제 데이터를 다시 사용할수 있게, 즉 네트워크나 DB에서 데이터를 사용할 수 있다고 콜백이 온 경우 다시 플랫폼 스레드와 매핑을 시킬 수 있다.
이러한 과정이 VirtualThread의 가장 큰 특징인데. 플랫폼 스레드에서 stack Memory를 VirtualThread에 stack 메모리로 가져올수 있고 이걸 함께 heap메모리 영역으로 옮기면서 stack를 보존할 수 있다.
다만 여기서 이러한 stack 메모리를 garbage collector가 알아야 한다는 점에서 이러한 VirtualThread의 특징을 사용할 수 있게 개발했다고 한다.
이전과 바뀐점은 VirtualThread를 사용한다면 Virtual Thread에서 Block을 해도 기본 Platform 스레드가 차단되는 것이 아니므로 기존의 블로킹 코드를 작성하지 않아야 하는 API에서 블로킹 코드를 작성해도 된다는 점으로 바뀌었다는 점이다.
반응형 프로그래밍에서는 블로킹 코드를 여전히 작성하지 않아야 하지만 VirtualThread에서는 API가 그 Blocking을 책임지고 있으므로 개발자 입장에서는 block을 하든말든 상관없다는 것이다. API의 책임으로 넘어가게 된다.
즉 위와같이 반응형 프로그래밍처럼 PlatformThread를 아주 유용하게 사용할수 있다는 점이다.
여기서 말하고 있는것은 다음과 같이 블로킹하는 코드를 작성해도 VirtualThread를 사용한다면 반응형 프로그램이과 똑같이 작동한다는 것이다. VitrualThread API가 결국 Blocking code를 알아서 해결해준다.
또한 stack을 heap에 저장했다가 가져와서 사용하기 때문에 debug또한 자연스럽게 할수 있다.
구조화된 동시성 : Structured Concurrency
기존의 코드보다 더 나은 방식이 있다고 설명해주면서 구조화된 동시성에 대해서도 설명하고 있다.
두개의 병렬작업을 수행하고 join으로 블로킹하고 있지만, VirtualThread에서의 블로킹 작업은 괜찮다고 말하고 있으므로 다음과 같은 코드는 성능상의 문제는 없다.
또한 try-with-resource 구문으로 thread의 해제를 해주기 때문에 더이상의 누수 스레드문제 또한 없다. 그리고 함께 fork를 통해서
스레드를 생성하고 있으므로 하나의 스레드의 문제가 생긴다면 바로 상위 스레드를 종료하거나, 모두 실패하거나, 하나라도 성공등의 다양한 방식을 정할수 있으며 그에 따라서 StructeredTaskScope를 통해 스레드를 묶어주어, 스레드의 낭비또한 없다. 여기서 말하는 낭비란, 실패하는 경우 다른 스레드가 동작하고 그것이 끝날때까지 기다려야하는 상황을 말한다.
ScopedValues
또한 Scoped 방식을 적용한다면 스레드 로컬 방식을 적용할때 등장하는 문제점도 해결할 수 있다.
Spring Boot를 사용하는 경우 스레드로컬을 자신도 모르게 사용하는데 가장 큰 문제는 제거하지 않으면 계속 살아있을수 있다는 점이다. 어플리케이션을 재시작 하지 않는 한 누수된 스레드가 해당 값을 영원히 유지하면서 스레드 누스가 일어날 수 있으며, VM에서는 이 또한 최적화 되지 않는다.
주의점에 대해서 설명해보자면 WAS를 사용할때 API요청시 1개의 request당 1개의 Thread를 사용하게 되는데, Thread의 종료를 명시적으로 설정해주지 않으면 Thread를 종료하지 않고, 이전 사용자가 Thread를 사용해 ThreadLocal에 데이터를 저장해놓는다면 다음 사용자가 Thread를 다시 받아 상요한다면 내용을 위변조 할수 있는 문제점도 생긴다.
즉 따라서 Thread의 사용이 끝나는 시점에 ThreadPool에 반환을 하기 전 ThreadLocal을 초기화 해주는 작업을 해줘야 한다는 문제점이다.
이때 VirtualThread에서는 다음과 같은 방식으로 ScopeValue를 사용해 스레드 로컬을 사용할 수 있으며, 문제없이 사용할 수 있다.
즉 반응형 프로그래밍의 추가로, 문제해결까지 갖춘채로 기존의 코드를 유지한채 virtual Thread를 사용한다면 문제를 해결하고 개발을 할 수 있다고 말하고 있다.
'Spring' 카테고리의 다른 글
Spring Boot 프로파일링 및 Stress Test [Spring] (0) | 2024.12.21 |
---|---|
멀티모듈 프로젝트 Docker 빌드 전략: Path 기반 접근법[Docker&Github Action] (1) | 2024.12.14 |
[Spring] 응? 이게 왜 롤백이 안되지? - 비동기와 @Transaction (4) | 2024.10.21 |
[Spring] Redis기반의 EventStreamListener가 동작하지 않는 문제 (2) | 2024.10.15 |
[Spring] Redis Stream을 사용한 EventPublisher, EventListener 구현 (0) | 2024.08.05 |