Spring WebClient + MVC (2) RestTemplate vs WebClient

#spring mvc#webclient

2023-12-13 00:11

대표 이미지

Spring MVC에서 WebClient를 적용하기 위한 사전 지식들을 정리하는 시리즈물로써, 핵심 개념 및 중요 요소들을 설명합니다. 이번 글에서는 기존 사용하던 RestTemplate과 주요 특성 및 동작 방식에 대해서 비교해보고, WebClient를 적용하기 위한 주요 활용 방안에 대해서 알아봅시다.

개요


두 번째 포스트에서는 WebClient를 적용하기 위해 기존 사용하던 RestTemplate과 주요 특성 및 동작 방식에 대해서 비교해보고, 실전에서 WebClient를 적용하기 위한 주요 활용 방안(에러 핸들링과 응답 객체 처리)에 대해서 알아본다.

기술적 배경


HTTP Client

  • HTTP 클라이언트는 웹 서버와의 통신을 담당하는 컴포넌트로, RESTful API 호출, 데이터 전송, 파일 업로드 및 다운로드 등 다양한 작업을 수행한다.
  • 이러한 클라이언트의 성능과 효율성은 애플리케이션 전체의 성능에 큰 영향을 미칩니다.

Spring MVC 아키텍처

  • Spring MVC는 요청 처리를 위해 하나의 쓰레드를 사용하는 블로킹 모델을 기반으로 하여, I/O 작업에서 발생할 수 있는 병목 현상을 고려해야 한다.
  • RestTemplate과 WebClient가 이런 환경에서 어떻게 동작하는지, 어떤 것이 더 효율적인지 이해한다.

RestTemplate

  • Spring 3.0부터 제공
  • Java Servlet API (1 요청 = 1thread)를 사용하여 블로킹 I/O 모델을 기본으로 한다.
  • Spring MVC의 블로킹 특성과 잘 맞지만, 높은 동시성과 빠른 응답 시간이 필요한 경우에 한계가 있을 수 있다.

WebClient

  • Spring 5.0부터 제공되어, Reactor 프로젝트를 기반으로 한다.
  • 비동기 및 논블로킹 I/O를 지원하며, 높은 동시성과 빠른 응답 시간을 제공한다.

동시성 제어와 병목 해소

  • 블로킹 모델에서 동시성을 높이기 위해서 쓰레드 수를 늘리는 방법이 일반적이지만, 이는 메모리 사용량과 컨텍스트 스위칭으로 인한 성능 저하를 야기할 수 있다.
  • 반면 논블로킹 모델은 이벤트 루프 방식을 통해 높은 동시성을 제공하여, 더 효율적인 리소스 사용이 가능하다.

주요 특성 비교


특성 RestTemplate WebClient
기본 동작 방식 sync + blocking async + non-blocking
메서드 호출 스타일 명령형 함수형
Connection Thread 관리 방식 동기 방식의 Thread Pool Netty와 같은 비동기 I/O 라이브러리를 사용하여 non-blocking Connection Pool 사용, EventLoop 사용
응답 클래스 ResponseEntity Mono/Flux, 비동기 및 리액티브 작업을 위한 리액터 타입 반환, 최종 결과값을 얻기 위한 연산자가 필요, subscribe() : 이터|에러|완료 시점에 수행될 콜백을 등록,block() : blocking 방식으로 결과값 대기
에러 핸들링 HttpClientErrorException 및 HttpServerErrorException 발생, ResponseErrorHandler 구현 함수형 메소드 onStatus() 사용, ExchangeFilterFunction 을 통해 고급 에러 처리 전략 적용 가능
필터와 인터셉터 ClientHttpRequestInterceptor 인터페이스 구현 ExchangeFilterFunction 인터페이스 구현

MVC 환경에서의 동작 프로세스 비교


1) RestTemplate

