[Spring, Java] 외부 API 병렬 처리로 성능 최적화하기: CompletableFuture + @Async 적용

2025. 7. 21. 17:59·Spring

📌 들어가며..

개발을 하다보면, 외부 API를 호출하는 과정에서 응답 시간이 길어져 전체 처리 속도가 느려지는 문제를 종종 겪게 됩니다. 

특히 여러 개의 외부 API를 순차적으로 호출한다면, 각 API의 응답 시간이 누적되어 서비스의 응답 지연으로 이어질 수 있습니다. 

 

이런 상황에서 병렬 처리를 통해 전체 응답 속도를 개선할 수 있는데, Java의 CompletableFuture와 Spring의 @Async를 활용하면 효율적인 비동기 병렬 처리가 가능합니다. 

 

이번 글에서는 CompletableFuture와 @Async를 활용해 외부 API 병렬 호출을 적용하고, 이를 통해 성능을 최적화하는 방법을 다룹니다. 

 

 

문제 상황

예를 들어, 사용자 요청에 따라 외부 API을 3번 호출해야 하는 서비스가 있다고 가정해봅시다. 

이때 각 API의 응답 시간이 약 1초씩 걸린다면, 이걸 순차적으로 호출하면 총 3초 이상이 소요됩니다.

 

[ 요청 ] -> [ API 1 호출 (1초) ] -> [ API 2 호출 (1초) ] -> [ API 3 호출 (1초) ] -> [ 응답 (총 3초 이상) ]

 

이렇게 순차 호출 구조는 호출해야 하는 API 개수와 응답 속도에 따라 서비스의 응답 속도가 점점 느려질 수 있어요. 

 

특히 아래와 같은 상황이라면 병렬 호출로 전체 응답 시간을 줄이는 게 필요합니다. 

  • 호출하는 API가 많을 때 
  • 각 API의 응답 시간이 길 때 
  • 실시간성이 중요할 때 

 

📌 CompletableFuture + @Async

CompletableFuture 클래스

이 클래스는 

  • 비동기 작업의 결과를 나중에 받아올 수 있으며
  • 결과가 준비되면 후속 작업을 연결할 수 있는 기능을 제공합니다. 

CompletableFuture는 Future와 CompletionStage 인터페이스를 구현한 클래스로 두 가지 기능을 모두 활용할 수 있습니다. 

 

Future는 결과를 조회하거나 대기할 때 사용하고 작업의 완료 여부를 확인하고 취소할 수 있습니다. 그리고 CompletionStage는 비동기 작업이 끝난 후 후속 작업을 체이닝하고, 작업 간의 의존 관계를 선언적으로 연결하며, 여러 비동기 작업을 결합할 수 있습니다. 

쉽게 말해, Future로 비동기 작업의 결과를 다루고, CompletionStage로 그 결과를 기반으로 또 다른 작업을 연결하거나 조합할 수 있는 거죠.

 

추가 설명을 하자면, Java 5부터 비동기 처리를 할 수 있는 Future가 나왔지만, Future는 결과를 가져오려면 블로킹이 필요로 하기 때문에 반드시 get()을 호출해야 한다는 한계가 있습니다. 

더보기

블로킹 vs 논블로킹

  • 블로킹 (Blocking)
    • 작업이 끝날 때까지 현재 스레드를 기다림 
    • 현재 스레드가 작업을 할 동안 다른 작업을 수행할 수 없음 
  • 논블로킹 (Non-Blocking) 
    • 작업이 끝날 때까지 기다리지 않고, 다음 작업을 바로 수행
    • 준비가 완료되면 등록된 후속 작업(콜백)이 실행됨
Future<String> future = executorService.submit(() -> {
    Thread.sleep(1000);
    return "완료";
});

String result = future.get();  // 결과 나올 때까지 대기 (블로킹)

이때 future.get()은 결과가 준비될 때까지 현재 스레드가 멈춰서 기다리게 됩니다. 

뿐만 아니라 Future는 후속 작업을 연결할 수 없고, 예외 처리를 체이닝할 수도 없고, 여러 비동기 작업을 효율적으로 조합하기도 어렵습니다. 

이런 한계를 해결하기 위해 Java 8에서 CompletableFuture가 등장하게 됩니다. 

 

