본문 바로가기
Spring

Speing Webflux 이해하기

by 초보개발자96 2023. 8. 22.

Spring MVC 는 매 요청 당 스레드 하나를 할당하고 요청에 대한 작업은 해당 스레드가 담당한다.

 

출처:  https://singhkaushal.medium.com/spring-webflux-eventloop-vs-thread-per-request-model-a42d07ee8502

 

요청에 할당되는 스레드는 응답을 주기 전까지 스레드를 반납하지 않는데, 여기서 아래와 같은 문제가 발생한다.

  1. 스레드는 요청에 대한 응답이 반환될 때까지 스레드 풀에 반환되지 않는다. 즉, Blocking call 로 인해 스레드가 CPU 를 사용하지 않더라도 스레드를 점유하고 있어 다른 요청에 해당 스레드를 할당할 수 없다.
  2. 스레드 내에 Blocking call 이 많아지면 문맥 교환이 많아진다.

Spring 5 부터는 Non-blocking 으로 동작하는 서버를 작성하기 위해 Webflux 를 이용할 수 있다. Webflux 는 기본적으로 Non-blocking I/O 를 제공하는 Netty 를 사용하는데, 이를 통해 적은 스레드로 요청을 효율적으로 처리한다.

처리 과정을 단순화하면 아래와 같다.

 
출처:  https://singhkaushal.medium.com/spring-webflux-eventloop-vs-thread-per-request-model-a42d07ee8502

 

  1. 이벤트 루프는 요청(event)을 하나씩 읽으며 처리한다.
  2. 요청을 처리하다가 (Non-blocking) I/O 작업을 만나면 처리를 위임하고 즉시 제어가 반환된다.
  3. 반환된 이벤트 루프는 다른 요청을 처리한다.
  4. 도중에 I/O 작업에 대한 결과가 반환되면 이벤트 루프가 실행하고 클라이언트에게 결과를 반환한다.

 

즉, 기존 MVC 와 달리 I/O 과정에서 이벤트 루프가 Blocking 되지 않고 다른 요청을 받을 수 있으므로 비교적 적은 수의 스레드로 요청을 처리할 수 있다.

Webflux 가 사용하는 reactor-netty 의 스레드는 기본적으로 코어 수만큼 생성된다. 실제로 Webflux 서버를 실행하면 reactor-http-nio 로 시작하는 스레드가 코어 수만큼 생성된 걸 확인할 수 있다.

 

Webflux Default thread 확인

 

물론 reactor.netty.ioWorkerCount 옵션을 통해 원하는 스레드 수를 설정할 수 있다.

 

