본문 바로가기
Spring

Spring - 비동기 api CompeletableFuture를 활용한 성능 개선

by 오늘부터개발시작 2023. 3. 29.

문제점 

담당하고 있는 프로젝트에 오픈스택 API를 활용해서 VM 리스트를 보여주는 화면이 있다. VM 리스트를 조회하면 사진의 왼쪽 리스트처럼 볼륨 정보, 이미지 정보 등의 VM에 엮여 있는 다른 리소스들의 정보는 ID 값만 내려오기 때문에, 상세 정보 조회를 위해서는 ID 값을 사용해서 추가적으로 API를 호출해야 한다.

 

만약에 페이지 size는 20이고, 볼륨, 이미지, 플레이버 상세 정보까지 리스트에 보여주려면 약 20 * 3 = 60번의 외부 API를 호출하게 된다. API 당 응답 속도를 100ms로 가정하면 100ms * 60으로 페이지가 로딩되는데 6초가 걸리게 된다. 실제로 현재 진행하고 있는 프로젝트에서는 5초 ~ 10초 정도의 응답시간이 걸렸고 비동기 API를 적용해서 약 1 ~ 2초로 응답시간을 개선하였다.

 

해결 방법

이러한 문제를 해결할 수 있는 방법은 비동기로 처리하는 방법이다. Spring에서 대표적으로 비동기를 처리하는 방법은 Spring MVC에서 CompletableFuture를 사용하거나 Spring WebFlux로 Mono를 사용하는 방법이 있다. 여기서는 CompletableFuture를 사용하여 해결하는 방법을 알아보도록 하겠다. 

 

먼저 동기와 비동기의 차이를 간단하게 알아보겠다. 동기로 처리한다는 것은 위의 그림처럼 하나의 요청을 보내고 해당 요청이 완료될 때까지 기다렸다가 새로운 요청을 보낸다는 것을 의미한다.

 

이와 다르게 비동기로 처리를 한다는 것은 하나의 요청이 완료될 때까지 기다리는 것이 아니라, 위의 그림처럼 동시에 여러 개의 요청을 보내고 처리도 동시에 하는 것을 의미한다. 이렇게 비동기로 처리하면 위의 예제에서 한 번에 60개의 요청도 보낼 수 있게 되고 60 * 100ms에서 1 * 100ms로 개선할 수 있게 된다. 그렇다면 어떻게 비동기로 처리를 할 수 있을까?

 

CompletableFuture

Java 8부터 지원하는 비동기 처리 API인 CompletableFuture가 있다. CompletableFuture는 다음과 같이 사용할 수 있다.

// VM 리스트 조회
List<Vm> vmList = vmApi.getServers();

// CompletableFuture를 담을 리스트 
List<CompletableFuture<Void>> completableFutures = new ArrayList<>();

// VM 리스트를 루프 돌며 비동기 요청 생성
vmList.forEach(vm -> {
    completableFutures.add(CompletableFuture.runAsync(() -> {
         Volume volumeDetail = cloudAPI.getVolume()
         vm.setVolumeName(volumeDetail.getName());
    }, executor));
    
    completableFutures.add(CompletableFuture.runAsync(() -> {
          Flavor flavorDetail = cloudAPI.getFlavor()
          vm.setFlavorName(flavorDetail.getName());
    }, executor));
    
    ... // 이미지 정보 추가
})

// 비동기 요청 실행
completableFutures.forEach(CompletableFuture::join);

 

위의 코드를 보면, CompletableFuture를 활용해서 비동기 API 요청을 리스트에 담고, 마지막에 join() 메소드를 사용하여 CompletableFuture를 실제로 실행한다. 이렇게 처리하면 위의 예제 케이스의 60개의 요청을 한 번에 처리할 수 있게 된다.

 

60개의 요청을 한 번에 처리할 수 있다는 것은 60개의 스레드가 각각의 요청을 처리한다는 의미인데, 요청을 처리할 스레드는 어떻게 준비하여야 할까?

 

위의 코드를 보면 비동기 요청을 정의할 때 executor를 변수로 넘겨주는 것을 볼 수 있는데, 이 변수가 바로 비동기 요청에 사용할 ThreadPool을 정의해 놓은 것이다. executor를 넘겨주지 않으면 CPU 개수만큼의 스레드를 가지고 있는 디폴트 ThreadPool을 사용하게 된다. 일반적인 경우에 60개의 CPU를 갖고 있을 리 없기 때문에 우리의 예제를 해결하기 위해서는 Custom ThreadPool을 사용하여 적절한 개수의 스레드를 준비해야 한다. CustomThreadPool은 yaml로도 설정할 수 있고 아래처럼 코드로도 설정할 수 있다.

 

@Bean(name = "customThreadPoolExecutor")
public Executor getCustomThreadPoolExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(100);
    executor.setMaxPoolSize(100);
    executor.setQueueCapacity(2000);
    executor.setThreadNamePrefix("custom-executor");
    return executor;
}

 

RestTemplate 설정

마지막으로 RestTemplate으로 외부 API를 요청하고, RestTemplate을 Bean으로 주입받아 사용하고 있다면 maxConnPerRoute 설정값을 변경해줘야 한다. RestTemplate은 하나의 엔드포인트에 동시에 맺을 수 있는 maxConnPerRoute가 2로 디폴트 설정되어 있다. 예를 들어, A, B라는 외부 API가 있다면 각각의 엔드포인트에 맺을 수 있는 Connection이 2개씩이고, 총 4개의 Connection을 맺을 수 있다. 만약 디폴트 설정으로 60번의 호출을 한다면 아래처럼 2개씩 나눠서 요청을 하게 되는 것을 확인할 수 있다.

 

 

때문에 반드시 RestTemplate의 maxConnPerRoute 값을 필요에 맞게 설정해줘야 한다. maxConnTotal 값도 있는데 이 값은 엔드포인트에 상관없이 최대로 맺을 수 있는 Connection 개수이다. 이 값도 필요에 맞게 설정이 필요하다. 이렇게 RestTemplate의 설정을 변경하면 아래처럼 2개 이상의 요청이 동시에 처리되었음을 알 수 있다. 

 

+ 이렇게 설정을 모두 완료했지만 원하는 만큼 요청이 동시 처리가 되지 않을 수 있는데, 요청을 받는 외부 API에서 해당 트래픽을 소화해 낼 수 있는지도 확인이 필요하다.

 

 

이렇게 CompletableFuture, RestTemplate, ThreadPool(Executor) 설정을 통해 외부 API 요청 응답속도 개선을 할 수 있다.