resttemplategif.gif

  1. HTTP 요청 수신
    • 클라이언트로부터 HTTP Request 요청이 들어오면 톰캣이 수신한다.
    • 톰캣은 각 클라이언트 연결에 대해 별도 스레드(메인 스레드) 할당 → 해당 스레드는 톰캣의 커넥터 풀에서 관리
  2. 요청 전달
    • 수신된 요청은 DispatcherServlet로 전달한다.
    • DispatcherServlet은 중앙 집중식 엔트리 포인트 → 적절한 컨트롤러로 라우팅
  3. 컨트롤러 수행
    • RepoController 에서 Service , Repository , DataProvider 등의 레이어 호출
  4. RestTemplate 호출
    • 외부 서비스 호출
    • 내부적으로 HTTP Connection Pool에 존재하는 Connection을 사용하여 리소스를 관리한다
    • 메인 스레드는 해당 connection으로부터의 응답을 기다린다.
  5. HTTP 커넥션 풀
    • 지정된 최대 수의 커넥션을 유지한다
    • 커넥션이 필요할 때 풀에서 사용 가능한 커넥션을 찾는다.
    • 커넥션 풀은 동일한 목적지에 대한 연속된 요청을 빠르게 처리하기 위해 여러 커넥션을 재사용한다.

2) WebClient

webclientgif.gif

  1. HTTP 요청 수신
  2. 요청 전달
  3. 컨트롤러 수행
  4. WebClient 호출
    • HTTP Connection을 바로 쓰지 않고, 외부 호출에 대한 connection을 관리하는 EventLoop Group 에서 EventLoop를 할당받아 사용한다.
    • WebClient의 기본 매커니즘은 non-blocking이나, Spring MVC에서 blocking으로 처리한다.
    • WebClient의 리액티브 프로그래밍 모델을 활용해서 MonoFlux 를 반환하는 endpoint를 만들어, 해당 라이브 스트림을 webClient를 사용하는 로직으로까지 전파하여 비동기적으로 사용하게 구현해야 한다.
  5. 비동기 작업 수행
    • EventLoop가 HTTP Conenction을 초기화 및 비동기로 네트워크 호출 수행
    • 네트워크 I/O가 완료되면, callback이 트리거되어 결과가 반환된다.
    • 해당 EventLoop는 다른 작업을 진행할 수 있는 상태로 EventLoop Group 으로 반환된다.
  6. 응답 반환
    • 비동기 작업의 결과가 도착하면, 이를 받아서 원래의 메인 스레드로 전달된다.

Blocking 모델 기반의 MVC 환경에서 WebClient를 활용하여 100% 비동기 non-blocking 로직을 구현하는 것은 어렵다. 온전한 리액티브 프로그래밍 모델을 적용하기 위해선 이를 지원하는 WebFlux 프레임워크를 사용해야 한다.

webfluxgif.gif

WebClient 주요 활용 방안


1) 에러 핸들링

  • Reactive Stream 클래스의 에러 핸들링을 사용해서 미리 구현되어 있는 오류 메서드를 사용할 수 있다.
  • API별로 예외를 별도 처리해야 하는 경우 유용하게 사용할 수 있다.
onErrorMap
  • 특정 조건을 만족하는 경우에만 오류를 처리
.onErrorMap(WebClientResponseException.class, ex -> {
    if (ex.getStatusCode() == HttpStatus.NOT_FOUND) {
        return new ResourceNotFoundException("Resource not found");
    }
    return ex;
})
onStatus
  • 특정 응답 상태인 경우, 오류 처리
.doOnError(ex -> {
    log.error("Error occurred: " + ex.getMessage(), ex);
})
doOnError
  • 오류가 발생했을 때 특정 작업을 수행
.onErrorResume(ex -> Mono.just("Fallback Value"))
onErrorResume
  • 오류가 발생했을 때 대체 Mono나 값을 반환
.onErrorResume(ex -> Mono.just("Fallback Value"))
onErrorContinue
  • 오류가 발생해도 특정 조건을 만족하면 계속 진행
.onErrorContinue((ex, obj) -> ex instanceof WebClientResponseException &&
   ((WebClientResponseException) ex).getStatusCode() == HttpStatus.NOT_FOUND, // 오류 조건 설정
       (ex, obj) -> {
            // 오류 처리 및 계속 진행할 작업 수행
            System.out.println("오류는 발생했지만 하단 작업을 이어서 수행: " + ex.getMessage());
    })

2) 응답 객체 핸들링

응답 타입

WebClient는 리액티브 프로그래밍 패러다임을 따라서 일반적인 ResponseType은 Mono<T>와 Flux<T>로 구분된다.

  • Mono<T> : 하나의 요소를 가지거나 요소가 없을 수 있는 리액티브 스트림. 하나의 객체를 반환하는 API 응답을 대표한다.
  • Flux<T> : 여러 요소를 가질 수 있는 리액티브 스트림. 리스트나 배열을 반환하는 API 응답을 대표한다.

