본문 바로가기
  • Code Smell
Framework

JAVA21 전환기

by HSooo 2024. 6. 19.

JAVA21 전환기

자바21이 2023년 9월경 출시되었다. 그리고 자바21을 지원하는 Spring Boot 3.2가 2023년 11월쯤 릴리즈 되었고 자바21에 대한 오류보고나 스프링 3.2에 대한 깃허브 이슈들을 찾아본 뒤, 적용하는데에 큰 이슈가 없을 것으로 판단해 2024년 1월경 자바21로 전환하기로 결정했다.

회사는 지금 MSA 구조로 자바11을 사용중인데, 적은 리소스로 많은 트래픽을 담당하기 위해 블로킹 IO를 담당하는 MVC 프로젝트와 인터페이스 및 동시성 처리를 위한 webflux 프로젝트로 분리되어 있다.

즉 공식적인 Reactive IO 를 지원하지 않는 (redis, mongodb 등 대부분은 요즘 지원하지만..) 오라클(R2DBC는 공식이 아니라 제외했다.) 등을 사용하려면 MVC 프로젝트에서 접근해서 데이터를 반환하고, webflux 프로젝트가 데이터를 받아 추가적인 reactive를 지원하는 리소스의 데이터 함께 가공하고 처리해서 클라이언트에 넘겨주는 구조로 구성되어 있다.

이런 구조의 프로젝트가 한두개가 아니라 도메인별로 프로젝트가 나뉘다보니 약 4~50여개의 프로젝트로 구성이 되는데, 관리하기도 쉽지않고, 코드의 가시성 또한 좋지 않았다.

또한 팀에서 사용하는 사내 프레임워크도 같이 개발하는데 사내에서 같이 사용하는 공통 기능 뿐만 아니라 Spring에 대한 설정과 사내 정책들을 포함하는 보일러 플레이트 로직을 포함하다 보니 MVC와 webflux를 한 모듈에 구현할 수가 없었다. 이래서 스프링도 mvc용 boot-starter와 boot-starter-webflux 로 나뉘어서 릴리즈 되었었다.

자바21은 자바8과 11과는 다르게 출시와 동시에 적용하는데 고민하지 않고 곧바로 전환하기로 결정했는데 이유는 가상스레드의 정식 출시 때문이기도 하다. 가상스레드를 적용하면 mvc와 webflux로 분리되어 있는 프로젝트를 하나의 MVC로 합칠 수 있었다.

가상스레드

기존 플랫폼 스레드는 서블릿을 서빙하는데 was의 할당량 만큼이 최대치였다.

즉, spring boot 설정 중 server.tomcat.thread.max 의 기본값은 200이므로 동시 요청 받을 수 있는 1대의 어플리케이션으로는 200개가 한계인 것인데, 이를 보완한게 webflux.

웹플럭스는 event loop와 netty connector를 이용해 작업단위의 큐를 생성해서 작업이 완료될 때 까지 대기 하지 않고, 완료되면 다시 이어받는 형식으로 획기적인 요청량을 늘렸는데, 작업의 콜백을 다른 스레드가 이어 받을 경우 stacktrace의 추적이 힘들고, 중간에 blocking 되는 로직이 있다면 오히려 thread per request 만큼의 효율도 못낸다는 단점이 있었다. 이는 비즈니스 코드를 작성하는데 엄청난 제약을 따르게 했고, 또한 코드의 가독성도 매우 좋지 않았다.

그리고 이걸 보완하고자 등장한게 project loom. 그리고 이게 자바19 버전에 virtual thread 라는 이름으로 도입되었다.

virtual thread는 플랫폼에 비해 매우 가벼워서 기존의 200개의 요청량을 맞추는 정도의 서버의 리소스로도 수백~수천개까지 동시 생성해 요청을 받아 낼 수 있다. (JVM 8GB 정도로 max 세팅하면 약 7천개정도에 OOM이 떨어진다.)

하지만 가상스레드는 무적이 아니다. 사용 할 때 주의

DB나 다른 외부 자원과 연결을 위해서는 해당 인터페이스를 담당하는 객체들이 필요하다. 예를들면 다른 API를 찌르기위해 RestTemplate나 WebClient, 몽고의 MongoTemplate, 레디스의 RedisTemplate 등

