[스프링 5 레시피] 5장: 스프링 MVC 비동기 처리
이 장의 주제인 스프링 MVC 비동기 처리가 필요한 이유에 대해서 간단하게 살펴보겠습니다.
클라이언트에서 요청을 보내면 하나의 서블릿 쓰레드가 그 요청을 받아서 처리하고 응답까지 도맡아 하기 때문에 클라이언트에게 응답을 돌려줄 때까지 해당 쓰레드는 블로킹(차단)됩니다.
Tomcat의 쓰레드 풀에 생성할 수 있는 최대 쓰레드 개수는 기본 200개이기 때문에 요청이 폭주하게 되면 여유 쓰레드가 없어서 Connection refuse나 Connection timeout이 발생할 수 있는데요,
이러한 문제로 서블릿 3부터는 HTTP 요청을 비동기로 처리할 수 있게 되면서 HTTP 요청을 접수했던 쓰레드를 WAS에 반환하고 Backend 작업을 할 때는 스레드를 작업 쓰레드 풀에서 꺼내 쓰다가 작업이 완료되면 다시 WAS의 쓰레드로 응답합니다.
이렇게 WAS의 쓰레드가 논블로킹으로 동작을 하게되면 더 많은 요청을 처리할 수 있게 됩니다.
요청의 동기와 비동기 처리 비교
먼저 동기 처리를 살펴볼텐데요, HelloService의 test() 메서드가 처리 시간이 3초 걸리는 작업이라고 가정하고 우리가 일반적으로 사용하고 있던 방식으로 요청을 해보겠습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/hello")
public class HelloApiController {
private final HelloService helloService;
@PostMapping("/blocking")
public String submit() throws InterruptedException {
Thread.sleep(3000L); // 서비스를 일부러 지연시킴
helloService.test();
return "result";
}
}
POST http://localhost:8080/api/hello/blocking
위와 같이 요청을 보냈을 때 응답을 반환하기 까지 약 3.1초의 처리 시간이 걸린 것을 확인할 수 있는데요, 위의 핸들러 메서드는 동기방식으로 동작하기 때문에 WAS의 쓰레드가 3.1초 간 블로킹 되어있었습니다.
다음 코드는 String을 반환하는 대신 Callable<String>을 반환하는 핸들러 메서드입니다. Callable은 call() 메서드 하나만 가지고있는 함수형 인터페이스이기 때문에 람다식으로 구현할 수 있는데요, Runnable.run() 메서드에서 반환 값이 있는 버전이라고 생각하시면 됩니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/hello")
public class HelloApiController {
private final HelloService helloService;
@PostMapping("/callable")
public Callable<String> submitCallable() {
System.out.println("Main: " + Thread.currentThread().getName());
// ------------ 요청을 받은 WAS 쓰레드는 return 전까지 사용되고 WAS의 쓰레드 풀에 반환
// Main: http-nio-8080-exec-1
// ------------ call() 메서드 안에 구현된 로직은 작업 쓰레드로 처리.
return () -> {
System.out.println("Work: " + Thread.currentThread().getName());
// Work: mvcTaskExecutor-1
Thread.sleep(3000L); // 서비스를 일부러 지연시킴
helloService.test();
return "result";
};
// ------------ 이후 응답할 때 다시 WAS의 쓰레드를 사용함
// 응답 처리 쓰레드: http-nio-8080-exec-2
}
}
POST http://localhost:8080/api/hello/callable
앞에서와 마찬가지로 요청을 보냈을 때 약 3.1초 정도의 시간이 걸리는 것을 확인할 수 있구요, 해당 코드는 비동기이기 때문에 내부적으로 동작 방식이 달라집니다. (위 코드 주석 참고)
이와같이 WAS의 쓰레드는 요청/응답만 처리하고 쓰레드 풀에 반환되고 백엔드 로직을 처리하는 약 3초의 작업은 작업 쓰레드를 사용하기 때문에 외부에서 들어오는 요청을 더 많이 받을 수 있게됩니다.
비동기 핸들러 인터셉터
스프링 MVC를 학습할 때 핸들러 인터셉터 다루는 방법을 배웠는데요, 핸들러가 비동기로 처리될 때는 인터셉터를 조금 다르게 구성해야합니다.
아래 코드를 보면 HandlerInterceptor 대신 AsyncHandlerInterceptor를 구현 하였고, 추가적으로 afterConcurrentHandlingStarted() 메서드 사용할 수 있게 됩니다.
@Slf4j
public class AsyncMeasurementInterceptor implements AsyncHandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
log.info("preHandle() 호출 !!");
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
log.info("postHandle() 호출 !!");
}
@Override
public void afterConcurrentHandlingStarted(HttpServletRequest request, HttpServletResponse response, Object handler) {
log.info("afterConcurrentHandlingStarted() 호출 !!");
}
}
POST http://localhost:8080/api/hello/callable
위에서 확인해봤던 Callable을 반환하는 비동기 핸들러를 요청해서 Interceptor의 실행 순서를 살펴보겠습니다.
WAS 쓰레드 1 [요청]
- preHandle() 호출
- 동기 로직 수행
- afterConcurrentHandlingStarted() 호출
작업 쓰레드 1 [작업]
- 비동기 로직 수행
WAS 쓰레드 2 [응답]
- preHandle() 호출
- postHandle() 호출