Spring-Webflux와 Spring Core component

박재현·2022년 4월 24일
1

spring

목록 보기
1/2
post-thumbnail

WebFlux

2000년 초 Spring Framework이 소개되며 IoC/DI라는것을 프레임워크에 녹인 엄청난 기능을 제공할 뿐만 아니라 MVC와 같은 편리한 도구를 통해 성능과 어플리케이션 품질을 일정이상 보장하는 형태의 구조화된 웹 서비스를 쉽고 빠르게 구축하는데 큰 기여를 했으며 현재에도 국내/외 많은 사용자를 보유중입니다.

2009년 Node.js에서 이벤트 루프를 활용한 asynchronous & non-blocking 기반 프레임웍 아키텍쳐를 제시했고 엔터프라이즈 시스템 수준에서도 효과적으로 적용할 수 있음이 확인되자, 전반적인 웹 서비스 프레임워크의 아키텍쳐에 전반적으로 새로운 패러다임의 바람이 불게 되었습니다.

그리고 asynchronous & non-blocking 와는 별개로 JVM 진영에서 ProjectReactor, ReactiveX/RxJava와 같은 Reactive-Programming 도구들이 계속 발전 해왔으며, Java는 8 버전 부터 CompletableFuture, stream, functinal interface, lambda 등 Reactive Programming과 Functinal Programming 패러다임에 사용할 수 있는 기능들을 적극적으로 추가/지원해왔습니다.

그리하여 Spring은 WebFlux를 Spring Framework5(Boot 2.0) 버전부터 공식적으로 발표합니다.
기존 Spring Web MVC는 Servlet API 및 Servlet 컨테이너를 대상으로 지원하고 있었는데, Spring-WebFlux는 Reactive-Stack Web Framework으로, Non-blocking 과 Reactive Streams의 back pressure를 지원하고 Netty, Undertow 및 Servlet 3.1+ 컨테이너와 같은 서버를 지원합니다. (WebFlux는 엄밀히 말하면 Spring 의 모듈중 하나 입니다)

기존 Servlet API를 사용하지 않고 asynchronous & non-blocking 용 공통 API가 새로 탄생함
작은 수의 스레드로 동시성을 처리하고 더 적은 하드웨어 리소스로 확장할 수 있는 non-blocking web stack이 필요했고.
Servlet은 3.1 부터 non-blocking I/O를 위한 API를 제공했지만. 그러나 기존 Spring-MVC에서 이 기능을 사용하면 트랜젝션이 동기적으로 변하거나 (필터, 서블릿) 또는 blocking (getParameter, getPart) 되는 문제가 있었기 때문에 asynchronous & non-blocking 을 기반으로 작성된 공통 API를 새로 사용합니다.

리액티브, 함수형 프로그래밍 지원
Java 8에서 람다 표현식을 추가하면서 Java에서 functinal API에 대한 기회가 생겼기 때문에, 비동기 로직의 선언형 개발과 non-blocking 애플리케이션 및 연속 스타일 API(CompletableFuture, ReactiveX, Reactor) 에 대해 친화적으로 지원합니다. 따라서 functinal endpoints 또한 제공합니다.
Reactive Streams는 back-pressure가 있는 asynchronous한 구성 요소 간의 상호 작용을 정의하는 규격 또는 사양입니다.(Java 9에서도 채택 됨) Reactive Streams의 주요 목적은 Subscriber가 Publisher가 보내는 스트림의 속도를 제어할 수 있도록 하는 것입니다.
Reactive Streams API는 저수준에 집약되어있어, 애플리케이션 개발에 유용하게 사용하지 못하는 경우가 많기 때문에 더 많은 애플리케이션 개발에 필요한 기능들을 사용하기위해 Reactive 라이브러리로 Reactor 라이브러리를 사용 합니다.

코루틴 지원
Reactor와 별개로, Couroutines 기반의 asynchronous & non-blocking 코드를 지원합니다.Controller에서 return Type을 Deferred, Flow으로 가능 & 중단 함수 지원 등등...

Spring MVC와의 비교

Spring MVC와 Boot에서 보이는 차이는 configuration 측면에서 많이 강조되는 경향이 있다면 WebFlux와의 차이는 실제 구현체들이 NIO로 변경되고 비동기-넌블로킹을 지원하는 Functinal Reactive Programming 친화적으로 변경되는 큰 차이를 보입니다.

대표적으로 구현체 변경은 HttpServlet을 구현한 DispatcherSevlet이 DispatcherHandler로 변경된것과, returnValueHandler가 ResultHandler로 변경되는 등의 변경이 있습니다.

spring stack

이는 기본적으로 Webflux에서 NIO를 지원하면서 Reactive 패키지로 별도로 분리된 공통 추상화 Servlet API를 새로 표준화하여 작성한 Reactive-Stream API 기반으로 작성된 Reactive Streams Adapters와 이를 지원하는 Servlet을 (Default 는 Netty) 사용하고 있으며 Webflux 내부적으로도 Reactive를 지원하는 구현체들로 변경되는것이 필요했기 때문이라고 볼 수 있습니다.

