Spring WebClient + MVC (3) WebClient Best Practice

#webclient#webclient

2024-01-08 09:47

대표 이미지

Spring MVC에서 WebClient를 적용하기 위한 사전 지식들을 정리하는 시리즈물로써, 핵심 개념 및 중요 요소들을 설명합니다. 이번 글에서는 Spring MVC 환경에서 WebClient를 적용하기 위한 Best Practice 구조에 대해 정리합니다.

개요


마지막 포스트에서는 지금까지 학습한 내용에 기반하여 Spring MVC 환경에서 WebClient를 적용하기 위한 아키텍처 및 프로젝트 구조와 같은 Best Practice에 대해 정리해본다.

비동기 구간 설정


Blocking 기반 Spring MVC에서 WebClient를 통해 비동기 Non-Blocking으로 데이터를 가져올 때 blocking 하는 지점을 설정한다.

DDD 기반의 아래 프로젝트 구조에서 각 계층별 역할은 다음과 같이 정리할 수 있다. image.png

  • Domain Service (UserService)
    • 응답받은 데이터를 조합하여 도메인 로직을 수행하는 구간
  • Repository (UserRepository)
    • 애그리거트(Aggregate) 하위 도메인 엔티티에 대한 CRUD 관리
  • DAO
    • JpaDAO : 자체 DB에서 관리하는 데이터에 접근하기 위한 JPA Interface
    • *HttpDAO : 외부 서비스에서 관리하는 데이터에 접근하기 위한 구현체
      • 비동기 Non-blocking 기반으로 호출한 리액티브 데이터 타입(Mono/Flux)의 응답을 Blocking 하여 완성된 응답값으로 반환한다.
  • Client (KakaoClient, NaverClient, SlackClient)
    • webClient 기반 통신 모듈
    • Mono 타입의 응답값 (e.g. Mono<ResponseEntity>)

User라는 애그리거트(Aggregate)는 서비스 자체적으로 관리하는 사용자 정보(UserEntity)와 각 SNS 서비스로부터 조회하여 가져온 사용자 정보( KakaoUserEntity, NaverUserEntity, SlackUserEntity)를 관리한다.

이 때 각 Client로부터 데이터를 개별적으로 조회해오는 것은 webClient를 통해 비동기적으로 수행할 수 있지만 httpDAO의 최종 응답값은 blocking을 통한 최종 응답값을 반환한다.

그 상위 레이어( Repository, Service, Controller)는 데이터의 동기화에 대해선 신경쓰지 않고 도메인 로직에만 집중할 수 있다.

프로젝트 구조


전체 프로젝트 구조는 multi-module 기반으로, 비즈니스 로직 재사용을 위해 메인 로직이 담긴 business-core 와 이에 대해 의존성을 갖ㄴ느 사용자로부터 요청을 처리하기 위한 business-interface, 배치 로직을 수행하기 위한 business-batch으로 구성할 수 있다.

이 때 webClient 적용을 위한 설정 및 구현체는 busienss-core에서 관리한다.

business
ㄴ business-batch
ㄴ business-interface
  ㄴ configurations // interface configurations
  ㄴ interfaces
    ㄴ domainA
      ㄴ api
        ㄴ v1
          ㄴ domainAController.java
    ...
ㄴbusiness-core
  ㄴ domains                                // Domain Service, Repository 등의 핵심 로직 관리
  ㄴ configurations
    ㄴ remote     
      ㄴ WebClientConfig.java               // WebClient 관련 설정 및 의존성 중앙 관리
  ㄴ infrastructures
    ㄴ dataprovider
      ㄴ client
        ㄴ DataProviderClient.java             // WebClient 기반의 Remote Service 통신을 위한 공용 인터페이스
        ㄴ ServiceA
          ㄴ exception
            ㄴ ServiceAException.java
            ㄴ ServiceAExceptionType.java
            ㄴ ServiceAErrorHandler.java       // Remote Service A Handler
          ㄴ req                               // Remote Service A에 대한 Request
          ㄴ res                               // Remote Service A에 대한 Response
          ㄴ ServiceAClient.java               // Remote Service A 통신을 위한 DataProviderClient 구현체

WebClientConfig.java

WebClient를 사용하여 외부 서비스와 통신할 때 필요한 설정과 의존성을 중앙에서 관리하는 클래스. 이 구성을 통해 모든 원격 서비스 클라이언트가 일관된 설정을 사용할 수 있다.

  • WebClient의 연결, 응답, 읽기 및 쓰기에 대한 타임아웃 설정
  • 커넥션 풀링과 관련된 설정
  • baseUrl 및 기본 HTTP 헤더 및 콘텐츠 타입 설정
@Configuration
@RequiredArgsConstructor
public class WebClientConfig {
     /**
     * WebClient 연결 및 타임아웃 공통 설정
     */
 