가상스레드는 MVC 형태의 코드로 Webflux에 근접하는 성능을 낼 수 있었지만 여기서 한가지 문제가 있었는데, 그 요청을 받아낼 만큼의 외부 자원 커넥션을 생각해야 한다는 점이다.

기존 MVC에서는 RestTemplate 내의 HttpClient를 풀링해서 사용했는데 대략 한개의 서버에 HttpClient URL 라우팅을 하지 않은 상태에서 20개정도면 처리량/요청량 (tps) 에 맞출수 있었다. 즉 어차피 동시에 들어와 처리할 수 있는 요청은 200개가 한계였고, 그 200개를 처리하는데에 20개 정도면 충분했다.

그외의 몰리는 요청은 대기 큐에 있었을 것이고, 클라이언트가 Abort 내지만 않는다면 조금 기다려 요청을 처리해주었을 것이다.

하지만 가상스레드는 아니었다. 가상스레드 전환 후, 동시에 들어오는 요청은 200개가 아닌 유추 할 수 없는 사이즈가 동시에 생성되었고 그 모든 스레드가 HttpClient를 달라고 점유하는 것이었다. 그리고 간혹 connectionRequestTimeout 발생하기 시작해 에러 알림만 오히려 더 늘어나게 된 것이다.

이로인해 HttpClient를 늘려야 하는 상황이 발생한 것인데, 생각해본 해결책으로는..

  • HttpClient 풀 갯수를 늘린다? 몇개 까지 늘려야 하는지를 측정할 수가 없었다.

  • 그래서 고안한 것이 virtual thread 갯수의 제한? 근데 이러면.. 기존 플랫폼 스레드와 다른게 없었다.
    (가벼워서 200개가 아닌 1000개 쯤 해도 된다 정도?)

웹플럭스의 경우 이벤트 큐를 만들고 그 안에서 필요한만큼 사용하니 문제가 될게 없었지만, 가상스레드는 오히려 이게 문제였다.. 오랜 테스트 해본 결과 오히려 풀링하는 구조를 제거해 램이 허용하는 내에서 HttpClient를 생성하는 구조로 변경하는 것이 낫다고 판단해 변경하고 문제를 해결하긴 했다.

또한....

쿠버네티스에서 OOM이 떨어지거나 CPU core limit에 도달해 사용량을 100% 치게되면 해당 pod가 restart 하게 되는데, 플랫폼은 스레드의 제한이 있고 웹플럭스는 루프 돌며 일정량씩 처리하기 때문에 보통 그럴 일이 없지만 버추얼스레드는 아니었다. 정말 요청 오는대로 다 받아버리니 CPU 100% 되는 일이 간혹 있었고, HA 구성으로 여러 pod 가 있으니 큰 이슈는 없었지만 트래픽 몰릴때 간혹 몇몇 pod들이 restart 된 흔적이 보였다.

이것 또한 어떻게 분배를 잘 해야할 것 같다.

성능 자체는 엄청 좋은게 맞다.

로컬에서 각기 다른 세 프로젝트 (일반 MVC, 웹플럭스, 자바21 버추얼)로 Thread.sleep 으로 블로킹하는 간단한 로직으로 JMETER 테스트 해보니 일반 MVC에서 약 500tps, 웹플럭스에서 약 7000tps, 버추얼에서 약 5300tps까지 나왔고, pinning 현상 없이 오라클/몽고 조회 및 반환하는 테스트에서도 기존 MVC와 비교 할 수 없었다.

하지만 버추얼 뒷단에 플랫폼이 있는 경우 버추얼의 성능 향상을 기대하기 어렵다. 결국 최종단의 tps에 맞춰 모든 요청의 tps가 결정되는 구조이니 말이다.

웹플럭스보다 성능이 약간 낮은게 중론인듯 하지만 .map 과 .flatMap 그리고 .zip 등으로 시작해 그 안에서 끝내야하는 Reactive 코드 작성의 난이도와 코드 내에 숨어 있을지 모르는 블로킹으로 인한 성능 저하 등을 신경쓰지 않아도 되는 장점을 볼 때 전환하는 것이 좋은 것 같다.

댓글