Spring MVC에 WebClient 사용해보기
개인적인 생각이지만 아직 국내의 일반적인 시스템은 WebFlux를 사용하여 전체 모든 구간을 Asynchronous Non-blocking I/O로 처리하기 어려울것 같다.
Reactive 프로그래밍에 대한 난이도도 높을 뿐더러 R2DBC가 나오긴 했지만 아직은 기존 기능에 비해 기술 성숙도가 부족하기 때문이다.
또한, 무엇보다 아직은 서블릿 기반의 멀티 스레드 처리가 빠르다...(아직 부족을 못느끼고 있음)
아무튼, 여러가지 이유로 아직은 Spring-boot-starter-web를 이용한 Web MVC를 사용하지만, WebClient는 학습해봐야 할것 같아 간단한 테스트를 하며 정리한 내용을 포스팅 하려 한다.
WebClient에 대한 기본적인 설명은 생략한다.
우선 간단한 테스트 환경을 구성하기 위해 토이프로젝트 환경을 이용하여 api consumer인 주문서비스와 api provider인고객서비스, 상품서비스를 기동시켰다.
주문서비스에서 고객 정보 한건과 상품 정보 한건을 호출하도록 구현했으며 식별성을 주기 위해 고객서비스는 2초의 sleep과 주문서비스는 3초의 sleep을 주었다.
1. Subsriber() 테스트
Webclient는 결과를 Mono나 Flux 형태로 최종 전달하지 않는 이상 코드 내에서 Subscribe()를 실행해야 실제 http 호출이 진행된다.
public class OrderService {
private final WebClient.Builder builder;
public void webclientTest01(Long id) {
log.debug("webclientTest01 start!");
WebClient webClient = builder.build();
Mono<UserResponseDto> user = webClient.get().uri("http://localhost:8030/toy1/users/v1/"+id)
.retrieve()
.bodyToMono(UserResponseDto.class);
Mono<ProductResponseDto> prod = webClient.get().uri("http://localhost:8010/toy1/products/v1/"+id)
.retrieve()
.bodyToMono(ProductResponseDto.class);
user.subscribe(u -> log.debug("user: {}", u.toString()));
prod.subscribe(p -> log.debug("prod: {}", p.toString()));
log.debug("webclientTest01 end!");
}
}
수행결과는 다음과 같다.
2021-x 00:57:29.138 DEBUG 1996 --- [nio-8020-exec-1] ...OrderService : webclientTest01 start!
2021-x 00:57:29.679 DEBUG 1996 --- [nio-8020-exec-1] ...OrderService : webclientTest01 end!
2021-x 00:57:31.876 DEBUG 1996 --- [ctor-http-nio-2] ...OrderService : user: UserResponseDto(id=1, emailId=yki1204@gamil.com, userNm=김철수)
2021-x 00:57:32.794 DEBUG 1996 --- [ctor-http-nio-3] ...OrderService : prod: ProductResponseDto(id=1, prodTypeCode=A01, prodName=테스트 상품01, prodPrice=12000, prodStock=100)
전체 코드 구간의 시작과 끝이 요청 스레드(nio-8020-exec-1) 약 500ms 정도가 사용되었고,
user정보 subscribe동작이 별도 스레드(ctor-http-nio-2)에서 약 2.xx초
prod정보 subscribe동작이 별도 스레드(ctor-http-nio-3)에서 약 3.xx초 사용되었다.
여기서 중요한 결과는 요청에 대한 스레드는 subscribe에 대한 동작이 처리되기도 전에 이미 종료되었다는 부분이다. (Non-blocking)
그럼 Web MVC에서 WebClient를 사용했을 경우 어떻게 결과를 최초 request에 대한 response로 전달할 수 있을까?
2. Block()
우선 간단하게 최초 request에 대한 response로 결과를 전달하기 위해 block() 기능을 사용할 수 있다.
public class OrderService {
private final WebClient.Builder builder;
public void webclientTest01(Long id) {
log.debug("webclientTest01 start!");
WebClient webClient = builder.build();
Mono<UserResponseDto> user = webClient.get().uri("http://localhost:8030/toy1/users/v1/"+id)
.retrieve()
.bodyToMono(UserResponseDto.class);
Mono<ProductResponseDto> prod = webClient.get().uri("http://localhost:8010/toy1/products/v1/"+id)
.retrieve()
.bodyToMono(ProductResponseDto.class);
log.debug("user: {}", user.block().toString());
log.debug("prod: {}", prod.block().toString());
log.debug("webclientTest01 end!");
}
}
동일한 코드에서 user, prod를 block()한 후 로그를 찍어 보았다.
2021-x 01:07:28.904 DEBUG 15480 --- [nio-8020-exec-1] ...OrderService : webclientTest01 start!
2021-x 01:07:31.637 DEBUG 15480 --- [nio-8020-exec-1] ...OrderService : user: UserResponseDto(id=1, emailId=yki1204@gamil.com, userNm=김철수)
2021-x 01:07:34.661 DEBUG 15480 --- [nio-8020-exec-1] ...OrderService : prod: ProductResponseDto(id=1, prodTypeCode=A01, prodName=테스트 상품01, prodPrice=12000, prodStock=100)
2021-x 01:07:34.661 DEBUG 15480 --- [nio-8020-exec-1] ...OrderService : webclientTest01 end!
1번 동작과 다르게 우선 모두 동일한 스레드에서 처리 되었으며,
시작 구간에서 user 정보를 얻어 오는데 2.xx초가 사용되었고
user정보 이후 prod 정보를 얻어오는데 다시 3.xx초가 사용되어 총 5.xx초 이상의 시간이 사용되었다.
즉, Synchronous Blocking I/O로 동작되어 기존의 RestTemplate 형식과 동일하게 동작되었다.
어? 그럼 이걸 왜 사용해야 하는거지?
3. Mono 혹은 Flux로 넘기기
이런 저런 테스트를 해보니 Spring MVC도 Controller의 Return Type을 Mono 혹은 Flux로 선언하여 넘길 수 있었다.
두개의 Mono정보를 Zip하여 Controller에 넘겨 보았다.
public class OrderService {
private final WebClient.Builder builder;
public Mono<Tuple2<UserResponseDto, ProductResponseDto>> webclientTest01(Long id) {
log.debug("webclientTest01 start!");
WebClient webClient = builder.build();
Mono<UserResponseDto> user = webClient.get().uri("http://localhost:8030/toy1/users/v1/"+id)
.retrieve()
.bodyToMono(UserResponseDto.class);
Mono<ProductResponseDto> prod = webClient.get().uri("http://localhost:8010/toy1/products/v1/"+id)
.retrieve()
.bodyToMono(ProductResponseDto.class);
log.debug("user: {}", user.block().toString());
log.debug("prod: {}", prod.block().toString());
log.debug("webclientTest01 end!");
return Mono.zip(user, prod);
}
}
간단하게 테스트 해보기 위해 단순 Mono.Zip을 통해 Tuple객체로 반환하는 구조로 진행하였다.
결과는 다음과 같다.
2021-x 01:15:23.006 DEBUG 18340 --- [nio-8020-exec-2] ...OrderService : webclientTest01 start!
2021-x 01:15:23.077 DEBUG 18340 --- [nio-8020-exec-2] ...OrderService : webclientTest01 end!
전체 서비스 로직 수행은 70ms 정도로 실제 http 호출이 진행되지 않았지만 Postman상에는 실제 결과가 tuple 형태로 확인되었고 응답시간도 두개의 요청(user, prod)가 적어도 병렬로 수행되어 5.xx초가 아닌 3.xx초로 측정이 되었다.
4. 결론
WebClient를 사용하여 결과를 조회하고 merge등의 작업을 하게 될 경우 block()을 사용하게 되면 결국 동기식의 블럭처리와 동일한 결과를 얻게 된다.
따라서, 가능한 Mono나 Flux 형태로 전달해야 하고 필요시에는 제공하는 기능(ex: zip)등을 사용하여 정보를 조합하여 전달 가능하다.
다만, 이렇게 처리 되었을 경우 어디에선가 결국 subscribe를 하는거 같은데...누가 어디서 하는지는 다시 찾아 봐야 할것 같다.