ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 스프링으로 시작하는 리액티브 프로그래밍 3 : WebFlux와 R2DBC
    책책책 책을 읽읍시다/프로그래밍 2024. 8. 9. 22:04

    Chapter 15. Spring WebFlux 개요


     Spring WebFlux는 리액티브 웹 애플리케이션 구현을 위해 Spring 5.0부터 지원하는 리액티브 웹 프레임워크다.

    Spring MVC의 기술 스택과 Spring WebFlux의 기술 스택을 비교해 보면서 Spring WebFlux의 기술과 Spring MVC 기술에 어떤 차이점이 있는지 간단하게 살펴보자.

    Spring MVC와 Spring WebFlux의 기술 스택 비교

    • 서버 : Spring MVC 프레임워크는 서블릿 기반의 프레임워크이기 때문에 아팣치 톰캣 같은 서블릿 컨테이너에서 Blocking I/O 방식으로 동작한다. 반면에 Spring WebFlux 프레임워크는 Non-Blocking I/O 방식으로 동작하는 Netty 등의 서버 엔진에서 동작한다.
    • 서버 API : Spring MVC 프레임워크는 서블릿 기반의 프레임워크이기 때문에 서블릿 API를 사용한다. 반면에 Spring WebFlux는 기본 서버 엔진이 Netty이지만 Jetty나 Undertow 같은 서버 엔진에서 지원하는 리액티브 스트림즈 어댑터를 통해 리액티브 스트림즈를 지원한다.
    • 보안 : Spring MVC 프레임워크는 표준 서블릿 필터를 사용하는 Spring Security가 서블릿 컨테이너와 통합되는 반면에 Spring WebFlux는 WebFilter를 이용해 Spring Security를 Spring WebFlux에서 사용한다.
    • 데이터 액세스 : Spring MVC 프레임워크는 Blocking I/O 방식인 Spring Data JDBC, Spring Data JPA, Spring Data MongoDB 같은 데이터 액세스 기술을 사용한다. Spring WebFlux는 데이터 액세스 계층까지 완벽하게 Non-Blocking I/O를 지원할 수 있도록 Spring Data R2DBC 및 Non-Blocking I/O를 지원하는 NosQL 모듈을 사용한다.

    Spring WebFlux의 Non-Blocking 프로세스 구조

     Spring MVC와 Spring WebFlux는 동시성 모델과 스레드에 대한 기본 전략에서 많은 차이점은 보인다.

     Blocking I/O 방식의 Spring MVC는 요청을 처리하는 스레드가 차단될 수 있기 떄문에 기본적으로 대용량의 스레드 풀(thread pool)을 사용해서 하나의 요청을 하나의 스레드가 처리한다(thread per request model). 반면에 Non-Blocking I/O 방식의 Spring WebFlux는 스레드가 차단되지 않기 때문에 적은 수의 고정된 스레드 풀을 사용해서 더 많은 요청을 처리한다.

     이처럼 Spring WebFlux가 스레드 차단 없이 더 많은 요청을 처리할 수 있는 이유는 요청 처리 방식으로 이벤트 루프 방식을 사용하기 때문이다.

    Spring WebFlux의 Non-Blocking 프로세스

    Non-Blocking 프로세스는 아래와 같다.

    1. 클라이언트로부터 들어오는 요청을 요청 핸들러가 전달 받는다.
    2. 전달받은 요청을 이벤트 루프에 푸시한다.
    3. 이벤트 루프는 네트워크, 데이터베이스 연결 작업 등 비용이 드는 작업에 대한 콜백을 등록한다.
    4. 작업이 완료되면 완료 이벤트를 이벤트 루프에 푸시한다.
    5. 등록한 콜백을 호출해 처리 결과를 전달한다.

     그림에서 보다시피 이벤트 로프는 단일 스레드에서 계속 실행되며, 클라이언트의 요청이나 데이터베이스 I/O, 네트워크 I/O 등 모든 작업들이 이벤트로 처리되기 때문에, 이벤트 발생 시 해당 이벤트에 대한 콜백을 등록함과 동시에 다음 이벤트 처리로 넘어간다.

     결과적으로 Spring WebFlux가 이벤트 루프 방식을 도입함으로써 적은 수의 스레드로 많은 수의 요청을 Non-Blocking 프로세스로 처리할 수 있게 되었다.

     

    Spring WebFlux의 스레드 모델

     Spring MVC의 경우, 클라이언트의 요청이 들어올 때마다 서블릿 컨테이너의 스레드 풀에 미리 생성되어 있는 스레드가 요청을 처리하고 요청 처리를 완료하면 스레드 풀에 반납되는 스레드 모델을 사용한다(thread per request).

     Spring MVC의 경우, 클라이언트의 요청당 하나의 스레드를 사용하기 때문에 많은 수의 스레드가 필요하다.

     반면에 Spring WebFlux는 Non-Blocking I/O를 지원하는 Netty 등의 서버 엔진에서 적은 수의 고정된 크기의 스레드(일반적으로 CPU 코어 개수만큼의 스레드 생성)를 생성해서 대량의 요청을 처리한다.

    LoopResources

    위 코드는 Reactor Netty의 LoopResources 인터페이스 코드의 일부인데, CPU 코어의 개수가 4보다 더 적은 경우 최소 4개의 워커 스레드를 생성하고, 4보다 더 많다면 코어 개수만큼의 스레드를 생성함을 알 수 있다.

     

    Chapter 18. Spring Data R2DBC


     R2DBC(Reactive Relational Database Connectivity)는 관계형 데이터베이스에 리액티브 프로그래밍 API를 제공하기 위한 개방형 사양(Specification)이면서, 드라이버 벤더가 구현하고 클라이언트가 사용하기 위한 SPI(Service Provider Interface)이다. R2DBC의 등장으로 관계형 데이터베이스를 사용하더라도 클라이언트의 요청부터 데이터베이스 액세스까지 완전한 Non-Blocking 애플리케이션을 구현하는 것이 가능해졌다.

    Spring Data R2DBC

     Spring Data R2DBC는 R2DBC 기반 Repository를 좀 더 쉽게 구현하게 해 주는 Spring Data Family 프로젝트의 일부이다. Spring Data R2DBC는 Spring이 추구하는 추상화 기법이 적용되어 Spring Data JDBC나 JPA 같은 기술을 사용해 보았다면 쉽게 접근할 수 있다.

     Spring Data R2DBC는 JPA 같은 ORM 프레임워크에서 제공하는 캐싱(caching), 지연 로딩(lazy loading), 기타 ORM 프레임워크에서 가지고 있는 특징들이 제거되어 단순하고 심플한 방법으로 사용할 수 있다.

     

    R2DBC vs. JDBC

    아래와 같이 MySQL 대용량 덤프 데이터를 받았다.

    create table salaries
    (
        emp_no    int  not null,
        salary    int  not null,
        from_date date not null,
        to_date   date not null,
        primary key (emp_no, from_date),
        constraint salaries_ibfk_1
            foreign key (emp_no) references employees (emp_no)
                on delete cascade
    );

    위와 같은 스키마 구성이고, 여기서 인덱스가 없는 salary 컬럼으로 조회하는 API를 하나 만든다. 실행계획은 당연히 full scan이다.

    먼저 JDBC + MVC 방식의 코드를 보자.

    @RestController
    @RequestMapping("/salaries")
    @RequiredArgsConstructor
    public class SalaryController {
        private final SalaryService salaryService;
    
        @GetMapping("/{salary}")
        public ResponseEntity<Salary> findSalary(@PathVariable Integer salary) {
            return ResponseEntity.ok(salaryService.findSalary(salary));
        }
    }
    
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class SalaryService {
        private final SalaryRepository salaryRepository;
    
        public Salary findSalary(Integer salary) {
            Salary result = salaryRepository.findBySalary(salary);
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            return result;
        }
    }
    
    public interface SalaryRepository extends JpaRepository<Salary, Integer> {
        Salary findBySalary(Integer salary);
    }

     

    service 레이어에서 쿼리 조회 후 의도적으로 0.2초 지연을 주었다. 싱글 스레드 blocking 방식으로 동작하면 salaryRepository의 응답을 기다린 후 쓰레드가 0.2 초 동안 대기한다. 다른 작업을 한다고 가정한 시나리오다.

    응답속도가 0.5초로 나오는데 이중 0.3초는 쿼리 조회 시간이다. 단일 쿼리가 0.3초면 실무에서 어마어마한 지연이다.

    이 API에 가상유저 100명으로 1분 동안 부하를 주면 아래와 같이 평균 응답속도 3.82초에 20TPS라는 결과가 나온다.

     

    이번엔 R2DBC + WebFlux 조합의 코드를 보자.

    @RestController
    @RequestMapping("/salaries")
    @RequiredArgsConstructor
    public class SalaryController {
        private final SalaryService salaryService;
    
        @GetMapping("/{salary}")
        public Mono<ResponseEntity<Salary>> getSalary(@PathVariable Integer salary) {
            return salaryService.findSalary(salary).flatMap(s -> Mono.just(ResponseEntity.ok(s)));
        }
    }
    
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class SalaryService {
        private final SalaryRepository salaryRepository;
    
        public Mono<Salary> findSalary(Integer salary) {
            return salaryRepository.findBySalary(salary)
                    .switchIfEmpty(Mono.error(new RuntimeException("Salary not found")))
                    .flatMap(s -> performAsyncTask(s).thenReturn(s));
        }
    
        private Mono<Void> performAsyncTask(Salary salary) {
            return Mono.fromCallable(() -> {
                Thread.sleep(200);
                return null;
            }).subscribeOn(Schedulers.boundedElastic()).then();
        }
    }
    
    public interface SalaryRepository extends ReactiveCrudRepository<Salary, Integer> {
        Mono<Salary> findBySalary(Integer salary);
    }

    R2DBC 버전에서는 비동기 Non-Blockin I/O 작업이라 가정하고 Thread sleep을 0.2초 주었다. R2DBC이기 때문에 쿼리 요청 후 스레드를 반납하고 다른 작업을 바로 실행할 수 있다.

    그리고 같은 조건으로 부하를 주었을 때 결과가 평균응답속도 2.48s에 28TPS로 많이 개선된 것을 볼 수 있다.

     

    표로 비교하면 아래와 같다.

      JDBC + 동기 Blocking 작업 R2DBC + 비동기 Non-Blocking 작업
    평균 응답속도 3.82s 2.48s
    TPS 20 28

    물론 실무에서는 조건이 이렇게 간단하진 않지만 Non-Blocking I/O 작업 효율을 알기에 적당한 것 같다.

     

    마치며


    리액티브 프로그래밍이란 개념이 나온지 꽤 되었는데 이제서야 공부해보았다. 처음엔 별거 아니겠지 했는데 공부하다보니 Non-Blocking I/O 위력이 만만치 않다는 걸 알게 되었다. 그리고 이런 방식으로 실무에 있는 성능 문제를 풀 수 있는데 그동안 등한시한 것을 후회가 되고 또 나태했구나라는 생각이 든다. 기회가 될 때 Non-Blocking I/O와 R2DBC 원리에 대해 더 딥 다이브할 생각이다.

    댓글

Designed by Tistory.