그래서 CompletableFuture는 

  • Future의 한계를 보완하면서 CompletionStage 인터페이스를 함께 구현한 클래스입니다. 
  • 비동기 작업의 결과를 논블로킹으로 처리하고
  • 후속 작업을 체이닝할 수 있고
  • 예외 처리도 지원하며
  • 여러 비동기 작업을 효율적으로 조합하고 결합할 수 있습니다. 

 

CompletableFuture 주요 메서드 정리 

  1. 비동기 작업 시작
    • supplyAsync : 비동기 작업을 실행하고 결과를 반환
    • runAsync : 비동기 작업을 실행하지만 반환값 없음 ex) 로그 기록 등
  2. 후속 작업 처리
    • thenApply : 결과를 받아서 새로운 값으로 변환 후 반환 
    • thenAccept : 결과를 받아서 출력하거나 저장 등
    • thenRun : 이전 결과랑 상관없이 별도 작업 실행 
  3. 비동기 작업 조합
    • thenCompose : 결과를 받아서 새로운 CompletableFuture 반환 ex) 첫 번째 API 결과로 두 번째 API 호출
    • thenCombine : 두 비동기 작업의 결과를 받아서 하나의 결과로 결합 
    • thenAcceptBoth : 두 비동기 작업의 결과를 받음 (반환 없음)
    • runAfterBoth : 두 작업이 모두 끝나면 실행 (결과는 안 받음)
    • applyToEither : 두 작업 중 먼저 끝난 작업의 결과를 반환 
    • acceptEither : 두 작업 중 먼저 끝난 작업의 결과를 출력하거나 저장 등
    • runAfterEither : 두 작업 중 먼저 끝나면 실행 (결과 무시함)
  4. 여러 작업을 동시에 처리
    • allOf : 여러 작업이 모두 끝날 때까지 대기
    • anyOf : 여러 작업 중 하나라도 끝나면 완료
  5. 예외 처리
    • exceptionally : 예외가 발생하면 대체 값 반환
    • handle : 성공/실패 상관없이 결과 또는 예외를 함께 받아 처리 ex) 성공이면 결과, 실패면 예외 로그
    • whenComplete : 성공/실패 상관없이 결과/예외를 받아 후속 작업 (반환 없음)
  6. 기타 
    • complete : 외부에서 직접 Future를 성공 상태로 완료
    • completeExceptionally : 외부에서 예외로 완료
    • join : 블로킹하며 결과 가져오기 (get()과 비슷)
    • getNow : 완료됐으면 결과 반환, 아니면 기본값 반환
    • orTimeout : 설정한 시간 내 완료 안 되면 TimeoutException 발생
    • completeOnTimeout : 설정 시간 내 완료 안 되면 대체값으로 완료

 

 

@Async

@Async는 Spring에서 제공하는 비동기 실행 기능으로, @Async가 붙은 메서드는 

  • Spring이 프록시를 만들어 실행하고
  • 지정한 Executor 스레드풀에서 실행됩니다.
  • 그리고 트랜잭션이나 AOP 등 그대로 유지한 채 비동기로 실행되고 
  • 해당 메서드의 반환 타입으로 CompletableFuture를 사용할 수 있습니다. (void, Future도 가능)
  • 참고로, 클래스 레벨에도 적용할 수 있고, 이 경우에는 해당 클래스의 모든 메서드가 비동기 실행 대상이 됩니다. 

그래서 단순한 자바의 비동기 작업을 하려면 CompletableFuture만 사용해도 되지만, Spring 프로젝트에서 트랜잭션, AOP, 보안 등을 유지하며 비동기 처리를 하고 싶다면 @Async와 CompletableFuture를 함께 사용해야 합니다. 

 

 

📌 구현

 

1. Async 기능 활성화 및 스레드풀 설정 

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(20);   // 최소 20개의 스레드 유지
        executor.setMaxPoolSize(50);    // 최대 50개의 스레드 생성
        executor.setQueueCapacity(200); // 대기 가능한 작업 수
        executor.setThreadNamePrefix("async-thread-");
        executor.setRejectedExecutionHandler(new CallerRunsPolicy()); // 작업 거부 시 호출한 스레드가 처리
        executor.initialize();
        return executor;
    }

    @Override
    public Executor getAsyncExecutor() {
        return asyncExecutor();
    }
}

 

