스프링 비동기(1) - TaskExecutor로 요청을 비동기 처리

2024. 3. 2. 17:07spring/비동기 처리

INTRO

서블릿 API 초창기 시절엔 구현 컨테이너 대부분이 요청당 스레드 하나만 사용했다. 하지만 서블릿3 명세부터 HTTP 요청이 기하급수적으로 늘면서 HTTP 요청을 비동기로 처리할 수 있게 되었다. 서블릿3.1 호환 컨테이너에서 제대로 사용한다면 모든 작업을 넌블로킹 형태로 작동시킬 수 있다.(리소스 역시 넌블로킹 형태로 작동해야 한다.)

 

예전에는 웹 애플리케이션은 유저의 요청을 접수한 서버가 HTML을 렌더링하고 이를 다시 클라이언트에 돌려줬다. 지금은 HTML 렌더링 작업이 클라이언트로 넘어갔고 HTML을 직접 주는 방식이 아니라 JSON, XML등의 다른 표현형을 덜려주는 식으로 통신 방법이 바뀌었다. XMLHttpRequest, 서버 전송 이벤트, 웹소켓 등 흥미로운 기술들이 있다.

 

 

 

과제

요청을 비동기 처리해서 서블릿 컨테이너의 부하를 줄여라

 

해결책

스레드를 블로킹하지 않고 백그라운드에서 처리한 후 결괏값을 유저에게 돌려주자

 

풀이

스프링 MVC 컨트롤러의 핸들레 메서드는 여러 가지 반환형을 지원한다.

타입 설명
DeferredResult 나중에 다른 스레드가 생산할 비동기 결과
ListenableFuture<?> 나중에 다른 스레드가 생산할 비동기 결과, DeferredResult 대신 사용할 수 있다.
CompletableStage<?> /
CompletableFuture<?>
나중에 다른 스레드가 생산할 비동기 결과, DeferredResult 대신 사용할 수 있다.
Callable<?> 나중에 결과를 생산할(또는 예외를 던질) 작업
ResponseBodyEmitter 여러 객체를 응답에 실어 클라이언트에 비동기로 전송할 때 씁니다.
SseEmitter 서버 전송 이벤트를 비동기로 작성할 때 쓴다.
StreamingResponseBody OutputStream을 비동기로 작성할 때 쓴다.

 

위와 같은 비동기 반환 클래스/인터페이슨느 모두 제네릭형 이라서, 모델에 추가할 객체, 뷰 이름, ModelAndView 객체까지 컨트롤러가 반환하는 어떤 반환형도 수용할 수 있다.

 

비동기 처리 설정하기

비동기 요청 처리는 서블릿 3.0부터 추가된 지원 기능으로, 스프링 MVC에서 사용하려면 모든 필터와 서블릿이 비동기로 작동하게끔 활성화해야 한다. 필터/서블릿을 등록할 때 setAsyncSupported() 메서드를 호출하면 비동기 모드가 켜진다.

 

WebApplicationInitializer 구현 클래스는 다음과 같이 작성한다.

public class WebAppInitializer implements WebApplicationInitializer {
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {

        System.out.println("chapter05");

        // Root Config 설정 -s
        AnnotationConfigWebApplicationContext rootContext = new AnnotationConfigWebApplicationContext();
        rootContext.register(RootConfig.class); //등록

        ContextLoaderListener listener = new ContextLoaderListener(rootContext);
        servletContext.addListener(listener);
        // Root Context Config 설정 -e

        // DispatcherServlet 설정 -s
        // 1. DispatcherServlet WebApplicationContext 객체 생성
        AnnotationConfigWebApplicationContext webContext = new AnnotationConfigWebApplicationContext();
        webContext.register(ServletConfig.class);

        // 2. DispatcherServlet 객체 생성 및 추가
        DispatcherServlet dispatcherServlet = new DispatcherServlet(webContext);
        ServletRegistration.Dynamic servlet = servletContext.addServlet("dispatcher", dispatcherServlet);


        // 3. 서블릿 매핑 및 부가 설정
        servlet.addMapping("/");
        servlet.setLoadOnStartup(1);
        //****비동기 모드****
        servlet.setAsyncSupported(true);
        // DispatcherServlet 설정 -e


        // Filter 설정 -s
        FilterRegistration.Dynamic filter = servletContext.addFilter("encodingFilter",
                                                                         CharacterEncodingFilter.class);
        filter.setInitParameter("encoding", "UTF-8");
        filter.addMappingForServletNames(null, false, "dispatcher");
        //****비동기 모드****
        filter.setAsyncSupported(true);
        // Filter 설정 -e
    }
}

 

AsyncTaskExucutor를 MVC 구성 클래스에 설정하자

 

@Configuration
public class AsyncConfiguration extends WebMvcConfigurationSupport {

    @Override
    protected void configureAsyncSupport(AsyncSupportConfigurer configurer) {
        configurer.setDefaultTimeout(5000);
        configurer.setTaskExecutor(mvcTaskExecutor());
    }

    @Bean
    public ThreadPoolTaskExecutor mvcTaskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        taskExecutor.setThreadGroupName("mvc-executor");
        return taskExecutor;
    }

    @Bean
    public InternalResourceViewResolver internalResourceViewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("/webapp/WEB-INF/template/");
        viewResolver.setSuffix(".jsp");
        return viewResolver;
    }
}

 

 

 

비동기 컨트롤러 작성하기

