Spring

[스프링 5 레시피] 5장: 스프링 MVC 비동기 처리

_min's 2021. 4. 7. 15:46
반응형

 

 

 

이 장의 주제인 스프링 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 [요청]

  1. preHandle() 호출
  2. 동기 로직 수행
  3. afterConcurrentHandlingStarted() 호출

작업 쓰레드 1 [작업]

  1. 비동기 로직 수행

WAS 쓰레드 2 [응답]

  1. preHandle() 호출
  2. postHandle() 호출
반응형