비동기 처리가 가능하도록 @EnableAsync를 추가하고, 사용할 스레드풀(Executor)을 설정합니다. 

getAsyncExecutor를 통해서 @Async가 이 executor를 사용하게 됩니다. 

 

2. 비동기 작업 정의 

@Service
@RequiredArgsConstructor
public class AsyncService {

    private final ApiClient apiClient;

    @Async("asyncExecutor")
    public CompletableFuture<List<DataDTO>> fetchDataAsync(Category category, int year) {
        DataDTO[] response = apiClient.requestData(category, year); // 외부 API 호출하는 부분
        return CompletableFuture.completedFuture(Arrays.asList(response));
    }
}

 

메서드에 @Async를 붙여 외부 API 호출을 비동기로 처리합니다. 

@Async가 붙으면 스프링이 알아서 별도 스레드에서 실행하도록 처리하고, 원하는 executor(asyncExecutor)도 설정할 수 있습니다. 

 

3. 비동기 작업을 여러 번 호출하고 결과 모으기 

@Service
@RequiredArgsConstructor
public class ApiService {

    private final AsyncService asyncService;

    public List<Data> getDataByCategoryAndYear(List<Category> categories, int currentYear) {
        List<CompletableFuture<List<DataDTO>>> futures = new ArrayList<>();

        // 비동기로 카테고리 및 연도별 데이터 요청
        for (Category category : categories) {
            for (int year = currentYear; year >= currentYear - Constants.YEAR_RANGE; year--) {
                futures.add(asyncService.fetchDataAsync(category, year));
            }
        }

        // 모든 비동기 작업 완료 대기
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

        // 결과 모으기
        Set<DataDTO> dataDTOs = futures.stream()
                .map(CompletableFuture::join)
                .flatMap(List::stream)
                .collect(Collectors.toSet());

        return DataDTO.toEntities(dataDTOs);
    }
}

 

여기서 비동기 메서드 여러 개를 실행하고, CompletableFuture.allOf로 모두 끝날 때까지 기다립니다. 

 

 

📌 주의할 점

@Async + CompletableFuture로 병렬 처리하면, API 호출 개수만큼 비동기 스레드가 할당되기 때문에 요청이 많아지면 동시 스레드가 늘어나고, CPU나 메모리 자원을 많이 소모하게 된다는 문제점이 있습니다. 

 

이를 해결하기 위해서는 

  • 스레드풀 크기를 조절해서 비동기 작업이 생성할 수 있는 스레드 수를 제한하거나 
  • 한 번에 처리할 작업량을 조절하거나, 페이징 처리를 통해 나눠 처리할 수도 있습니다. 
  • 비동기 작업에 타임아웃을 설정해서, 느리거나 응답이 없는 API 호출 때문에 자원이 낭비되는 것을 방지할 수 있습니다. 
  • 그리고 대량의 외부 API를 호출하거나 데이터를 처리해야 할 때는 Spring Batch 같은 배치 처리 방식도 고려해볼 수 있습니다.

 

 

 

📌 결과

 

기존(왼쪽)에는 순차적으로 처리해서 2m 57.30s가 걸렸지만, 병렬 처리를 한 뒤(오른쪽) 13.82s로 대폭 단축할 수 있었습니다. 

 

 

 


참고

  • Java CompletableFuture
  • [Java] CompletableFuture에 대한 이해 및 사용법
  • Creating Asynchronous Methods

 

'Spring' 카테고리의 다른 글

Spring Boot + Apache POI로 엑셀 파일 처리하기  (3) 2025.08.27
[Spring] API 문서 자동화: springdoc-openapi로 Swagger UI 만들기  (1) 2025.07.02
'Spring' 카테고리의 다른 글
  • Spring Boot + Apache POI로 엑셀 파일 처리하기
  • [Spring] API 문서 자동화: springdoc-openapi로 Swagger UI 만들기
yeonsu00
yeonsu00
yeonsu00 님의 블로그 입니다.
  • yeonsu00
    코딩연수
    yeonsu00
  • 전체
    오늘
    어제
    • 분류 전체보기 (16)
      • Java (0)
      • Spring (3)
      • Database (0)
      • Server (0)
      • Infra (2)
      • 아무거나 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
yeonsu00
[Spring, Java] 외부 API 병렬 처리로 성능 최적화하기: CompletableFuture + @Async 적용
상단으로

티스토리툴바