본문 바로가기
  • 개발하는 곰돌이
Development/Spring & Spring Boot

[WebClient] WebClient를 사용하여 외부API 호출 후 처리할 비즈니스 로직을 비동기로 처리해보기

by 개발하는 곰돌이 2023. 4. 12.

목차

    문제의 상황

    프로젝트를 진행하다 보면 여러 개의 외부 API를 호출하고 그 응답을 토대로 어떤 동작을 수행하는 경우가 있다. 일반적으로 많이 사용하던 RestTemplate의 경우에는 스프링 5.0부터 사소한 변경이나 버그 수정만 반영된다고 하길래 사실상 deprecated된게 아닌가 생각한다. 마침 스프링 5.0에 새로 추가된 WebClient가 비동기 방식도 지원한다고 하여 RestTemplate 대신 사용하고 있었다.

     

    WebClient를 사용하여 외부 API를 비동기 방식으로 호출하는 예제는 찾기 쉬웠지만 정작 외부 API의 응답을 토대로 추가적인 로직을 수행하는 예제는 사실상 없다시피 했다. 그래서 비동기 방식을 지원한다는 WebClient의 특성이 무색하게 CompletableFuture를 사용하여 아래와 같이 억지로 비동기 방식으로 사용하고 있었다.

    public void refresh() {
        CompletableFuture.supplyAsync(() -> logic1());
        CompletableFuture.supplyAsync(() -> logic2());
        CompletableFuture.supplyAsync(() -> logic3());
        CompletableFuture.supplyAsync(() -> logic4());
        CompletableFuture.supplyAsync(() -> logic5());
        CompletableFuture.supplyAsync(() -> logic6());
    }

    하지만 억지로 비동기 방식을 사용하다보니 로직들끼리는 비동기적으로 작동하였지만 각 로직 내부에서 반복적으로 외부 API를 호출하는 로직은 동기적으로 작동하다보니 완전한 비동기방식이라고 볼 수는 없었다.

     

    이 글에서는 기본적인 WebClient 사용법에 대해서는 다루지 않고 WebClient를 사용하여 반복적으로 외부 API를 호출하는 각 로직을 비동기방식으로 변경한 방법에 대해 정리하려고 한다.

    응답값을 어떻게 처리하지?

    문제는 위와 같이 사용하면 반환 타입이 Mono<T>이 되어버려 Mono 내부의 객체를 토대로 이런저런 로직을 수행할 수가 없었다. block() 메소드를 사용하면 Mono<T>으로부터 원하는 객체를 얻어 원하는 로직을 수행할 수 있지만 block()은 동기적으로 동작하기 때문에 비동기 처리를 할 수가 없다는 문제가 생긴다.

     

    사실 이 문제 때문에 상당히 골치가 아팠다. 이전에 진행했던 프로젝트에서 하나의 서비스 로직을 수행하기 위해 7개의 외부 API를 호출해야하는데 동기적으로 처리하자니 중간에 어떠한 외부 API가 장시간 결과를 반환하지 않으면 다른 외부 API는 호출조차 할 수 없고, 비동기적으로 처리하자니 마땅히 떠오르는 방법이 없었다.

    subscribe()를 사용해보자!

    사실 처음 WebClient 관련 정보를 찾아볼 때 subscribe()를 사용하면 비동기적으로 동작한다는 내용을 본 적은 있지만 정작 API의 응답으로 받은 body를 핸들링하여 별도의 로직을 수행하는 예시 코드를 찾아보기 힘들었다.

     

    그렇게 관련 정보를 찾아다니다가 결국 포기하고 CompletableFuture를 사용하여 로직들끼리만 비동기적으로 작동하도록 임시방편을 적용하고 운영하다가 팀 동료가 발견한 방법이 있어서 사용해보기로 했다.

    CompletableFuture를 사용한 코드

    public JsonObject getApiResponse(String url, Map<String, String params) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.setAll(params);
        return Webclient.create()
            .get()
            .uri(url, (uriBuilder -> uriBuilder.queryParams(formData).build()))
            .header(HttpHeaders.ACCEPT, "*/*")
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + authkey)
            .retrieve()
            .bodyToMono(String.class)
            .map(JsonParser::parseString)
            .map(JsonElement::getAsJsonObject)
            .block();
    }
    
    public void refresh() {
        // 각 로직마다 getApiResponse를 1번 이상 호출함
        CompletableFuture.supplyAsync(() -> logic1());
        CompletableFuture.supplyAsync(() -> logic2());
        CompletableFuture.supplyAsync(() -> logic3());
        CompletableFuture.supplyAsync(() -> logic4());
        CompletableFuture.supplyAsync(() -> logic5());
        CompletableFuture.supplyAsync(() -> logic6());
    }
    
    private void logic1() {
        refreshData(getApiResponse("url1", Map.of("key", "value").getAsJsonArray("list"));
    }
    
    private void logic2() {
        memberRepository.findAll()
            .forEach(member -> refreshData(
                getApiResponse("url2", Map.of("member", member.getId()).getAsJsonArray("list"))
            ));
    }
    
    ...

    여기서 문제는 logic1~logic6은 비동기적으로 동작하지만 logic2와 같은 부분에서 member마다 동기적으로 API를 호출하다보니 정말로 비동기 방식으로 구현하고 싶었던 외부 API 호출은 동기 방식으로 구현된다는 것이었다.

    실제로 로그를 찍어보면 로직들끼리는 별개의 스레드에서 비동기로 동작하지만 각 로직 내부는 동일한 스레드에서 순차적으로 서브로직이 시작되고 종료되는 상태였다.

    subscribe()를 사용한 코드

    Mono<T>.subscribe()의 오버로딩된 메소드들 중에 람다를 파라미터로 받는 메소드가 있다는 것을 알게 되었는데, subscribe()의 파라미터로 전해지는 람다의 파라미터가 Mono<T>에 담긴 T 객체라는 사실을 알게 되었다. 이를 토대로 CompletableFuture를 사용했던 이전 코드를 아래와 같이 리팩토링할 수 있었다.

    public Mono<JsonObject> getApiResponse(String url, Map<String, String params) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.setAll(params);
        return Webclient.create()
            .get()
            .uri(url, (uriBuilder -> uriBuilder.queryParams(formData).build()))
            .header(HttpHeaders.ACCEPT, "*/*")
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + authkey)
            .retrieve()
            .bodyToMono(String.class)
            .map(JsonParser::parseString)
            .map(JsonElement::getAsJsonObject);
    }
    
    public void refresh() {
        // 각 로직마다 getApiResponse를 1번 이상 호출함
        logic1();
        logic2();
        logic3();
        logic4();
        logic5();
        logic6();
    }
    
    private void logic1() {
        getApiResponse("url1", Map.of("key", "value"))
            .subscribe(response -> refreshData(response.getAsJsonArray("list")));
    }
    
    private void logic2() {
        memberRepository.findAll()
            .forEach(member -> 
                getApiResponse("url2", Map.of("member", member.getId()))
                	.subscribe(response -> refreshData(response.getAsJsonArray("list")))
            );
    }
    
    ...

    이렇게 리팩토링한 코드에서 각 로직 및 서브로직마다 로그를 남기게 하여 실행해보면 아래와 같은 결과가 나온다.

    WebClient 자체가 비동기 방식으로 동작하니 CompletableFuture를 사용하지 않아도 각 로직이 비동기적으로 호출되는 것을 확인할 수 있다. 또한, 동일한 스레드에서 순차적으로 서브로직이 실행되던 이전 코드와 달리 각기 다른 스레드에서 순서 상관없이 마구잡이로(?) 서브로직이 실행되는 것도 확인할 수 있었다.

    속도 차이

    subscribe()를 사용한 방법은 마지막 서브로직이 종료된 시간으로 측정하였다.

     

    25.8초가 걸리던 시간이 13.4초로 약 48% 감소하였다. 생각했던 것에 비하면 속도 상승폭이 적었는데, 로그를 찍어봤을 땐 API 요청들은 모두 거의 동시에 발생했지만 응답을 받는 시간은 꽤 시간차가 있는 것을 볼 수 있었다. 이 부분은 조금 더 확인이 필요할 것 같다.

    결론

    생각보다 속도 상승폭이 적었지만 그래도 소요 시간이 절반가량 감소한 것은 나름 긍정적인 결과라고 생각한다. 그외에도 일련의 과정을 거치면서 기존 코드에서 또다른 비효율적인 로직이 있지는 않은지 생각해봐야겠다는 생각이 들었다.

     

    예시의 코드에서는 subscribe() 내부에서 refreshData()를 호출했을 뿐이지만 실제 서비스에서는 subscribe() 내부에서 API의 응답으로 받은 JSON 객체를 추가적으로 가공하고 refreshData()를 호출하는 로직도 있었다. 이 경우에도 정상적으로 비동기적으로 동작이 수행되는 것을 확인할 수 있었다.

     

    그 외에도 각 API 호출에서 받은 응답 데이터 JSON 하나마다 DB 쿼리를 호출하고 있는 상황인데, 이 부분도 WebClient의 비동기 방식을 그대로 유지하면서 API 호출에서 받은 모든 JSON을 합쳐서 하나의 쿼리로 호출하는 방법이 있는지도 찾아보면 좋을 것 같다.

    참조 링크

     

    비동기 테스트 만들기

    비지니스 로직을 하다 보면 외부 API 호출을 할 때가 많은데, 속도를 위해 비동기 처리를 하곤 합니다. API로 구현된 비동기 처리는 예제가 많았지만, 서비스로 구현된 비동기 처리는 예제를 찾기

    hyune-c.tistory.com

     

    댓글