아래에서 좀 더 자세히 다룰 예정이지만
MVC, Boot 환경에서는 기본적으로 아래와 같은 web.servlet 패키지의 의 Servlet Interface를 Spring에서 구현한 구현체인 'DispatcherServlet' 를 이용합니다. (Dispatcher Servlet은 Spring Front Controller 라고 불리기도 합니다.)

반면, WebFlux에서는 Servlet API를 사용하지 않고, Reactive Streams 기반으로 작성된 interface를 이용하는 DispatcherHandler 라는 구현체를 사용합니다.

생략된 부분이 많지만, 일반적으로 @RestController를 이용한 RESTful API를 제공하는 Spring Application을 개발하면 위와 같은 동작방식으로 예상됩니다.

MVC, Boot 환경에서는 일반적으로 서블릿 컨테이너인 Tomcat을 이용하는데 Tomcat은 기본적으로 Default Thread Pool이 200개로 구성되며, 하나의 request당 하나의 Thread가 할당되어 연산 종료시 점유한 쓰레드를 반환합니다.

즉 개별적 스레드가 할당되어 요청이 완료될 때 까지 점유되었다가 요청을 보내고 난 후 해당 스레드를 가용한 상태로 전환합니다.

그래서 서버 하드웨어가 처리 가능한 쓰레드를 적절하게 Tomcat 에 설정하곤 합니다.
이런 Tomcat의 모델을 Thread per request model 이라고 합니다.

Servlet Connector를 BIO에서 NIO로 변경하면 쓰레드 idle 시간을 20~30% 줄이면서 connector -> Servlet 쓰레드 할당 시점에 성능을 다소 올릴 수 있지만 Thread 할당 시점 이후부터는 다시 thread per request model이 기본적이라는것은 변함이 없습니다.

이렇게 SpringMVC, Boot 환경에서는 동시접속 사용자 처리를 위하여 Tomcat의 thread per request model 모델을 활용하고 있기 때문에, 요청수가 설정된 쓰레드에 근접할수록 쓰레드 풀에 가용한 쓰레드가 부족해지면서 response 레이턴시가 증가하는 추세를 보입니다.

더 많은 가용 Thread 를 Thread Pool에 할당해도 되지만, Core 수 이상의 더 많은 Thread가 사용되면 결국 Context Switching에 더 많은 비용이 들게됩니다. 결과적으로 유저가 많아지면 선형적으로 비용도 증가할 수 밖에 없는 구조입니다.

Webflux는 상대적으로 적은 수의 쓰레드를 가지고 있으면서, 더 많은 요청이 발생했을때 효율적이고 안정적인 서버 퍼포먼스를 제공할 수 있는 EventLoop model을 지원하는 Netty를 기본 Servlet Container로 채택합니다.(Netty는 현재 Line에 계신 개발자 이희승님이 창시자 격으로 참여하셨다고 합니다. 국뽕!)

Netty의 이벤트 루프 모델

Netty의 이벤트 루프는 쓰레드의 하나의 이벤트 루프당 하나의 이벤트 큐를 유지함으로서, 공통 EventQueue를 사용하는것보다 경쟁상태에 소비하는 오버헤드가 더 작습니다. 그리고 Spring의 네트워크 계층과의 연결 뿐만아니라 grpc등 통신 프로토콜에서도 사용되는 신뢰받는 통신 프레임워크로 인정받고 있습니다.

Netty

Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients

Netty는 Spring의 네트워크 계층과의 연결 뿐만아니라 grpc등 통신 프로토콜에서도 사용되는 신뢰받는 통신 프레임워크로 인정받고 있습니다.

Bootstrap
Bootstrap클래스는 Netty 부트스트랩을 처리합니다. 부트스트래핑 프로세스에는 스레드 시작, 소켓 열기 등이 포함됩니다.

EventLoop
Netty는 네트워크 소켓에서 들어오는 새로운 이벤트를 계속 찾는 루프입니다. 이벤트 발생을 식별하고, 이벤트를 처리할 적절한 Handler로 전달합니다. (SocketChannel 이벤트가 발생하면 적절한 이벤트 핸들러 예를들어 ChannelHandler로 이벤트를 전달합니다.)

EventLoopGroup
EventLoopGroup은 EventLoop 들의 그룹입니다. EventLoopThread 등과 같은 일부 리소스를 공유합니다.

SocketChannel
SocketChannel는 네트워크를 통해 다른 컴퓨터에 대한 TCP 연결을 말합니다. Netty를 클라이언트로 사용하든 서버로 사용하든 네트워크의 다른 컴퓨터와 교환되는 모든 데이터는 TCP 연결을 나타내는 SocketChannel 인스턴스를 통해 전달됩니다.

SocketChannel은 EventLoop에 의해 관리되며 또 항상 동일한 EventLoop 인스턴스에 의해 관리되는것이 보장됩니다. 이벤트 루프는 항상 동일한 쓰레드에 의해 실행되기 때문에, SocketChannel 인스턴스 또한 동일한 쓰레드에 의해서만 접근됩니다. 따라서 소켓 채널에서 읽을때 스레드 동기화 문제를 걱정할 필요가 없습니다.