    @Value("${webclient.connectMax}")
    private int connectMax;
    @Value("${webclient.acquireTimeout}")
    private int acquireTimeout;
    @Value("${webclient.connectTimeout}")
    private int connectTimeout;
    @Value("${webclient.responseTimeout}")
    private int responseTimeout;
    @Value("${webclient.readTimeout}")
    private int readTimeout;
    @Value("${webclient.writeTimeout}")
    private int writeTimeout;
 
    /**
     * ObjectMapper 커스텀 설정.
     * JSON 처리에 필요한 설정 및 모듈을 추가한다.
     */
 
    private final ObjectMapper objectMapper = createObjectMapper();
 
    public ObjectMapper createObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        objectMapper.registerModule(new LocalDateTimeModule());
        objectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
 
        return objectMapper;
    }
 
    /**
     * WebClient에 사용될 공통 ConnectionProvider 설정.
     * HTTP 클라이언트의 커넥션 풀 및 타임아웃 설정을 중앙에서 관리한다.
     */
 
    @Bean
    public WebClientFactory businessClientFactory() {
        ConnectionProvider provider = ConnectionProvider.builder("provider")
                .maxConnections(connectMax)
                .pendingAcquireTimeout(Duration.ofMillis(acquireTimeout))
                .build();
 
        /**
         * HttpClient timeout 지정
         * @return
         */
        HttpClient httpClient = HttpClient.create(provider)
                .compress(true)
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout)
                .responseTimeout(Duration.ofMillis(responseTimeout))
                .doOnConnected(conn ->
                        conn.addHandlerLast(new ReadTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS))
                                .addHandlerLast(new WriteTimeoutHandler(writeTimeout, TimeUnit.MILLISECONDS))
                );
 
        ReactorClientHttpConnector connector = new ReactorClientHttpConnector(httpClient);
 
        return new WebClientFactory(connector);
    }
 
    /**
     * WebClientFactory 클래스.
     * WebClient 인스턴스를 생성하는데 필요한 메소드와 설정들을 포함한다.
     */
 
    public class WebClientFactory {
        private ReactorClientHttpConnector connector;
 
        public WebClientFactory(ReactorClientHttpConnector connector) {
            this.connector = connector;
        }
 
        public WebClient getClient(String baseUrl) {
            Jackson2JsonEncoder encoder = new Jackson2JsonEncoder(objectMapper);
            Jackson2JsonDecoder decoder = new Jackson2JsonDecoder(objectMapper);
 
            // HTTP 코덱 설정
            ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                    .codecs(codecConfigurer -> {
                        codecConfigurer.defaultCodecs().jackson2JsonEncoder(encoder);
                        codecConfigurer.defaultCodecs().jackson2JsonDecoder(decoder);
                    }).build();
 
            return WebClient.builder()
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .baseUrl(baseUrl)
                    .clientConnector(connector) // HTTP 클라이언트 라이브러리 셋팅
                    .filter(ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
                        System.out.println("[WebClient] Request: " + clientRequest.method() + " " + clientRequest.url());
                        return Mono.just(clientRequest);
                    }))
                    .exchangeStrategies(exchangeStrategies) // HTTP 메세지 reader/writer 커스텀
                    .build();
        }
    }
 
    /**
     * 서비스별 클라이언트 Bean 생성 메서드.
     */
 
    @Bean(name = "servicAWebClient")
    public WebClient serviceAWebClient(@Value("${remote.serviceA.url}") String baseUrl, ServiceAWebClientErrorHandler errorHandler) {
        return businessClientFactory().getClient(baseUrl).mutate()
                .filter(((request, next) -> next.exchange(request)
                        .doOnError(WebClientResponseException.class, e-> errorHandler.handle(e))
                )).build();
    }
 
    @Bean(name = "serviceBWebClient")
    public WebClient serviceBWebClient(@Value("${remote.serviceB.url}") String baseUrl, ServiceBWebClientErrorHandler errorHandler) {
        return businessClientFactory().getClient(baseUrl).mutate()
                .filter(((request, next) -> next.exchange(request)
                        .doOnError(WebClientResponseException.class, e-> errorHandler.handle(e))
                )).build();
    }
     
    ...
}

DataProviderClient.java

WebClient를 활용하여 데이터 제공자(Remote Service)와의 HTTP 통신 로직을 추상화한 인터페이스. 요청에 필요한 URL, HTTP 메서드, 헤더, 바디 등을 처리한다.

  • HTTP 요청 방식과 주소를 인자로 받아 동적으로 API 호출
  • 요청 헤더 및 바디 데이터의 파싱 및 변환
  • 응답 데이터(Mono)의 타입 변환 및 반환
@Slf4j
public abstract class DataProviderClient {
 
    private final WebClient webClient;
 
    public DataProviderClient(WebClient webClient) {
        this.webClient = webClient;
    }
 
