티스토리 뷰
Servlet 3.0 이전
Servlet 3.0 에는 Servlet Thread 만 존재했다.
예를들어 Thread per Request Model 을 사용하는 Tomcat 의 경우에는 기본적으로 200개의 Servlet Thread 를 가졌고 내부적으로 Blocking 되는 코드가 있다면 서버의 Latency 가 길어지게 된다.
왜냐하면 하나의 Request 에 대해서 하나의 Servlet Thread 를 할당하게 되는데, 해당 Servlet Thread 가 Blocking 상태에 빠지게 된다면 더 이상 Servlet Thread Pool 에는 남아있는 Thread 가 없게되고 Request 는 Servlet Thread 가 반환될 때까지 기다리게 되는 것이다.
이러한 문제를 해결하기 위해 Servlet Thread 개수를 늘리면 되지 않느냐고 생각할 수 있다.
하지만 Servlet Thread 의 수를 무한정 늘리게 되면 CPU 는 그만큼 더 많은 Context Switching 을 해야하고 그에 따라 오버헤드가 붙게된다. 이러한 현상이 계속되면 CPU 사용량이 계속해서 증가하게되고 Thread 로 인한 메모리 낭비 문제가 발생할 수 있다.
Servlet 3.0
Servlet 3.0 에서는 비동기 처리를 위해서 Servlet Thread 와 Worker Thread 가 분리되었다.
@WebServlet(urlPatterns="/welcome", asyncSupported=true)
public class AsyncWelcome extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
final AsyncContext asyncContext = request.startAsync();
new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {}
HttpServletResponse response = (HttpServletResponse) asyncContext.getResponse();
response.setContentType("text/plain");
response.setCharacterEncoding("UTF-8");
try {
response.getWriter().println("Welcome The Ch4njun World!");
} catch (IOException e) {}
// 여기서 asyncContext 를 통해 Worker Thread 의 작업종료를 알린다.
asyncContext.Complete();
}).start();
// 위 Thread 를 비동기적으로 실행시키고 이 시점에서 Servlet Thread 는 반환된다.
}
}
위 코드는 Servlet 3.0 에서 지원하는 비동기를 이용한 코드이다. 세부적인 동작과정을 살펴보자.
- Web Server 의 NIO Connector 에 의해서 Request 가 들어오고 Connection 이 만들어진 후 Servlet Thread 를 할당받는다.
- 오래걸리는 작업을 새로운 Worker Thread 로 할당해 처리한다.
- 그리고 Servlet Thread 는 Servlet Thread Pool 에 반환된다.
(이 Servlet Thread 로 다른 Request 를 처리할 수 있다)
여기까지 정리해보면 NIO Connector 가 아직 해당 Connection 을 물고있기 때는 상태이다. - Worker Thread 는 AsyncContext 를 통해서 Response 를 추가하고 작업이 완료됨을 알린다.
- 해당 Response 를 Client 에게 전달하고 Connection 은 종료된다.
하지만 결국 Worker Thread 에서 외부 API 호출, DB 쿼리 조회와 같은 Blocking I/O 작업을 수행한다면 Worker Thread 는 해당 응답을 기다리게 된다. 이렇게 될 경우 결국 Request 가 증가함에 따라서 Blocking 상태에 놓이는 Worker Thread 가 증가하게되고 위에서 언급한 문제는 동일하게 발생한다.
이러한 원인이 계속되는 이유는 Servlet 에서 Non-Blocking I/O 를 지원하지 않기 때문이다.
또 다른 예시가 괜찮은 포스팅이 있어 추가로 남긴다!
https://blogs.sap.com/2013/03/01/non-blocking-java/
Non-blocking Java with Servlets 3.0 | SAP Blogs
Imagine that the assistant of an executive director after sending an email does nothing else but just waits until he/she gets a response to this email. An executive director would need hundreds of “blocking” assistants to manage his daily work. Quite e
blogs.sap.com
Servlet 3.1
Servlet 3.1 에서 Servlet Application 에서 Non-Blocking I/O 를 지원하게 된다. ReadListener, WriteListener 인터페이스를 통해 이러한 부분을 지원한다.
여기까지 봤을 때 이제서야 비동기 논블로킹을 위한 기반이 만들어졌다고 볼 수 있다. 하지만, 이러한 기술을 쓰자고 Servlet 코드를 직접 쓸순 없으니까 이제부터 Spring 에서 어떤 방식으로 지금까지 설명한 Servlet 비동기 논블로킹 방식을 활용하는지 살펴보자.
Spring 3.2 (자연스럽게 Spring 으로 넘어온건 기분탓)
Spring 3.2 에서는 Servlet 3.0, Servlet 3.1 에서 추가된 비동기, 논블로킹에 대한 기술을 편하게 활용할 수 있도록 Controller 의 Handler 가 다양한 자료형의 객체를 반환할 수 있도록 지원한다.
Callable, ListenableFuture, CompletableFuture, WebAsyncTask, DefferedResult
이러한 클래스의 객체를 반환하기만 하면 Spring 이 알아서 위에서 설명한 비동기작업을 실행해준다.
하지만 Worker Thread 도 결국 자원이다. Servlet Thread 를 직접 사용하는 것이 아니라 Blocking 작업과 같이 오래걸리는 작업을 Worker Thread 에 할당한다고 하더라도 새로운 Request 를 계속해서 받을 수 있다는 것 빼고는 나아진게 없다. 심지어 Servlet Thread 와 Worker Thread 모두 생성해야 하기 때문에 손해일수도 있다.
그래도 아예 의미없는 것은 아니다. 오래걸리는 API 와 바로 반환해줄 수 있는 API 가 공존한다면 오래걸리는 API 를 Servlet Thread 로 직접 사용하게되면 금방 반환해줄 수 있는 API 호출까지 Blocking 되게 되는데 이러한 부분은 해소할 수 있다.
DefferedResult
위에서 이야기한 다양한 클래스들이 있지만 조금 특이한 DefferedResult 클래스에 대해서 이야기해보도록 한다. DefferedResult 는 Spring 비동기 기술의 핵심이라고 한다. DefferedResult 를 이용해 다양한 응용이 가능하기 때문이다.
그러면 왜 DefferedResult 가 Spring 비동기 기술의 핵심이라고 하는지 살펴보자.
기본적으로 Callable, WebAsyncTask, ListenableFuture, CompletableFuture 를 반환하게되면 Worker Thread 가 생성되고 거기서 작업을 실행한다. 만약 해당 Worker Thread 에 Blocking 작업을 동작시켰다고 가정해보자. 예를들면, 외부 API 호출, DB 쿼리요청 등이 있을 것이다.
그러면 해당 Worker Thread 는 결국 Blocking 상태에 놓이고 해당 요청에 대한 응답을 받아야 이후 동작을 수행한다. 이런 구조에서 만약 많은 수의 Request 가 들어오게 된다면 Servlet Thread 는 계속 반환되기 때문에 괜찮다고 하지만.. 결국 Worker Thread 도 자원이고 많아지게되면 리소스 낭비와 CPU 사용량이 증가하는 문제가 발생하게 된다.
그러면 Worker Thread 가 Blocking 상태에 놓이지 않고 Non-Blocking 으로 처리될 수 있는 방법이 없을까?
이러한 방법을 제공하는것이 바로 DefferedResult 이다!!
DefferedResult 는 쉽게 설명하면 Servlet Thread 1개로 여러 Request 를 처리하면서 Worker Thread 를 계속해서 만들지 않는 방법이다.
Servlet Thread 는 별도의 Worker Thread 를 생성하는 것이 아니라 DefferedResult 큐에 Handler 를 추가하고 Servlet Thread 를 Servlet Thread Pool 에 반환한다. 그리고나서 외부에서 특정 Handler 에 대한 이벤트가 발생하면 해당 Request 에 대한 Response 처리 작업을 수행한다.
이렇게 생각할 수 있다.
"결국 외부 이벤트를 만들어주는 Worker Thread 를 만들어야 되는거 아니야!?"
다음과 같은 상황을 생각해보자.
Client 의 Request 가 왔을 때 DefferedResult 를 반환함으로써 Handler 를 추가하고 Servlet Thread 를 반환하는 것이다. 그리고나서 AsyncRestTemplate 이나 WebClient 와 같은 비동기 논블로킹 방식으로 외부 API 를 호출하고 그에 대한 Callback 에 dr.setResult() 를 놓는다면 어떨까?
외부 API 를 호출한 결과는 ListenableFuture, CompletableFuture 로 받을텐데 여기서 별도의 Worker Thread 는 어디있는지 고민해보자.
AsyncRestTemplate 에 Netty 를 주입하거나 WebClient 를 사용하게 되면 하나의(?) Worker Thread 로 Event Loop 방식으로 동작한다.
위 상황에서 외부 이벤트를 만들어주는 Worker Thread 가 Request 수만큼 많아지는지 생각해보자.
이렇게 하나의 Servlet Thread 로 AsyncRestTemplate 이나 WebClient 를 실행하고 DefferedResult 를 반환함으로써 Servlet Thread 를 Pool 에 반환하게 되면, AsyncRestTemplate, WebClient 는 하나의(?) Worker Thread 로 Event Loop 형태로 처리하기 때문에 결과적으로 Request 마다 새로운 Worker Thread 를 만들지 않게되는 것이다.
즉, 당연히 DefferedResult 를 반환하고 dr.setResult() 하주는 작업을 단순 비동기 작업으로 실행하게된다면 외부 이벤트를 만드는 작업을 위한 Worker Thread 를 만들게 된다.
하지만, 적은 수의 Worker Thread 를 가지고 동작하는 Netty, WebClient 와 같이 Reactive Library 를 사용하게 된다면 Request 마다 새로운 Worker Thread 를 만들지 않는다.
그럼 또 이러한 질문을 할 수 있다.
"그럼 그냥 AsyncRestTemplate 이나 WebClient 가 반환해준 ListenableFuture, CompletableFuture 를 바로 반환해주면 되는거 아니야? 어차피 바로 반환할 수 있다면서!"
물론 그러한 방법도 가능하다.
하지만, DefferedResult 와 조합해서 사용하게 되면 Callback 을 통해서 처리 결과를 가공하거나 메소드 체이닝을 통해서 여러 비동기 논블로킹 호출을 수행하는 등의 작업에 어려움이 있다. 따라서 이러한 경우에 DefferedResult 의 객체인 dr 을 우선 반환하고 ListenableFuture, CompletableFuture 의 메소드 체이닝을 통해 다양한 작업을 처리한 뒤 Callback 을 통해서 dr.setResult() 할 수 있다.
이처럼 DefferedResult 는 다른 비동기 처리기술과 결합되어 사용된다.
물론 단독으로 사용되어 다른 API 호출이나 스케줄링에 의해서 dr.setResult() 될 수도 있겠지만, 논지를 벗어나는 상황인 것 같아서 넘어가도록 한다.
정리하자면 DefferedResult 를 반환하고 이후에 Callback 에서 dr.setResult() 해주는 것도 결국 CompletableFuture 에 달린 것이고, 단순히 CompletableFuture 를 반환하는 것도 CompletableFuture 에 달린 것이다.
즉, 부가적인 처리를 위해 DefferedResult 를 반환해 응답을 지연시킬 것인지 말 것인지의 차이라는 것이다.
'Back-End > Spring Boot' 카테고리의 다른 글
[Spring] @Transactional 파보기 (Feat. Transaction Propagation 활용 사례소개) (1) | 2023.01.25 |
---|---|
[WebFlux] SSE 구현 예시 (0) | 2021.12.23 |
EhCache Self-Invocation 문제 (0) | 2021.12.23 |