본문 바로가기

3. 기술 공부/Java (Spring, Spring Boot)

[Java Servlet] 3. Servlet 3.0, 3.1 그리고 Spring MVC

 

Connected Posts

2021.07.16 - [3. 기술 공부/Java (Spring, Spring Boot)] - [Java Servlet] 1. Sync? Async?, Blocking? Non-Blocking?

2021.07.16 - [3. 기술 공부/Java (Spring, Spring Boot)] - [Java Servlet] 2. Servlet과 Servlet Container

2021.07.16 - [3. 기술 공부/Java (Spring, Spring Boot)] - [Java Servlet] 3. Servlet 3.0, 3.1 그리고 Spring MVC2021.07.16 - [3. 기술 공부/Java (Spring, Spring Boot)] - [Java Servlet] 3. Servlet 3.0, 3.1 그리고 Spring MVC

 

Goal

[Java Servlet] 시리즈 포스트에서는 Java Servlet에 대한 간단한 개념과, Java Servlet 역사에 큰 breakpoint가 있었던 Servlet 3.0(Async Servlet), 3.1(Non-blocking I/O)에 대해 정리해보려고 한다. 이번 포스팅에서는 어떤 breakpoint가 있었는지 살펴보고, 실제로 벤치마크 테스트를 한 결과를 보겠다.

 

1. Servlet 3.0 Async Servlet

지난 포스팅에서 살펴보았던 것처럼, Client <-> Servlet Container 단의 Asyn/Non-blocking 적용으로 뒷단 Servlet API에서도 Async/Non-blocking 지원이 필요해졌다. Async servlet Servlet 3.0부터 추가되었는데, 애플리케이션에 들어오는 request 비동기적인 방식으로 처리할 있도록 지원하게 되었다.

 

  • Async 방식
  • Request Thread는 Processing Thread에게 할 일을 위임하고 반환되어 재사용
  • Request Thread의 고갈을 막을 수 있지만, Active Thread의 개수가 줄어드는 것은 아님

Async Servlet을 사용하면 이제 서블릿 컨테이너의request thread 들어오는 request 받아 request processing 담당할 다른 background thread에게 넘기게 된다. (당연히 background thread 풀로 관리가 되어야함) 그리고 request thread client에게 응답을 보낸다. 그리고 할일을 다한 request thread background thread에게 request 넘기자마자 thread pool 회수되어 재사용될 있게 된다.

 

그러나,  접근 방식 자체는 request thread pool 고갈 문제를 해결할 있지만, 시스템 리소스 소비 문제를 해결할 수는 없다. 왜냐하면 다른 background thread request 처리하기 위해 생성되고, 리퀘스트를 프로세싱하는 작업 자체가 non-blocking으로 작동하지 않으면 동시에 활성화되어있는 active thread 개수는 줄어들지 않아 리소스 사용량이 개선되지 않기 때문이다.

그러므로 Async servlet만으로는 기존 문제를 완전히 해결할 없다는 한계가 있으며, 또다른 blocking을 막기 위해 프로세싱하는 작업 자체를 non-blocking 방식으로 동작하는 작업만 해야할 것이다.

 

또 다른 한계는 request 처리하는 부분에 async 적용되었을 뿐, request response 서빙하는 I/O Traditional I/O만을 허용하여 blocking 된다는 점이다. I/O 단에서 다수의 thread 블락되게 되면 thread starvation을 유발하고 성능에 영향을 있을 것이다.

 

2. Servlet 3.1 Non-blocking I/O

 

  • Non-blocking I/O 추가
  • Servlet 3.0에서 tradition I/O만 허용하였던 문제점을 개선
  • Write/Read Listener를 추가하여 write/read에 대해 non-blocking으로 동작

그래서 Java EE 7 포함되어 배포된 Servlet 3.1에는 non-blocking IO 포함되었다.

만약 우리가 client에게 사이즈가 JSON file 돌려주어야한다고 가정했을 때, `OutputStream` NIO buffer 우선 write 하고, selector/channel 매커니즘을 사용하여 buffer client 의해 비워져야할 것이다. 만약 클라이언트가 열악한 네트워크 상황에 있다면, `InputStream` `OutputStream` blocking이기 때문에 `out.write()` buffer 비워질 때까지 기다려야할 것이다.

이러한 문제를 Servlet 3.1에서는 `Write/Read Listener` 소개하여 해결하게 되었다.

 

`WriteListener` Servlet container 의해 호출되는 `onWritePossible()` 메소드를 가진 인터페이스이다. Output 스트림을 관리하는 `ServletOutputStream.isReady()` 메소드가 NIO channel buffer write 있는지 여부를 체크하는데 사용된다. false 리턴하는 경우, Servlet container 대한 `onWritePossible()` 메소드를 호출할 것을 예약하고, 언젠가 다른 thread 의해 `onWritePossible()` 호출된다. 이런 방식을 사용하게 되면 `out.write()` 느린 client channel buffer 비울 때까지 block 일이 없게 된다.

 

3. Asynchronous Non-blocking I/O Serlvet

이로써 서블릿을 사용하는 애플리케이션에 모든 과정에 Asynchronous non-blocking 적용이 되었다. 필자는 Servlet을 기반으로 동작하는 Spring MVC 애플리케이션을 Async/Non-block으로 작동하게 하고 싶었다. 그러면 이제 Servlet 버전을 올리고, Spring MVC에서 지원하는데로 반환 타입만 Future 타입으로 바꿔주면 될까 싶었는데 또다른 한계가 있었다.