리액티브 스트림

  • 데이터의 비동기 처리를 위한 표준화된 API 스펙
  • 고성능, 저 지연, 백프레셔(backpressure) 핸들링을 목표로 한다 → 백프레셔 : 데이터 소비자가 처리할 수 있는 것보다 빠르게 데이터를 전달받았을 때 발생하는 문제를 표현하는 용어

단, Servlet API 기반의 Spring MVC에서 최종 응답 형태가 Mono/Flux이라고 할지라도 내부적으로 데이터는 비동기적으로 처리하지만, 클라이언트에게 전달되는 시점에는 blocking 된다. image.png

응답 핸들링 예제
  • 단건 DTO → Entity 변환
@GetMapping("/user-entity")
    public Mono<UserEntity> find() {
       Mono<UserDTO> userDTOMono = webClient.get()
               .uri("http://localhost:8100/user-dto-one")
               .retrieve()
               .bodyToMono(UserDTO.class);
 
       return userDTOMono.map(dto -> new UserEntity(dto.getName()));
    }
  • 리스트 DTO → Entity 변환
    • Mono<List> 사용한 방법
    • Flux<T> 사용한 방법
    • 두 방법은 결과가 같다
@GetMapping("/user-entities-with-mono")
public Mono<List<UserEntity>> retrieveMonoList() {
   Mono<List<UserDTO>> userDTOMonoList = webClient.get()
           .uri("http://localhost:8100/user-dto-list")
           .retrieve()
           .bodyToMono(new ParameterizedTypeReference<>() {});


   return userDTOMonoList.map(dtoList -> {
       List<UserEntity> entityList = new ArrayList<>();
       for (UserDTO dto : dtoList) {
           entityList.add(new UserEntity(dto.getName()));
       }
       return entityList;
   });
}

@GetMapping("/user-entities-with-flux")
public Flux<UserEntity> retrieveFlux() {
   Flux<UserDTO> userDTOMonoList = webClient.get()
           .uri("http://localhost:8100/user-dto-list")
           .retrieve()
           .bodyToFlux(UserDTO.class);

   return userDTOMonoList.map(dto -> new UserEntity(dto.getName()));
}
  • 단건 DTO → Entity 변환 후, 데이터 변경
@GetMapping("/user-entity-original-type-set-property")
public UserEntity findOriginalTypeWithSettingProperty() {
    Mono<UserDTO> userDTOMono = webClient.get()
            .uri("http://localhost:8100/user-dto-one")
            .retrieve()
            .bodyToMono(UserDTO.class);
 
    Mono<UserEntity> userEntityMono = userDTOMono.map(dto -> new UserEntity(dto.getName()));
 
    UserEntity userEntity = userEntityMono.toFuture().join(); // 원본 타입이 필요하면 CompletableFuture를 활용
    userEntity.setName("data change"); // data change로 이름 변경
    return userEntity;
}
  • 응답 합치기
@GetMapping("/merge-user-entity")
public Mono<List<UserEntity>> findMultiple() {
    Mono<UserDTO> userDTOMono1 = webClient.get()
            .uri("http://localhost:8100/user-dto-one")
            .retrieve()
            .bodyToMono(UserDTO.class);
 
    Mono<UserDTO> userDTOMono2 = webClient.get()
            .uri("http://localhost:8100/user-dto-one")
            .retrieve()
            .bodyToMono(UserDTO.class);
 
    return userDTOMono1.zipWith(userDTOMono2).map(tuple -> {
        UserEntity userEntity1 = new UserEntity(tuple.getT1().getName());
        UserEntity userEntity2 = new UserEntity(tuple.getT2().getName());
        return List.of(userEntity1, userEntity2);
    });
}
  • 응답을 구독하지 않는 경우
@GetMapping("/no-subscribe")
public String noSubScribe() {
    webClient.get()
            .uri("http://localhost:8100/user-dto-one")
            .retrieve()
            .bodyToMono(UserDTO.class)
            .log();
 
    // 응답을 아무것도 구독하지 않음 -> webClient 요청이 발송되지 않음
 
    return "success";
}