본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[Spring] WebFlux는 어떻게 적은 리소스로 많은 트래픽을 감당할까?

https://alwayspr.tistory.com/44

 

Spring WebFlux는 어떻게 적은 리소스로 많은 트래픽을 감당할까?

위 그림은 DZone 게시글 중 하나인 Spring WebFlux를 이용한 Boot2와 Spring MVC를 이용한 Boot1을 비교한 그래프이다. 해당 그래프에서는 두 가지 특징을 볼 수 있다. 첫 번째로는 유저가 적을 때에는 성능�

alwayspr.tistory.com

더보기

Spring WebFlux와 Spring MVC를 성능 비교했을때, 성능엔 별반 차이가 없다. 두번째로 유저수가 늘어날수록 극명한 성능 차이를 보여주고 있다. 어떻게 이러한 차이가 일어날 수 있을까?

 

1. I/O

더 적은 하드웨어 리소스, 더적은 스레드로 동시성을 처리하기 위해서는 논블로킹 웹스택이 필요하여 WebFlux가 만들어졌다. non-blocking을 통해서 적은 수의 리소스로 동시성을 다룬다는 것인데, I/O의 원리부터 시작해서 non-blocking에 대해 이해해본다.

사용자가 I/O 요청을 할때 CPU가 I/O Controller에 요청을 하고 I/O Controller가 파일을 다 가져오면 그것을 Memory에 적재시키고 CPU에게 완료되었다고 알려준다. 즉 큰 그림은 CPU -> I/O Controller -> CPU의 형태이다.

 

핵심은 CPU가 I/O를 직접 가져오는 것이 아니라, 작은 CPU라고 불리는 I.O Controller가 수행한다는 이야기다. 좀더 나아가면 작업을 단순히 위임시키고 작업이 완료되는 동안에는 다른 일을 할 수 있다는 말이다. 이러한 예처럼 I/O를 처리하는데 몇가지 방법이 있다.

Block I/O

가장 기본적인 I/O 모델이며 Spring MVC와 RDBMS를 사용하고 있으면 대부분 이 모델을 사용하고 있는것이다. Application에서 I/O 요청을 한 후 완료되기 전까지는 Application이 Block되어 다른 작업을 수행할 수 없다. 이는 해당 자원이 효율적으로 사용되지 못하고 있음을 의미한다.

 

그러나 생각을 해보면 Application들은 Blocking 방식임에도 불구하고 마치 Block이 안된듯이 동작하는 것처럼 보인다. 이것은 Single Thread 기반으로 하는 것이 아닌 Multi Thread를 기반으로 동작하기 때문이다. Block되는 순간 다른 Thread가 동작함으로써 Block의 문제를 해소하였다. 그러나 Thread간의 전환(Context Switching)에 드는 비용이 존재하므로 여러 개의 I/O를 처리하기 위해 여러개의 Thread를 사용하는 것은 비효율적으로 보인다.

Synchronous Non-Blocking I/O

Application에서 I/O를 요청 후 바로 return되어 다른 작업을 수행하다가 특정 시간에 데이터가 준비가 다되었는지 상태를 확인한다. 데이터의 준비가 끝날때가지 틈틈이 확인을 하다가 완료가 되었으면 종료된다.

 

여기서 주기적으로 체크하는 방식을 폴링(Polling) 이라고 한다. 그러나 이러한 방식은 작업이 완료되기 전가지 주기적으로 호출하기 대문에 불필요하게 자원을 사용하게 된다.

Asynchronous Non-blocking I/O

I/O 요청을 한 후 Non-Blocking I/O와 마찬가지로 즉시 리턴된다. 하지만, 데이터 준비가 완료되면 이벤트가 발생하여 알려주거나, 미리 등록해놓은 callback을 통해서 이후 작업이 진행된다.

 

이전 두 I/O의 문제였던 Blocking이나 Polling이 없기때문에 자원을 보다 효율적으로 사용할 수 있다. 이후로는 편의상 Non-Blocking I/O라고 한다.

Blocking I/O

@Test
public void blocking() {
    final RestTemplate restTemplate = new RestTemplate();
    
    final StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    
    for (int i = 0; i < 3; i++) {
        final ResponseEntity<String> response = 
                restTemplate.exchange(THREE_SECOND_URL, HttpMethod.GET, HttpEntity.EMPTY, String.class);
        assertThat(response.getBody()).contains("success");
    }
    
    stopWatch.stop();
    
    System.out.println(stopWatch.getTotalTimeSeconds());
}

Spring의 HTTP 요청 라이브러리인 RestTemplate을 사용하여 3초가 걸리는 API를 3번 호출하였다. 결과는 9.xx초가 나온다. 이유는 I/O가 요청 중일 때에는 아무 작업도 할 수 없기 때문이다.

Non Blocking I/O

@Test
public void nonBlocking() throws InterruptedException {
    final StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    for (int i = 0; i < LOOP_COUNT; i++) {
        this.webClient
                .get()
                .uri(THREE_SECOND_URL)
                .retrieve()
                .bodyToMono(String.class)
                .subscribe(it -> {
                    count.countDown();
                    System.out.println(it);
                });
    }
    
    count.await(10, TimeUnit.SECONDS);
    stopWatch.stop();
    System.out.println(stopWatch.getTotalTimeSeconds());
}

