[스프링] 스프링 MVC와 웹플럭스

스프링 MVC

프론트 컨트롤러

웹 애플리케이션 개발을 위해 스프링에서 제공하는 스프링 MVC 프레임워크를 사용할 수 있다. 스프링 MVC의 프론트 컨트롤러(또는 웹 컨트롤러)는 프론트 컨트롤러 패턴(front controller pattern)을 구현한 것이다. 모든 웹 요청은 반드시 프론트 컨트롤러를 거쳐 처리된다. 프론트 컨트롤러를 디스패처 서블릿(dispatcher servlet)이라고도 하며 이에 대응되는 실제 인스턴스는 DispatcherServlet이다. 템플릿 엔진으로 웹 페이지를 제공하는 대신 REST API만을 서비스하는 경우에도 결국 스프링 MVC가 제공하는 다양한 기술들을 사용하여 요청을 처리하고 응답을 생성한다. DispatcherServlet는 MVC에서 HTTP 요청을 처리 및 컨트롤하는 핸들러/컨트롤러를 위한 중앙 디스패처이다.

DispatcherServlet는 스프링 애플리케이션 컨텍스트에 등록된 여러 가지 구성 가능한 델리게이트(delegate) 컴포넌트들을 사용하여 웹 요청을 처리한다. 정확히는 DispatcherServlet는 요청 처리를 위한 알고리즘을 제공하고, 실제 요청 처리 작업은 델리게이트 컴포넌트들이 수행한다. DispatcherServlet은 소스 코드나 web.xml 파일 상에서 서블릿 명세에 따라 선언 및 매핑되어야 한다. 스프링 부트를 사용할 경우 스프링 구성을 사용하여 내장된 서블릿 컨테이너(톰캣)를 부트스트랩하므로 DispatcherServlet의 등록 및 초기화 과정의 순서가 다르다[링크][https://docs.spring.io/spring-boot/docs/current/reference/html/web.html#web.servlet.embedded-container].

DispatcherServlet에 의해 처리되고 매핑된 웹 요청은 컨트롤러가 받으며 스프링 웹 애플리케이션 컨텍스트 또는 컨트롤러의 클래스와 메서드에 적용한 애너테이션을 이용해 요청을 처리할 수 있다. 클래스 레벨에 @Controller 또는 @RestController를 적용하면 해당 클래스는 프론트 컨트롤러 기능을 담당하는 클래스가 된다. 스프링은 웹 요청이 들어오면 해당 요청을 처리하는 핸들러 메서드(handler method)를 찾으며 해당 웹 요청을 처리할 핸들러 메서드를 요청 URL에 매핑한다. 핸들러 메서드를 정의하기 위해서는 메서드에 @RequestMapping을 적용하면 된다.

@RequestMapping을 적용한 핸들러 메서드가 인자로 받을 수 있는 것은 다음과 같다.

  • 서블릿 요청 및 응답 객체인 HttpServletRequest 또는 HttpServletResponse
  • 요청 매개변수 (@RequestParam 적용)
  • 모델 속성 (@ModelAttribute 적용)
  • 요청에 포함된 쿠키값 (@CookieValue 적용)
  • 요청 헤더값 (@RequestHeader 적용)
  • 요청 속성값 (@RequestAttribute 적용)
  • 핸들러 메서드가 모델에 속성을 추가하기 위해 사용하는 Map 또는 ModelMap
  • 핸들러 메서드가 객체 바인딩/유효성을 검증한 결과를 가져올 때 필요한 Errors 또는 BindingResult
  • 핸들러 메서드가 세션 처리를 완료했음을 알릴 때 사용하는 SessionStatus

핸들러 메서드는 위와 같은 다양한 인자를 받을 수 있으므로 해당 인자를 사용하여 요청 처리에 필요한 정보를 가져오거나 추가하는 방식으로 요청 처리를 수행하도록 정의한다.

웹 요청을 처리하는 주체는 DispatcherServlet 인스턴스이다. 보통 하나의 DispatcherServlet이 여러 URL 요청을 처리하지만 여러 DispatcherServlet가 서로 다른 URL을 처리하도록 구성할 수도 있다. 어노테이션을 적용하여 클래스 및 메서드가 웹 요청을 처리하도록 하고 특정 URL 경로에 특정 핸들러 메서드를 매핑하는 것은 DispatcherServlet 인스턴스가 그러한 역할을 수행하도록 설정하는 것과 같다. 즉, DispatcherServlet은 스프링 MVC의 핵심 서블릿 클래스로 웹 요청을 받아 적절한 핸들러 메서드로 웹 요청을 위임하는 라우팅(경로 설정) 역할을 수행한다.


서블릿 필터와 핸들러 인터셉터

웹 요청을 적절한 핸들러 메서드에게 위임하는 과정에서 자바 서블릿 필터(filter)를 사용하거나 스프링이 제공하는 핸들러 인터셉터(handler interceptor)를 사용한 요청/응답 데이터 처리, 빈 검증과 같은 추가적인 작업이 가능하다.

자바 서블릿 명세에는 서블릿 필터라는 것이 정의되어 있다. 필터를 사용하면 요청/응답 데이터를 직접 사용할 수 있으며 요청/응답을 가로채서 원하는 형태로 변환할 수도 있다. 서블릿 필터의 사용 예로는 인증, 로깅 및 감사(audit), 데이터 압축 등이 있다. 필터에 관한 자세한 내용은 오라클 페이지링크를 참고하자.

스프링이 제공하는 핸들러 인터셉터(HandlerInterceptor)를 사용하면 요청/응답 데이터를 가로채서 서블릿이 처리하기 전후에 각각 전처리 및 후처리할 수 있다. 핸들러 인터셉터는 스프링 웹 애플리케이션 컨텍스트에 구성되기 때문에 컨테이너의 기능을 자유롭게 활용할 수 있고 컨테이너 내부에 선언된 모든 빈을 참조할 수 있다. 핸들러 인터셉터를 특정 요청 URL에만 적용되도록 매핑할 수도 있다.


예외 처리

웹 애플리케이션 실행 도중 예외가 발생하게 되면 발생한 예외에 대한 스택 트레이스 정보가 응답으로 반환된다. 스프링 MVC 웹 애플리케이션의 애플리케이션 컨텍스트에 예외 리졸버(ExceptionResolver) 빈을 하나 이상 등록하면 try/catch 구문으로 예외 처리를 하지 않은 예외(캐치되지 않은 예외)에 대해서도 특정 처리를 할 수 있다. 캐치되지 않은 예외는 HandlerExceptionResolver 인터페이스를 구현한 커스텀 ExceptionResolver 빈에 의해 처리된다. ExceptionResolver 빈은 DispatcherServlet에 의해 자동으로 감지된다.

예외 종류 별로 서로 다른 에러 처리를 할 수도 있다. 페이지를 보여주는 서비스인 경우 에러 별 특정 에러 페이지를 서로 다르게 매핑할 수 있다. 스프링 MVC은 SimpleMappingExceptionResolver라는 예외 리졸버 인스턴스를 제공한다. 이를 사용하여 웹 애플리케이션 컨텍스트에서 발생한 예외를 매핑하여 처리할 수 있다.

예외 리졸버인 HandlerExceptionResolver를 직접 구성하지 않고 핸들러 메서드에 @ExceptionHandler 어노테이션을 적용하여 예외 핸들러를 매핑하는 예외 처리 방법도 있다. 해당 어노테이션을 사용하면 구성 클래스에서 HandlerExceptionResolver를 정의할 필요가 없다. @ExceptionHandler 적용 시 특정 예외를 지정하면 해당 예외가 발생했을 때 해당 핸들러 메서드가 처리한다. 즉, @ExceptionHandler를 적용한 핸들러 메서드는 예외 처리 기능을 담당하게 된다. @ExceptionHandler@RequestMapping과 동작이 유사하며 @RequestMapping 메서드처럼 여러 타입을 반환할 수 있다.

@ExceptionHandler 메서드는 정의된 컨트롤러 클래스 내에서만 작동하기 때문에 다른 컨트롤러에서 예외가 발생하면 호출되지 않는다. 이 동작이 의도된 것이 아니며 애플리케이션 전체에 걸쳐 예외를 처리할 예외 처리 핸들러 메서드를 정의하고자 한다면 예외 처리를 위한 별도 클래스를 생성하고 클래스 레벨에 @ControllerAdvice를 적용함으로써 애플리케이션 컨텍스트에 존재하는 모든 컨트롤러에 대해 예외 처리가 가능하다.


스프링 MVC 비동기

서블릿 3 명세 이전의 서블릿 컨테이너를 사용한 스프링 MVC 웹 애플리케이션에서는 HTTP 요청을 비동기적으로 처리할 수 없으며 클라이언트의 요청 당 스레드가 생성된다. 이 경우 스레드는 서블릿 컨테이너의 스레드이다. 요청에 대한 응답을 생성할 때까지 해당 스레드는 다른 요청을 처리할 수 없으며 블로킹된다. 이 때 제한된 스레드를 효율적으로 사용 및 관리하기 위해 스레드풀이 사용된다. 서블릿 3 명세부터 HTTP 요청을 비동기적으로 처리할 수 있게 되었다. HTTP 요청을 응답으로 처리 완료할 때까지 스레드는 블로킹되지 않고 다른 요청을 받아 처리할 수 있다. 컨트롤러 메서드의 호출자 스레드는 비동기적으로 작업을 백그라운드 스레드에게 요청한 후 작업의 결과가 준비되는 즉시 클라이언트에게 응답으로 반환할 수 있다. 서블릿 3.1 명세 호환 컨테이너에서는 IO 작업이 완료될 때까지 스레드가 블로킹되지 않으며 다른 작업을 수행할 수 있는 NIO 기능이 도입되어 모든 요청에 대한 응답 작업을 논블로킹 방식으로 처리할 수 있으므로 완전한 비동기, 논블로킹 방식의 프로그래밍 구현이 가능하다.

하나의 요청을 하나의 스레드가 모두 처리하고 요청이 들어오는 순서대로 스레드가 하나씩 할당되어 처리하는 것은 요청을 동기적으로 처리하는 것이다. 이 경우 핸들러 메서드의 요청 스레드가 블로킹되며 요청 작업이 완료될 때까지 기다려야 한다. 이런 방식 보다는 요청 스레드를 블로킹하지 않고 백그라운드에서 요청이 처리되는 동안 요청 스레드는 다른 작업을 수행하다가 요청이 처리 완료되면 결과를 받고 클라이언트에게 응답으로 보내는 것이 더 효율적이다. 클라이언트의 요청을 비동기적으로 처리하면 적은 수의 스레드로 많은 작업을 수행할 수 있으며 서버(서블릿 컨테이너)의 부하를 줄일 수 있다.

스프링 MVC에서 프론트 컨트롤러의 핸들러 메서드는 다양한 형태의 데이터를 응답으로 반환할 수 있지만 핸들러 메서드가 요청을 비동기적으로 처리하기 위해서는 응답 데이터를 특별한 타입으로 반환해야 한다.

스프링 MVC에서 핸들러 메서드가 지원하는 비동기 반환 타입은 다음과 같다.

  • DeferredResult: 나중에 다른 스레드가 생산할 비동기 결과이다. Callable<?> 대신 사용할 수 있다.
  • ListenableFuture<?>: 나중에 다른 스레드가 생산할 비동기 결과이다. DeferredResult 대신 사용할 수 있다.
  • CompletableStage<?>, CompletableFuture<?>: 나중에 다른 스레드가 생산할 비동기 결과이다. DeferredResult 대신 사용할 수 있다.
  • Callable<?>: 나중에 결과를 반환할(또는 예외를 던질) 작업이다.
  • ResponseBodyEmitter: 여러 객체를 응답에 실어 클라이언트에 비동기로 전송할 때 사용한다.
  • SseEmitter: SSE를 비동기로 작성할 때 사용한다.
  • StreamingResponseBody: OutputStream을 비동기로 작성할 때 사용한다.

위의 비동기 반환 클래스 및 인터페이스는 모두 제네릭 타입이며 기존에 컨트롤러가 반환하던 모든 반환 타입을 인자로 전달하여 비동기 응답 데이터를 구성할 수 있다. 요청의 비동기 처리를 위해서는 핸들러 메서드의 반환형을 비동기 반환 타입으로 정의할 뿐만 아니라 모든 필터와 서블릿이 비동기로 작동하게끔 활성화해야 한다. 따라서 필터와 서블릿을 등록할 때 setAsyncSupported() 메서드를 호출하여야 한다. 구성 클래스가 추상 클래스 AbstractAnnotationConfigDispatcherServletInitializer를 상속하는 경우 이 클래스에 등록된 DispatcherServlet과 필터의 isAsyncSupported 프로퍼티가 이미 활성화되어 있으므로 별도 설정은 필요 없다.

자바에서 제공하는 인터페이스인 CompletableFuture는 비동기적으로 작업을 처리하기 위한 도구로 사용되며, 이를 핸들러 메서드의 반환형으로 정의하는 경우 핸들러 메서드들이 병렬로 실행될 수 있다. 반환형이 CompletableFuture인 핸들러 메서드는 비동기적으로 실행되며 요청 스레드는 비동기적으로 핸들러 메서드 작업(요청 처리)을 백그라운드 스레드에게 위임하고 웹 요청에 대한 반환 결과를 기다린다. 따라서 애플리케이션은 리소스(스레드)를 낭비하지 않고 최대한으로 활용하여 더 많은 요청을 처리할 수 있다. 이는 애플리케이션의 확장성과 처리량을 향상시키는 데 도움이 될 수 있다.

스프링 MVC에서 컨트롤러의 핸들러 메서드를 호출하는 것은 서블릿 컨테이너의 DispatcherServlet이다. 스프링 MVC가 제공하는 다양한 기술들을 사용하여 웹 요청을 처리하고 응답을 생성하는 핵심 서블릿 클래스가 바로 DispatcherServlet이다. DispatcherServlet는 클라이언트로부터 웹 요청을 받아 적절한 핸들러 메서드로 웹 요청을 위임하는 라우팅(경로 설정) 역할을 수행한다. 따라서 요청에 따라 적절한 컨트롤러 클래스 및 컨트롤러의 핸들러 메서드를 호출하는 호출자는 바로 DispatcherServlet이다. 핸들러 메서드가 비동기/동시성 프로그래밍을 위한 인터페이스인 CompletableFuture를 반환하는 경우 이를 받아 처리하는 서블릿 컨테이너의 동작 방식에 따라 완전한 비동기/동시성 프로그래밍이 가능할 수도, 그렇지 않을 수도 있다.

앞서 서블릿 3.1 버전 부터 완전한 비동기 및 동시성 프로그래밍이 가능하다고 했다. 3.0 이전 버전에서는 핸들러 메서드에게 작업을 요청하고 결과를 받아 처리하는 호출자 스레드와 요청 처리 작업을 실행하는 작업 스레드는 동일한 스레드였다. 즉, 요청을 받은 스레드가 요청 작업까지 완료하였다. 이러한 구조에서 호출자 스레드는 작업이 완료되어 결과를 반환받을 때까지(자신이 모든 작업을 수행 완료할 때까지) 동안 블로킹되어 다른 작업을 수행할 수 없다. 즉, 호출자는 요청 작업이 완료될 때까지 기다리며 다른 요청을 처리할 수 없다. 작업이 IO 작업을 포함한다면 해당 스레드는 IO 처리 결과를 기다리는 동안 다른 작업을 수행하지 못한 상태로 블로킹된다. 따라서 작업 스레드 풀의 자원을 효율적으로 활용하지 못한다.

비동기/동시성 프로그래밍을 위해서는 호출자 스레드와 작업 스레드를 분리하여 독립적으로 동작하도록 구성해야 한다. 서블릿 3.0 버전에서는 호출자 스레드와 작업 스레드가 서로 분리되어 동작할 수 있다. 따라서 호출자 스레드는 작업 스레드에게 작업을 요청한 후 결과를 기다리지 않고 다른 작업(다른 요청 작업)을 처리할 수 있다. 요청을 받은 스레드가 요청 처리 작업까지 완료하는 것이 아니며 스레드는 도중에 해제될 수 있다. 작업 스레드는 백그라운드에서 동작하며 요청 작업의 결과가 준비되는 즉시 호출 스레드에게 반환한다. 하지만 서블릿 3.0 버전에서는 이러한 비동기적 요청 처리 작업이 전통적인 IO 상에서 이루어졌다. 서버가 클라이언트와 HTTP 연결을 수립하고 요청에 대한 응답 데이터를 작성 및 전송하는 동안 서블릿 컨테이너의 해당 스레드가 블로킹되는 한계점이 존재하였다. 요청을 처리하는 작업을 별도의 백그라운드 스레드에서 수행할 수는 있지만 클라이언트에게 요청에 대한 응답을 제공하는 작업이 서블릿 컨테이너의 스레드를 블로킹하는 동작으로 인해 여전히 스레드 블로킹으로 인한 리소스 점유의 한계가 존재했다.

서블릿 3.1 버전부터 도입된 NIO는 버퍼와 채널을 사용하여 네트워크 상의 논블로킹 통신을 가능하게 한다. 따라서 서블릿 컨테이너의 호출 스레드는 요청 작업 처리를 백그라운드 스레드에 요청할 수 있으며, 요청 작업이 완료된 후 그 결과를 가지고 요청에 대한 응답 데이터를 작성하는 동안 호출 스레드는 블로킹되지 않아 완전한 비동기 및 논블로킹 기반의 웹 요청 처리가 가능하다.

단일 요청의 처리 시간 자체는 CompletableFuture를 사용하더라도 크게 달라지지 않을 수 있다. 핸들러 메서드는 요청 작업에 대한 결과 반환이라는 동기적 작업이기 때문이다. 핸들러 메서드 내에서 여러 작업을 처리하는 경우 이러한 작업을 또다시 비동기적으로 처리를 하여 요청을 스레드 블로킹 없이 효율적으로 처리한다면 단일 요청 처리의 속도를 높일 수는 있을 것이다.


스프링의 @Async

스프링은 메서드를 별도의 스레드에서 비동기적으로 호출하기 위해 @Async 어노테이션을 제공한다. 해당 메소드는 별도의 스레드에서 실행된다. @Async 메서드의 호출자는 메서드 호출 즉시 값을 반환받으며, 메서드의 실제 실행은 스프링의 TaskExecutor에게 제출된 작업에서 일어난다. @Async 메서드의 반환 유형은 일반적인 참조 타입 또는 원시 타입이어도 되지만 메서드 호출자가 비동기적으로 실행한 작업에 대한 결과를 받고 작업과 콜백을 결합하거나 작업들을 서로 결합하기 위해서는 Future 인터페이스 타입을 반환하는 것이 좋다. 비동기 작업을 위한 인터페이스인 CompletableFuture는 제네릭 타입이므로 작업 수행 결과의 타입을 파라미터로 전달받을 수 있다. CompletableFuture의 이미 정해진 결과를 반환하는completedFuture() 메서드를 사용하거나 비동기적 작업을 실행한 후 결과를 반환하는 runAsync(), supplyAsync() 메서드를 사용하여 완료된 CompletableFuture 인스턴스를 반환할 수 있다.

@Async 메서드를 별도의 스레드에서 비동기적으로 실행하기 위해서는 @Configuration 어노테이션이 설정된 구성 클래스나 @SpringBootApplication 어노테이션이 설정된 클래스에 @EnableAsync 어노테이션 설정이 필요하다. @EnableAsync 어노테이션은 백그라운드 스레드 풀에서 @Async 메서드를 실행하도록 스프링의 관련 기능을 활성화한다. 구성 클래스에서 Executor 빈 정의 시 프로퍼티를 설정함으로써 코어 풀 사이즈(동시 스레드 개수), 최대 풀 사이즈, 큐 사이즈 등 스레드 풀과 관련된 설정이 가능하다. Executor 빈을 정의하지 않으면, 기본적으로 SimpleAsyncTaskExecutor 구현체를 빈으로 생성 및 등록하여 이를 사용한다.

CompletableFuture를 반환하는 @Async 메서드를 사용한 메서드의 비동기 실행 코드는 다음과 같다.

public class MyService {
  // 비동기 메서드 정의
  @Async
  public CompletableFuture<String> getResultA() {
    // 비동기 작업 정의
    return CompletableFuture.supplyAsync(() -> { ... });
  }
  
  @Async
  public CompletableFuture<String> getResultB() {
    return CompletableFuture.supplyAsync(() -> { ... });
  }
  
  @Async
  public CompletableFuture<String> getResultC() {
    return CompletableFuture.supplyAsync(() -> { ... });
  }
}

public class MyClient {
  // 호출자 스레드
  public void getResult() {
    ...
    
    // 작업 스레드
    CompletableFuture<String> completableFutureA = myService.getResultA();
    CompletableFuture<String> completableFutureB = myService.getResultB();
    CompletableFuture<String> completableFutureC = myService.getResultC();
    
    // 다중 작업 실행 (비동기 작업 병렬 실행)
    // 모든 작업이 완료될 때까지 기다린다.
    CompletableFuture.allOf(completableFutureA, completableFutureB, completableFutureC).join();
  }
}

@Async 메서드는 각각 별도의 스레드에서 비동기적으로 실행된다. 하지만 Executor의 코어 풀 사이즈에 따라 여러 메서드가 하나의 스레드 상에서 실행될 수 있다.

비동기적으로 처리할 메서드가 세 개이고 각각 실행이 완료되기까지 1초의 시간이 걸리며, 코어 풀 사이즈가 2라고 가정하면 메서드와 메서드가 실행되는 스레드 정보는 다음과 같다.

  1. 메서드1이 스레드1 상에서 실행
  2. 메서드2가 스레드2 상에서 실행
  3. 메서드1이 완료되어 메서드3이 스레드1 상에서 실행


이 때 모든 작업이 실행 완료되기까지 소요되는 시간은 최소 2초이다. 코어 풀 사이즈가 3이라면 최소 1초가 소요되며 사이즈가 1이라면 최소 3초가 소요된다. 만약 메서드를 비동기가 아닌 동기적으로 처리한다고 가정하면 세 개의 메서드는 동기적으로 차례대로 실행되므로 모든 작업이 실행 완료되기까지 소요되는 시간은 최소 3초이다. 비동기적으로 처리할 수 있는 작업들을 동기적으로 처리하고 있었고 멀티 스레드 구현이 가능한 환경이라면 작업들을 비동기적으로 처리하여 작업 시간을 단축할 수 있다.

작업을 비동기적으로 처리함으로써 처리 시간을 단축시키려면 작업의 병렬성을 고려하여 비동기적으로 실행할 작업의 수와 스레드의 수를 적절히 정해야 한다. 단일 스레드 환경에서는 비동기 처리의 이점을 얻기는 힘들며 멀티 스레드 환경에서 하나 이상의 작업이 하나의 스레드 상에서 수행되도록 함으로써 더 빠른 속도로 모든 작업을 수행 완료할 수 있다. 하지만 스레드는 한정된 자원이다. 비동기적으로 처리할 수 있는 작업들이 많다고 해도 가용 스레드의 수는 한정되어 있다. 처리할 작업의 수에 비해 상대적으로 가용 스레드 수는 적은 것이 일반적이다. 따라서 모든 작업들을 비동기적으로 처리할 수 있다 해도 한정된 스레드 자원으로 인해 작업 시간을 단축하는 데에는 한계가 있다.


스프링 웹플럭스

리액티브 웹 애플리케이션 개발을 위한 스프링 웹플럭스는 스프링 MVC와 구동 방식이 다르다. 스프링 MVC에서는 디스패처 서블릿(DispatcherServlet)이 프론트 컨트롤러 역할을 하여 웹 애플리케이션으로 들어오는 요청을 가장 먼저 받아 처리했다. 스프링 웹플럭스에서는 디스패처 핸들러(DispatcherHandler)링크가 디스패처 서블릿의 역할을 대신하여 요청을 가장 먼저 처리한다. DispatcherHandler는 웹플럭스에서 HTTP 요청을 처리 및 컨트롤하는 핸들러/컨트롤러를 위한 중앙 디스패처이다.

DispatcherHandler는 스프링 애플리케이션 컨텍스트에 등록된 여러 가지 구성 가능한 델리게이트(delegate) 컴포넌트들을 사용하여 웹 요청을 처리한다. 정확히는 DispatcherHandler는 요청 처리를 위한 알고리즘을 제공하고, 실제 요청 처리 작업은 델리게이트 컴포넌트들이 수행한다. 스프링 웹플럭스가 제공하는 델리게이트 컴포넌트들은 다음과 같다.

  • HandlerMapping: 요청을 핸들러 객체에 매핑한다.
  • HandlerAdapter: 모든 핸들러 인터페이스를 위한 어댑터이다.
  • HandlerResultHandler : 핸들러를 처리하여 값을 반환한다.


스프링 웹플럭스 웹 애플리케이션은 서블릿 명세를 따르는 서블릿 컨테이너 뿐만 아니라 웹플럭스의 인터페이스를 구현한 기타 웹 컨테이너(네티, 리액터 네티, 언더토우 등)도 지원한다. 스프링 웹플럭스는 웹 애플리케이션 동작을 위해 HandlerAdapter 인터페이스를 제공하고 각 컨테이너 별로 HandlerAdapter 구현체가 달라진다. 리액터 네티의 경우 구현체는 ReactorHttpHandlerAdapter이며, 서블릿 컨테이너의 경우 ServletHttpHandlerAdapter이다.

HttpHandler 인터페이스는 리액티브 HTTP 요청 처리를 위한 스프링 웹플럭스의 최하위 컴포넌트이며 하나의 handle() 메서드를 제공한다. handle() 메서드는 Mono<Void> 형을 반환하며 org.springframework.http.server.reactive 패키지의 ServerHttpRequest, ServerHttpResonse 객체를 인자로 받는다. 두 객체 역시 인터페이스이고 컨테이너 종류에 따라 구현체가 달라진다. 각각 서블릿의 요청 및 응답 객체인 HttpServletRequestHttpServletResponse에 대응된다.

스프링 웹플럭스가 지원 가능한 컨테이너 별로 생성되는 HandlerAdapter, HttpHandler 인스턴스가 다르다. 스프링 웹플럭스는 호환성 유지를 위해 인터페이스를 제공하며 각 컨테이너는 이 인터페이스를 따르므로 서로 호환 가능하다. 공통적으로 웹 요청을 가장 먼저 받아 처리하는 HandlerAdapter 구현체에 HttpHandler 구현체를 등록하여 애플리케이션을 구성한다.

스프링 MVC와 스프링 웹플럭스는 모두 프론트 컨트롤러 패턴을 사용하여 웹 요청을 처리한다. 따라서 프론트 컨트롤러 역할을 하는 인스턴스만 다르며 스프링 MVC에서 컨트롤러 클래스와 핸들러 메서드를 정의하기 위해 사용했던 @Controller@RestController를 스프링 웹플럭스에서도 동일한 역할 및 기능을 위해 그대로 사용 가능하다.

스프링 웹플럭스의 컨트롤러 클래스에서 @RequestMapping를 적용한 핸들러 메서드가 인자로 받을 수 있는 것은 다음과 같다.

  • 요청 및 응답 객체인 ServerHttpRequest 또는 ServerHttpResponse
  • URL에서 추출한 요청 매개변수 (@RequestParam 적용)
  • 모델 속성 (@ModelAttribute 적용)
  • 요청에 포함된 쿠키값 (@CookieValue 적용)
  • 핸들러 메서드가 모델에 속성을 추가하기 위해 사용하는 Map 또는 ModelMap
  • 핸들러 메서드가 객체 바인딩/유효성을 검증한 결과를 가져올 때 필요한 Errors 또는 BindingResult
  • 핸들러 메서드가 세션 처리를 완료했음을 알릴 때 사용하는 SessionStatus

스프링 웹플럭스에서는 요청을 처리하는 리액티브 핸들러 함수를 정의할 수 있다. @RequestMapping을 붙여 요청을 핸들러 메서드에 매핑하는 대신, HandlerFunction라는 함수형 인터페이스를 사용하여 함수를 작성함으로써 요청을 처리하는 핸들러 함수 정의가 가능하다.


웹플럭스에서 컨트롤러 메서드의 반한형

웹플럭스에서 프론트 컨트롤러 클래스에 정의한 핸들러 메서드의 반환형이 String일 경우와 Mono<String>, Flux<String>일 경우 차이점은 다음과 같다.

먼저 String일 경우 서버는 문자열 데이터를 단일 응답 데이터(비스트림 데이터)로 HTTP 응답 본문에 작성한 후 클라이언트에게 제공한다. 이 경우는 요청 당 응답이 제공되는 전형적인 HTTP 요청/응답 처리 과정이며 컨트롤러 메서드는 동기적으로 동작한다. 클라이언트는 컨트롤러 메서드의 작업이 완료될 때까지 응답을 기다려야 한다. 클라이언트에게 제공할 응답을 구성하는 요청 스레드는 컨트롤러 메서드의 작업이 완료될 때까지 블로킹된다.

Mono<String>인 경우 서버는 하나의 문자열 데이터를 단일 청크(단일 응답 데이터)로 클라이언트에게 제공한다. 이 경우 컨트롤러 메서드는 비동기적으로 동작한다. 클라이언트는 컨트롤러 메서드의 작업이 완료되지 않아도 호출 즉시 응답을 받을 수 있으며, 서버는 메서드 작업의 완료되어 결과값이 준비되면 클라이언트에게 응답으로 제공한다. 클라이언트에게 제공할 응답을 구성하는 요청 스레드는 컨트롤러 메서드의 작업이 완료될 때까지 블로킹되지 않는다.

StringMono<String>의 차이는 Mono<String>의 경우 리액티브 프로그래밍 구현을 위한 리액터 타입이므로 비동기, 논블로킹 연산이 가능하다는 점이며 클라이언트가 받는 응답 데이터는 동일하다.

반면 Flux<String>일 경우 서버는 문자열 데이터를 청크 단위로 나누어(문자열을 하나씩) 스트림 데이터로 클라이언트에게 제공한다. 실제로 컨트롤러 메서드가 응답 메시지를 어떤 미디어 타입으로 매핑하는지에 따라 클라이언트가 받는 메시지의 형태가 달라진다. 응답 헤더의 Content-Type 값이 text/plain, application/json인 경우 TCP 기반 HTTP 프로토콜의 동작 방식으로 인해 응답 데이터가 모두 준비될 때까지 클라이언트가 기다리는 것은 동일하다. 반면 응답 헤더의 Content-Type 값이 text/event-stream, application/stream+json, application/x-ndjson일 경우 클라이언트는 데이터가 모두 준비되지 않더라도 데이터가 모두 전송될 때까지 스트리밍 형태로 시간을 두고 받을 수 있다. 이 경우 Flux 데이터 스트림이 완료될 때까지 HTTP 연결은 열린 상태가 된다. 웹플럭스는 데이터 스트림의 데이터 항목들을 개별적으로 처리하고 스트림을 통해 시간을 두고 비동기적으로 방출한다.


SSE 스트리밍 데이터

SSE(server-sent event)란 클라이언트-서버 모델에서 HTTP 프로토콜을 기반으로 단일 HTTP 연결을 통해 서버가 클라이언트에게 실시간 텍스트 데이터를 이벤트 스트림 형태로 제공하는 기술이다. SSE를 사용하여 클라이언트는 서버가 제공하는 이벤트 스트림을 구독할 수 있으며, 서버는 데이터가 준비되는 즉시(사용 가능한 즉시) 클라이언트에게 데이터를 보낼 수 있다. SSE는 HTTP 프로토콜 기반이므로 단방향 통신(one-way connection)만을 지원하며(클라이언트는 서버에게 이벤트를 보낼 수 없다) 웹 소켓 프로토콜 기반의 양방향 통신 보다 구현이 쉽다.

SSE를 사용하면 서버는 단방향 이벤트 스트림을 처리하고, 클라이언트는 서버가 데이터를 방출할 때마다 데이터를 받을 수 있다. 서버가 SSE를 사용하여 스트림 데이터를 응답 데이터로 제공하는 경우 클라이언트는 받고자 하는 모든 데이터가 준비될 때까지 기다리지 않아도 되며 전체 데이터 중 일부가 서버에 의해 제공되더라도 응답으로 받아 처리 가능하다.

SSE 데이터의 경우 HTTP 요청 헤더는 Accept: text/event-stream 값을 가지며 응답 헤더는 Content-Type: text/event-stream를 가진다. text/event-stream 미디어 타입은 응답 데이터에 접두사인 data:를 추가해준다. 이 미디어 타입은 자바스크립트 API인 EventSource를 통해 지원하므로 웹 브라우저 전용이다.

스프링 MVC나 스프링 웹플럭스는 SSE 스트림 데이터를 응답 데이터로 제공할 수 있다. 스프링 MVC의 경우 컨트롤러 메서드의 반환형을 ResponseBodyEmitter 구현체인 SseEmitter로 지정함으로써 SSE 지원 기능 제공이 가능하다. SseEmittersend() 메서드를 사용하여 응답 데이터를 스트림 형태로 연속적으로 구성하고, complete() 메서드를 사용하여 스트림 데이터를 생성 완료한다. 이때 send() 메서드를 사용하여 응답 데이터를 생성하는 작업을 담당하는 별도의 스레드 할당이 필요하다.

스프링 웹플럭스에서는 리액터 타입인 Flux<String>를 컨트롤러 메서드의 반환형으로 지정하고 @GetMapping 어노테이션의 produces 속성을 MediaType.TEXT_EVENT_STREAM_VALUE로 지정하여 SSE 지원이 가능한 엔드포인트 구성이 가능하다. 또는 미디어 타입 지정 없이 Flux<ServerSentEvent<String>를 컨트롤러 메서드의 반환형으로 지정해도 된다.


코틀린 코루틴


성능 비교

서블릿 컨테이너 3.1 비동기 호출, 리액터 네티 내장 웹플럭스, 톰캣 내장 웹플럭스


참고

Comments