서블릿에 Request, response 다루는 HttpServletRequest, HttpServletResponse객체는 내부적으로 InputSream OutputStream 사용하고 있다는 것이다. 자바의 Stream은 기본적으로 블로킹 방식이기 때문에, 앞단의 모든 노력에도 기본적으로 완전한 논블로킹이 아닌 블로킹 I/O 방식이라는 한계를 가지게 된다.

따라서 우리가 Spring 5 MVC에서 non-blocking 방식으로 동작하는 코드를 작성할 있지만, 여전히 서블릿 컨테이너 3.1 API에는 동기적이거나 블로킹되는 메서드들이 남아있어 non-blocking으로 동작하게끔 하려는 애플리케이션에서 실수로 blocking API 호출하는 일이 발생할 위험이 항상 도사리고 있다.

이러한 이유로 blocking API 사용될 잠재적인 위험이 없는 스택의 필요성이 대두되었고, 그에 따라 Spring 5에서는 Async concurrency model 위해 또다른 선택지로 Reactive 기반의 webflux 제공하고 있다. Servlet기반의 Spring MVC 대조적으로 Spring WebFlux Servlet API 기반으로 구축되지 않았으며 설계 비동기이기 때문에 그러한 비동기 요청 처리 기능이 필요하지도 않다고 한다. 

 

4. Servlet 3.1 + vs Reactive

그렇다면 무조건 Spring Webflux만이 답인가? 혼돈이 왔다. Servlet 3.1 이상에서 Async/Non-blocking을 적용하더라도 response/request를 write/read하는 부분에서 blocking이 된다는 한계가 있지만 그래도 앞단에서 Async/Non-blocking이 적용된 부분에 대해서는 Sync/blocking인 경우와 확실한 성능 차이를 기대해볼 수 있지 않을까하는 실낱같은 희망을 가지고 실제 테스트를 진행해보았다. 테스트 대상은 Spring MVC sync block, Spring MVC async non-block, Spring Webflux기반의 웹서버이며 테스트 시나리오는 아래와 같다.

Test Case 1 - 스레드 풀 200개 + Remote Blocking API Call
- tomcat, jetty 스레드 풀을 200개로 고정한다.
- 스레드가 1초 동안 Sleep되는 Remote API “/sleep” 을 호출한다.
- wrk를 통해 Request Context를 생성하여 부하를 준다.
- 10 thread and 100 connection
Test Case 2 - 스레드 풀 1개 + Remote Blocking API Call
- tomcat, jetty 스레드 풀을 1개로 고정한다.
- 스레드가 1초 동안 Sleep되는 Remote API “/sleep” 을 호출한다.
- wrk를 통해 Request Context를 생성하여 부하를 준다.
- 10 thread and 100 connection
Test Case 3 - 스레드 풀 1개 + Remote Blocking API Call
- tomcat, jetty 스레드 풀을 1개로 고정한다.
- 스레드가 1초 동안 Sleep되는 Remote API “/sleep” 을 호출한다.
- wrk를 통해 Request Context를 생성하여 부하를 준다.
- 100 thread and 100 connection

 

결과는 아래와 같다.

Case 대상 스레드 요청 10 동안 처리량 Avg Latency Timeout
1 Spring MVC sync block 200 10 thread
100 connection
135 1.02s 120
  Spring webflux 200 133 1.25s 118
  Spring MVC
Async non-block
200 135 1.30s 118
2 Spring MVC sync block 1 10 thread
100 connection
9 1.02s 0
  Spring webflux 1 135 1.22s 120
  Spring MVC
Async non-block
1 135 1,12s 120
3 Spring MVC sync block 1 100 thread
100 connection
9 1.02s 0
  Spring webflux 1 142 1.03s 127
  Spring MVC
Async non-block
1 150 1.03s 135

response/request 사이즈가 의미있게 크지 않아서인지 몰라도 Spring webflux와 Spring MVC async/non-block가 큰 성능 상의 차이점을 보이지는 않았다. 기존 Spring MVC sync/block의 경우 Spring MVC async/non-block만 적용해도 아주 큰 성능의 향상을 볼 수 있다는 점이 확인 되었다.

 

결론적으로 테스트 결과만 두고 보면 아직 Spring Webflux가 Spring MVC에 비해 월등히 우세하다?라고 보기에는 어렵다고 생각했다. 각 팀의 상황(기존에 Spring MVC를 바탕으로 개발했다거나하면.. Webflux 전환을 위한 리소스가 들테니..)에 맞추어 사용하면 되겠지만 프로젝트를 처음 세팅하는 상황에서 완전한 Async concurrency model을 적용하기 위해서는 Webflux를 기반으로 프로젝트를 시작하는 것이 (미래에 더 나은 지원이 될 것을 기대하며..) 낫지 않을까 싶다.

 

이번 포스팅을 통해 Java Servlet의 기본과 어떻게 Async concurrency model을 적용하기 위해 Servlet이 변화해왔는지 변화 과정을 알아보았다. 연결된 부분들을 폭넓게 조사하다보니 Async concurrency model로의 변화를 논하기 위해서는 Reactive에 대해 알아보아야할 필요가 있다는 생각이 들었다. 이후 주제에서 Reactive도 다룰 수 있기를 바라며 포스팅을 마치도록 하겠다.