간단히 핸들러 메서드의 반환형만 바꾸면 요청을 비동기 처리하는 컨트롤러로 바꿀 수 있다.

 

1. Callable

@Controller
@RequiredArgsConstructor
@RequestMapping("/reservationQuery")
public class ReservationController {

    private final ReservationService reservationService;

    @GetMapping
    public String home() {
        return "welcome";
    }
    
    @PostMapping
    public Callable<String> submitForm(
            @RequestParam("courtName") String courtName,
            Model model
    ) {
    	log.info("process: [{}] ", getCurrentThreadName());
        return () -> {
            List<Reservation> reservations = Collections.emptyList();
            if (courtName != null) {
                Thread.sleep(5000);
                reservations = reservationService.query(courtName);
            }
            model.addAttribute("reservations", reservations);
            log.info("return: [{}] ", getCurrentThreadName());
            return "welcome";
        };
    }
}

 

submitForm() 메서드는 String을 직접 반환하지 않고 Callable<String>을 대신 반환한다. Callable<String> 람다 표현식 안에서는 query() 메서드의 처리 시간을 지연시켰다.

 

예약 서비스를 호출해서 로그를 출력해봤다.

[http-nio-8080-exec-7] timestamp: 2024-03-03 21:38:14.242
process: [http-nio-8080-exec-7] 
[http-nio-8080-exec-7] return: com.spring.study.chapter05.application.ReservationController$$Lambda$390/0x0000000801005d80@468a1ac4
return: [mvcTaskExecutor-1]

 

로그를 살펴보면 [http-nio-8080-exec-7] 가 HTTP 요청을 받아 처리하다 해제되고 mvcTaskExecutor가 이어받아 결과를 반환한다.

 

 

 

2. DeferredResult

 

Callable<String> 대신 DeferredResult<String>을 써도 된다. DeferredResult를 사용하려면 클래스 인스턴스를 만들어 비동기 처리 작업(Runnable)을 전송한 다음, 이 작업 내부에서 setResult() 메서드를 이용해 DeferredResult 결괏값을 설정한다.

 예외가 발생하면 DeferredResult.setErrorResult() 메서드의 인수로 보내 처리한다.

@PostMapping
public DeferredResult<String> submitForm(
        @RequestParam("courtName") String courtName,
        Model model
) {
    final DeferredResult<String> result = new DeferredResult<>();
    
    taskExecutor.execute(() -> {
        List<Reservation> reservations = Collections.emptyList();
        if (courtName != null) {
            try {
                Thread.sleep(5000);
                reservations = reservationService.query(courtName);
                model.addAttribute("reservations", reservations);
                result.setResult("reservationQuery");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

        }
    });
    
    return result;
}

 

 

여기서도 렌더링할 뷰 이름은 DeferredResult<String> 형으로 반환하고 있다. 스프링이 주입한 TaskExecutor의 execute() 메서드에 실행 코드에 해당하는 Runnable 객체를 전달하고 실제 결괏값은 이 안에서 설정한다.( setResult() )

DeferredResult로 반환할 경우 스레드를 직접 만들어야 하지만 Callable로 반환할 경우엔 그럴 필요가 없다.

 

 

3. CompletableFuture

 

이번에는 CompletableFuture<String>을 반환하고 TaskExecutor로 코드를 비동기 실행한다.

    @PostMapping
    public CompletableFuture<String> submitForm(
            @RequestParam("courtName") String courtName,
            Model model
    ) {
        return CompletableFuture.supplyAsync(() -> {
            List<Reservation> reservations = Collections.emptyList();
            if (courtName != null) {
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {}
                reservations = reservationService.query(courtName);
            }
            model.addAttribute("reservations", reservations);
            return "reservationQuery";
        }, taskExecutor);
    }

 

실행 코드를 CompletableFuture.supplyAsync() 메서드에 넣고 호출하고 CompletableFuture객체를 돌려받는다.

supplyAsync()메서드는 Supplier 와 Executor 두 타입의 객체를 매개변수로 받으므로 비동기 처리 시 TaskExecuor를 재사용할 수 있다. 매개변수가 Supplier 하나뿐인 supplyAsync() 메서드는 JVM이 가용한 기본 포크/조인 풀을 써서 실행된다.

 

4. ListenableFuture

자바 Future를 구현한 스프링의 ListenableFuture 인터페이스는 Future 완료 시점에 콜백을 실행한다. 실행 코드를 AsyncListenableTaskExecutor에 전송하면 ListenableFuturer가 반환된다.

 

@PostMapping
public ListenableFuture<String> submitForm(
        @RequestParam("courtName") String courtName,
        Model model
) {
    //여기서 taskExecutor는 AsyncListenableTaskExecutor의 구현체이다.
    return taskExecutor.submitListenable(() -> {
        List<Reservation> reservations = Collections.emptyList();
        if (courtName != null) {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {}
            reservations = reservationService.query(courtName);
        }
        model.addAttribute("reservations", reservations);
        return "post";
    });
}

 

Callable형 실행 코드를 submitListenable() 메서드로 taskExecutor에 전달하면 ListenableFuture를 반환받는데, 이 객체를 메서드 결과로 활용하면 된다.

 

스프링 MVC는 내부적으로 ListenableFuture를 DeferredResult에 맞추기 때문에 성공/실패 콜백은 DeferredResult.setResult / DeferredResult.setErrorResult를 호출한다.

=> 이 작업은 HandlerMethodReturnValueHandler 구현체가 대행한다.