WebFlux에서 제공하는 WebClient를 사용해서 위와 동일하게 3초가 걸리는 API를 호출하는 코드이다. for문 안의 변수인 LOOP_COUNT는 100으로 코드상에서 설정되어 있다. 3초 걸리는 API를 100번 호출한다 하더라도 3.xx초 밖에 걸리지 않는다. 더 나아가서 LOOP_COUNT를 1000으로 변경하더라도 컴퓨터에서는 4.xx초 밖에 걸리지 않는다. Blocking I/O와 비교했을때 효율적이다.

 

만약, Blocking을 위처럼 많은 요청을 동시에 처리하려면 그 만큼의 Thread가 생성되어야 한다. 그러나 이렇게 처리한다 해도 Context Switching에 의한 오버헤드가 존재할 것이다.


Event-Driven

2018 10대 전략기술 트렌드에 Event-Driven이 포함되어 있다. 또한 Event-Driven을 토대로 많은 프레임워크와 라이브러리가 발전하고 있다. Spring WebFlux, Node.js, Vert.x 등이 그에 따른 예이다. 우리가 자주 접하는 기술들에 어떻게 스며들어 접목이 되었는지 살펴보도록 한다.

 

Event-Driven Programming은 프로그램 실행 흐름이 이벤트(ex: 마우스 클릭, 키 누르기 또는 다른 프로그램의 메시지와 같은 사용자 작업)에 의해 결정되는 프로그래밍 패러다임이다. Event가 발생할때 이를 감지하고 적합한 이벤트 핸들러를 사용하여 이벤트를 처리하도록 설계되었다. 순차적으로 진행되는 과거의 프로그래밍 방식과는 달리 유저에 의해 종잡을 수 없이 진행되는 GUI가 발전됨에 따라 Event-Driven 방식은 더욱더 많이 쓰이게 되었다.

 

아래는 Java를 이용하여 Click Event를 구현한 예이다.

JButton button = new JButton();
button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        System.out.println("clicked");
    }
});

물론 Lambda Expression을 사용하면 아래처럼 간결한 표현이 가능하다.

JButton button = new JButton();
button.addActionListener(e -> System.out.println("clicked"));

Java 이외 다른 언어에서도 자연스럽게 Listener에 등록하여 Event를 구현하고 있다. 그런데 Button은 어떻게 유저에 의해 Click이 되었는지 인지할 수 있을까? 단순히 Listener에 등록하기만 하면 자동적으로 인지할까?

 

다음은 C언어로 키보드 Event를 핸들링하는 코드이다.

int main(void) {
    char key;
    while(1) {
        key = getch(); // 무한루프를 돌면서 사용자에 의해 Key가 눌러지는것을 감지한다.
        
        switch (key) { // 감지된 값을 토대로 해야 할 일을 알맞은 곳에서 처리한다.
            case 1: 실행문; break;
            case 2: 실행문; break;
            case 3: 실행문; break;
            case 4: 실행문; break;
            default: 실행문; break;
        }
    }
    return 0;
}

이를 일반화시켜 말하면, Event Loop가 돌면서 Event를 감지한 뒤 Event Handler 또는 Event Listener에게 보내서 작업을 처리한다.

일일이 Event를 제어했던 과거와는 달리 요즘은 이를 단순히 Listner에 행위만 등록해주면 간편하게 Event를 제어할 수 있다. 이는 Event Handler만이 관심 대상이고 이에 집중할 수 있게 한다. 달리 말하면 Event Loop를 돌면서 요청을 감지하고 적합한 Handler에 위임해주는 부수적인 부분은 언어 레벨에서 처리를 해준다는 말이다.

Event-Driven 이라는 키워드가 언급되면 위의 이미지를 기억에서 꺼내면 된다. 그리고 이러한 Event 처리는 Server에도 적합하다. 왜냐하면 Http Request라는 Event가 발생하기 때문이다. 그래서 Node.js, Spring WebFlux, Vert.x 등은 Event-Driven 형태로 Architecture가 구현되어있다.


Spring Framework

Spring은 Reactive Stack과 Servlet Stack 두가지 형태를 제공한다. 또한 Reactive Stack은 non-blocking I/O를 이용해서 많은 양의 동시성 연결을 다룰 수 있다. Servlet Stack의 문제점을 파악하고 이를 어떻게 Reactive Stack으로 해결했는지 알아본다.

1) Spring MVC

유저들로부터 HTTP 요청이 들어올때 요청들은 Queue를 통하게 된다. Thread Pool이 수용할 수 있는 수(thread pool size)의 요청까지만 동시적으로 작업이 처리되고 만약 넘게 된다면 큐에서 대기하게 된다. (one request per thread model)

 