ChannelInitializer
ChannelInitializer는 소켓 채널이 생성될 때 소켓 채널의 채널 파이프라인에 연결되는 특수 채널 핸들러입니다. 그런 다음 소켓 채널을 초기화할 수 있도록 ChannelInitializer가 호출됩니다.
소켓 채널을 초기화한 후 채널 초기화기가 채널 파이프라인에서 자동으로 제거됩니다.

ChannelPipeline
각 Socket Channel에는 채널 파이프라인이 있습니다. ChannelPipeline에는 ChannelHandler 인스턴스 목록이 포함되어 있습니다. EventLoop이 SocketChannel에서 데이터를 읽을 때 데이터는 ChannelPipeline의 첫 번째 ChannelHandler로 전달됩니다. 첫 번째 채널 핸들러는 데이터를 처리하고 채널 파이프라인의 다음 채널 핸들러로 전달하도록 선택할 수 있으며, 선택된 채널 핸들러는 데이터를 처리하고 다시 채널 파이프라인 내 다른 핸들러로 전달하도록 선택할 수 있습니다.

ChannelHandler
Channel Handler는 SocketChannel에서 수신한 데이터를 실제로 처리하는 핸들러입니다.

위 과정을 코드로 보면 아래와 같이 표현됩니다.

    public void run() throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 public void initChannel(SocketChannel ch) throws Exception {
                     ch.pipeline().addLast(new DiscardServerHandler());
                 }
             })
             .option(ChannelOption.SO_BACKLOG, 128)
             .childOption(ChannelOption.SO_KEEPALIVE, true);
    
            // Bind and start to accept incoming connections.
            ChannelFuture f = b.bind(port).sync(); //
    
            // Wait until the server socket is closed.
            // In this example, this does not happen, but you can do that to gracefully
            // shut down your server.
            f.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }

Channel, Thread, EventLoop, EventLoopGroup 간의 관계

  • 각 EventLoopGroup은 하나 이상의 EventLoop를 포함한다
  • 각 EventLoopGroup은 EventLoop를 Channel에 할당한다
  • 한개의 EventLoop에 한개 이상의 Channel을 할당할 수 있다
  • 각 Channel은 생명주기 간 하나의 EventLoop에 등록된다
  • 각 EventLoop는 생명주기 간 하나의 Thread 로 바인딩 되는것이 보장된다

여기서, BossGroup은 실제 이벤트 루프 입력점에서 I/O 작업을 처리하는 이벤트 루프그룹이고, workerGroup은 실제 이벤트를 송/수신하는 작업을 하는 이벤트 루프 그룹입니다.
생성자를 통해서 이벤트 루프 그룹에서 가용되는 이벤트 루프 쓰레드 개수를 지정할 수 있는데요.

우리는 직접 Netty를 셋팅하고 운용하지 않으니 이 bossGroup과 WorkerGroup이 기본값으로 설정됩니다.
기본적으로 WorkerGroup은 머신 코어수*2, BossGroup은 포트 바인딩한 1개의 쓰레드를 사용하는것으로 보입니다.

물론 BossGroup에 여러개의 쓰레드와 이벤트루프를 만들어 사용하면 서버 포트를 여러개 운용할때 사용할 수 있다고 합니다.

그리고 BossGroup의 쓰레드 개수와 서버 포트의 바인딩은 아래 Q&A가 도움이 될 수 있을 것 같습니다.

core component의 차이

기존에 Spring MVC 환경에서는 서블릿 컨테이너로 들어온 요청이 Dispatcher Servlet으로 전달되면, Dispatcher Servlet은 HandlerMapping, HandlerAdapter, ViewResolver(핸들러 어댑터로 실제 결정된 핸들러에 따라 ViewResolver 사용은 선택적임) 를 사용해 핸들링을 진행했습니다.

WebFlux에서의 core component의 흐름은 이렇게 변경되었습니다.

기존 DispatcherServlet이 하던 역할을 DispatcherHandler가 하게되고, HandlerMapping과 HandlerAdapter는 MVC의DispatcherServlet과 동일한 이름을 갖고 행위도 같지만 내부적으로 reactive 하게 개발되어 reactor 패키지에 포함된 다른 코드로 변경되었습니다.

MVC
org.springFramework.web.servlet.HandlerMapping
org.springFramework.web.servlet.HandlerAdapter

WebFlux
org.springFramework.web.reactive.HandlerMapping
org.springFramework.web.reactive.HandlerAdapter

DispatcherHandler가 DispatcherHandler가 handle() 호출 하기 전 HandlerMapping은 request와 핸들러에 선언된 어노테이션 등 분석해서 적절한 핸들러를 loopUp 하고 런타임에 핸들링을 수행할 핸들러가 결정되고 HandlerAdaptor를 통해 사용됩니다.

또, HandlerAdaptor는 HandlerResultHandler 를 통해 HandlerAdaptor가 반환하는 Retsult를 HandlerResult 로 Wrapping 하여 반환하는 차이가 있습니다. (위 표에서는 ResultHandler를 넣기가 애매해서 빠져있음)

0개의 댓글