현재 CPU 의 코어 개수는 터미널에서 sysctl -n hw.ncpu 명령으로 확인할 수 있다. (https://www.baeldung.com/spring-webflux-concurrency#1-reactor-netty)  

 

적은 수의 스레드를 쓰는 만큼 스레드가 Blocking 되지 않는 것이 중요하다. Webflux 의 스레드가 Blocking call 을 만나면 어떻게 되는지 JPA 를 이용하여 간단히 테스트를 진행해 보자.

JPA 는 JDBC 구현체를 이용하여 데이터베이스에 접근하므로 Blocking 방식으로 동작한다.

 

참고:    https://gmlwjd9405.github.io/2018/12/25/difference-jdbc-jpa-mybatis.html

 

JDBC 에 대한 내용은 여기에 잘 정리되어 있다.

 

아래는 요청을 처리하는데 1초 정도 걸리는 API 에 요청 100개를 보낸 결과이다.

 

코드

@GetMapping("/block")
fun blockingCall() {
    logger.info("do worker thread.")
    fileRepository.sleepOneSecond()
}

 

결과

1초 요청 100개 처리에 약 10초 정도 소요

 

1초에 10개의 요청만 동시에 처리되어 100개 요청을 처리하는데 약 10초 정도 소요된다. JPA 에서 Blocking 되어 다른 요청을 처리하지 못하기 때문이다. Non-Blocking 방식의 서버는 적은 스레드로 많은 요청을 처리해야 하는데, 만약 JDBC 와 같은 Blocking call 만 제공하는 라이브러리로 요청을 처리하면 스레드가 Blocking 되어 전체 처리량이 낮아진다.

이를 개선하려면 Blocking call 을 비동기 처리하여 이벤트 루프를 Blocking 하지 않도록 해야 한다. 비동기 처리를 하려면 별도 스레드 풀을 이용해야 한다. 여러 방법이 있겠지만 여기서는 Reactor 와 Coroutine 을 이용했다.

 

Reactor

Reactive streams 는 Non-blocking 을 이용한 비동기 데이터 처리의 표준이다. Webflux 에 사용되는 Reactor 는 Reactive streams 를 구현한 구현체 중 하나이다.

아래는 Reactor 를 이용하여 비동기 처리한 코드이고, 요청 101개를 보낸 결과이다.

 

코드

/**
 * boundedElastic 대신 사용할 스케줄러입니다.
 * @return
 */
@Bean
fun jdbcScheduler(): Scheduler {
    return Schedulers.fromExecutor(Executors.newFixedThreadPool(300, object : CustomizableThreadFactory() {
        override fun getThreadNamePrefix(): String {
            return "Custom-"
        }
    }))
}

@GetMapping("/reactor")
fun reactor(): Mono<FileResponse> {
    logger.info("do worker thread.")
    return Mono.fromCallable {
        logger.info("do other thread.")
        FileResponse.from(fileRepository.findOneAfterASecond())
    }
        .subscribeOn(jdbcScheduler)
}

 

 

boundedElastic 은 고정된 스레드 풀을 유지한다. 즉, 스레드 풀을 따로 생성하여 처리한다.

 

결과

비동기 처리를 위한 스레드

 

1초 요청 101개 처리에 약 1초 정도 소요

 

이번에는 이벤트 루프를 Blocking 하지 않았고 워커 스레드가 300개 이므로 101개의 요청이 거의 동시에 처리했다.

 

Coroutine

코루틴은 일종의 경량 스레드로, 하나의 스레드 내에서 작업(루틴)을 나누어 처리하면서 동시성을 제공한다. 코루틴의 withContext 는 CoroutineContext 를 만들어서 새로운 코루틴을 실행해 주는데, 이를 이용하면 비동기로 처리할 수 있다.

아래는 동시 요청 64개를 보낸 결과이다.

 

코드

/**
 *
 * @return
 */
suspend fun coroutine() {
  logger.info("do worker thread.")
  withContext(Dispatchers.IO) {
      logger.info("do other thread.")
	  fileRepository.findOneAfterASecond()
  }
}

 

결과

IO Dispatcher 와 Default Dispatcher 가 스레드를 공유. 참고:  https://sandn.tistory.com/110

 

1초 요청 64개 처리에 약 1초 정도 소요

 

Dispatchers.IO 와 Dispatchers.Default 는 스레드 풀에 고정된 스레드 수를 유지한다. 실행 시, 총 74개의 스레드를 볼 수 있고, 그중 64개가 Dispatchers.IO 작업을 수행한다. 만약 65개의 요청을 보내게 되면 비동기 처리할 스레드가 부족하여 Blocking 되고, 총 처리시간은 2초 정도 소요된다.

 


 

이렇게 Webflux 내에 Blocking call 이 있어도 비동기 처리하면 Blocking 없이 요청을 처리할 수 있다. 다만, 비동기 처리를 위해 별도의 스레드를 이용해야 하고, 해당 스레드는 Blocking call 이 종료될 때까지 Blocking 된다. 이벤트 루프를 Blocking 하지 않아 Webflux 에서 할당한 스레드만큼만 사용하여 모든 요청을 처리하는 것처럼 보이지만, 실제로는 Thread Per Request 모델과 마찬가지로 비동기처리를 위한 스레드 풀의 크기를 넘는 동시요청이 들어오면 처리량이 낮아진다. 

 

JDK 21 에서 지원하는 가상 스레드의 경우 Blocking call 을 만나더라도 실제 스레드는 Blocking 되지 않는다. 즉, 실제 스레드는 Blocking 되지 않은 다른 가상 스레드를 곧바로 실행할 수 있다.

 

이를 개선하려면 I/O 작업 자체가 Non-blocking 으로 동작해야한다.

예를 들어, Spring 4.0 에서 나온 AsyncRestTemplate 은 기본적으로 앞서 테스트한 것처럼 스레드 하나를 할당하여 비동기로 처리한다. 근데, AsyncRestTemplate 의 AsyncClientHttpRequestFactory 설정을 Non-blocking I/O 를 지원하는 Netty 기반으로 변경하면 마법처럼 모든 요청을 별도의 스레드 할당 없이 처리할 수 있다.

다만 AsyncRestTemplate 는 deprecated 될 예정이니 Non-blocking IO 를 사용하고 싶으면 대안으로 나온 WebClient 를 쓰면 된다.(이 역시 Netty 를 이용한다). 데이터베이스 접근 기술도 JDBC 대안으로 R2DBC 라는 스펙이 있다.

 

R2DBC

R2DBC 는 데이터베이스를 접근하기 위한 Reactive 스펙이다. DB 의 Non-blocking call 을 가능하게 하기 위해 각 벤더들이 Non-blocking 을 지원하도록 구현하여 드라이버를 만든다. 구현된 드라이버 목록은 https://r2dbc.io/drivers/ 에서 확인할 수 있다.

 

각 드라이버가 Non-blocking I/O 를 지원하는 방식은 다르겠지만, jasync-sql, r2dbc-mysql 는 Netty 기반이었다.

 

여기서는 r2dbc-mysql 을 이용하여 테스트를 해보았다. (다만, 이 라이브러리는 더 이상 유지보수 안 한다고 한다)

 

결과

Webflux 에서 reactor-http thread 10 개와 r2dbc-mysql 에서 생성된 reactor-tcp thread 10개가 생성됨

 

1초 요청 150개 처리에 약 1.2초 정도 소요

 

Blocking IO 를 사용헀을때와는 달리 유지하고 있는 스레드 수를 뛰어넘는 요청도 문제없이 처리한 것을 볼 수 있다. 즉, Non-blocking IO 를 지원하는 DB, HTTP Client 라이브러리를 사용하여 이벤트 루프를 Blocking 만 하지 않는다면 Webflux 는 좋은 선택지가 될 수 있다.

다만, 이러한 I/O 작업 이외에 굉장히 무거운 연산을 코드 레벨에서 하면 이벤트 루프가 Blocking 될 여지가 있으므로 주의해야 한다. 또 한 가지 Webflux 도입이 꺼려지는 점은 Webflux 가 Reactive streams 구현체인 Reactor 를 이용하는데, 이에 대한 학습 곡선이 만만치 않다.

만약 코틀린을 이용한다면 Webflux 와 함께 코루틴을 사용하는 게 좋아 보인다. 코루틴을 이용하면 kotlinx.coroutines 에서 제공하는 Publisher 에 대한 확장함수를 통해 Mono, Flux 와 같은 타입을 쉽게 다룰 수 있다.