    protected <T> Mono<T> invoke(String apiUrl, HttpMethod apiHttpMethod,
                                 DataProviderClientRequestSpec request,
                                 ParameterizedTypeReference<T> responseType) {
        try {
            LinkedMultiValueMap<String, String> queryParams = request.getQueryParams();
            Object bodyParams = parseBodyParams(request);
 
            HttpHeaders httpHeaders = request.getHttpHeaders();
            HttpMethod httpMethod = apiHttpMethod;
 
            return webClient.method(httpMethod)
                    .uri(uriBuilder -> uriBuilder
                            .path(apiUrl)
                            .queryParams(queryParams)
                            .build(request.getUriVariables()))
                    .headers(headers -> headers.addAll(httpHeaders))
                    .bodyValue(bodyParams)
                    .retrieve()
                    .bodyToMono(responseType);
 
        } catch (Exception e) {
            log.error("DataProviderException", e); // TODO : exception handler에서 명확한 위치에 대해 오류가 안 남아서 추가함
            throw new DataProviderException(500, "DATA-PROVIDER-ERROR-500", "Data Provider 요청을 처리하는 도중 문제가 발생하였습니다.");
        }
    }
 
    private Object parseBodyParams(DataProviderClientRequestSpec request) throws Exception {
        Object bodyParams = request; // json 데이터는 exchange 실행될 때 자동으로 변환됨
 
        Class<? extends DataProviderClientRequestSpec> requestClass = request.getClass();
 
        // form 형식 데이터는 DTO에서 정의한 파라미터로 변환
        if (requestClass.isAnnotationPresent(DataProviderFormContentType.class)) {
            return request.getFormParams();
        }
 
        return wrapRequestBodyParams(requestClass, bodyParams);
    }
 
    /**
     * 요청 DTO를 Wrapping 할 때 사용
     * Wrapping이 필요한 클라이언트에서 재정의해서 사용
     * 대부분의 경우는 Wrapping이 필요없으므로 BodyParams를 그대로 반환
     */
    protected Object wrapRequestBodyParams(Class<? extends DataProviderClientRequestSpec> requestClass, Object bodyParams) {
        return bodyParams;
    }
}  

ServiceAClient.java

외부 서비스 A와 통신하기 위한 클라이언트 구현체. WebClient를 사용하여 비동기 및 Non-blocking 방식으로 원격 서비스와 통신한다.

  • 외부 서비스 API 엔드포인트 호출
  • 요청 및 응답 데이터의 직렬화 및 역직렬화
  • 에러 처리를 위한 ServiceAErrorHandler와의 통합
@Slf4j
public class ServiceAClient extends DataProviderClient {
 
    public ServiceAClient(@Qualifier("serviceAWebClient") WebClient webClient) {
        super(webClient);
    }
 
    public Mono<ServiceAUserFindResponse> findServiceAUser(ServiceAUserFindRequest request) {
        String url = "/user";
 
        return invoke(url,
                HttpMethod.GET,
                request,
                new ParameterizedTypeReference<>() {});
    }
 
    public Mono<ServiceAFindResponse> findUserByEmail(ServiceAUserFindByEmailRequest request) {
        String url = "/user/email";
 
        return invoke(url,
                HttpMethod.GET,
                request,
                new ParameterizedTypeReference<>() {});
    }
}

ServiceAErrorHandler.java

외부 서비스와의 통신 중 발생하는 예외나 에러를 처리하는 핸들러

  • HTTP 응답 코드를 기반으로 한 에러 분류 및 처리
  • 서비스별로 특화된 에러 메시지나 예외 반환
  • 실패한 요청에 대한 재시도 로직 or fallback 처리

마무리


지금까지 Blocking 기반의 Spring MVC 환경에서 webClient를 적용하기 위해 기본적인 I/O 모델부터 시작하여, 일반적으로 사용하고 있던 restTemplate 과의 주요 특성별 비교, WebClient의 에러 핸들링 및 응답 객체 처리에 대해 알아보고 이를 기반으로 실전에서 활용할 수 있는 방안에 대해서 살펴보았습니다.

WebClient는 기본적으로 반응형 프로그래밍을 위한 비동기 논블로킹 I/O 작업을 지원하는 Spring WebFlux에 최적화된 통신 모듈입니다. 따라서 전통적인 서블릿 기반의 Spring MVC에서 활용하는데는 한계가 있다는 것을 느끼셨을 거라 생각합니다.

본 글에서의 Best Practice는 이런 제한된 환경 속에서 현실적으로 적용할 수 있는 하나의 방법을 제시하였습니다. 기존에 운영하고 있던 Spring MVC 환경의 프로젝트를 WebFlux로 바꾸기엔 부담스럽지만, 외부 서비스와의 통신 리소스를 줄이기 위해 webClient를 활용하고 싶을 때, 위에서 말한 구조와 방식을 참고하여 도움이 되기를 기원합니다.

감사합니다.