Thread Pool은 다음과 같다. Thread를 생성하는 비용이 크기 때문에 미리 생성하여 재사용함으로써 효율적으로 사용한다. 그렇다고 과도하게 많은 Thread를 생성하는 것이 아니라 서버 성능에 맞게 thread의 최대 수치를 제한시킨다. tomcat default thread size는 200이다.

 

그런데 만약 대량의 트래픽이 들어와 thread pool size를 지속적으로 초과하게 된다면 어떻게 될까?

설정해놓은 thread pool size를 넘게 되면 작업이 처리될때까지 queue에서 계속해서 기다려야 한다. 그래서 전체의 대기시간이 늘어난다. 이러한 현상을 Thread pool hell이라고 한다.

 

다음은 Linkedin의 Thread pool hell 현상에 대한 그래프이다. Thread pool이 감당할 수 있는 요청수를 넘는 순간부터는 평소보다 수배나 많은 지연시간을 보여준다.

Thread Pool이 감당할 수 있을때까진 빠른 처리속도를 보이지만, 넘는 순간부터는 지연시간이 급격하게 늘어난다. 하나의 작업이 늦게 처리되는 부분에 대해서도 고민해볼 필요가 있다. 특수한 경우를 제외하면 DB, Network 등의 I/O가 일어나는 부분에서 아마 시간을 많이 소비했을 것이다.

 

I/O 작업은 CPU가 관여하지 않는다. I/O Controller가 데이터를 읽어오고 이를 전달받을 뿐이다. I/O를 처리하는 방식 중 가장 효율이 좋은 방법은 Asynchronous Non-blocking I/O이다. Blocking 방식은 I/O Controller가 데이터를 읽는 동안 CPU가 아무일도 할 수가 없고, Synchronous Non-Blocking 방식은 polling 때문에 불필요하게 CPU를 소비한다.

 

Spring에서도 Non-Blocking I/O를 이용해서 효율적으로 작업을 처리할 수 있는 방법을 제공한다. 그 수단이 WebFlux이다.

Spring WebFlux

위는 전반적인 WebFlux의 구조이다. 사용자들에 의해 요청이 들어오면 Event Loop를 통해서 작업이 처리된다. one request per thread model과의 차이점은 다수의 요청을 적은 Thread로도 커버할 수 있다. worker thread default size는 서버의 core 갯수로 설정이 되어있다. 즉 서버의 core가 4개라면 worker thread는 4개라는 말이며 이 적은 Thread를 통해서도 traffic을 감당할 수 있다. 위에서 하나의 Thread로 3초가 걸리는 API 1000개를 호출하여도 4초밖에 안걸렸다. 또한 비슷한 Architecture를 가진 Node.js가 이를 증명하고 있다.

 

이렇듯 Non Blocking 방식을 활용하면 좀 더 효율적으로 I/O를 제어할 수 있고 성능에도 좋은 영향을 미친다. 특히나 유행하는 MSA에서는 수많은 Microservice가 거미줄처럼 서로를 네트워크를 통해서 호출하고 있다. 즉 많은 수의 Network I/O가 발생할텐데 이를 Non Blocking I/O를 통해 좀더 성능을 끌어올릴 수 있다.

 

WebFlux로 성능을 최대치로 끌어올리려면 모든 I/O 작업이 Non Blocking 기반으로 동작해야 한다. Blocking이 되는 곳이 있다면 안하느니만 못한 상황이 되어버린다.

 

blocking library를 사용해야 하는 경우? Ractor와 RxJava는 다른 스레드에서 처리를 계속할수 있도록 publishOn이라는 옵션을 제공하지만, Blocking API는 동시성 모델에 적합하지 않다.

 

Java 진영에는 아쉽게도 DB Connection을 non-blocking으로 지원하는 라이브러리가 널리 보급되어 잘 사용되지는 않고 있다. 또한 소수의 Thread에 의해서 수많은 요청을 처리하고, 순서대로 작업이 처리되는 것이 아니므로 Event에 기반하여 실타래가 엉킨 것처럼 작업이 처리되기때문에 버그 트래킹하기가 힘들다는 문제가 있다.

 

성능이 좋으니 무조건 WebFlux를 사용해야 할까?

Spring MVC나 Spring WebFlux 둘 다 성능이 동일한 구간이 있다. 서버의 성능이 좋으면 좋아질수록 해당 구간은 더 늘어날 것이다. 그렇기에 현재의 환경이 해당 구간이라면 굳이 사용할 필요가 없다. 또한 동기 방식이 코드 작성, 이해 , 디버깅하기 더 쉽다. 이말은 즉 높은 생산성을 가진다는 말과 같은 것으로 보인다.

 

왜 성능이 동일한 구간이 생기는지를 알수 있다. 저 구간은 바로 Thread Pool이 감당할 수 있을 정도의 요청이었기에 비동기적으로 잘 수행하다가 이후에는 Queue에 쌓여 점점 성능이 느려졌던 것이다. Spring WebFlux가 적은 리소스로 많은 트래픽을 감당할 수 있었던건 I/O를 Non Blocking을 이용하여 잘 사용하는 것과 Request를 Event-Driven을 통해서 효율적으로 처리하기 때문에 가능하다.