[Async-2][Optimization] CompletableFuture와 효율적인 스레드 풀 관리

2025. 12. 27. 18:35·Java/Core

1. 비동기 프로그래밍의 핵심 도구: CompletableFuture

 앞서 비동기 처리의 기초 개념을 살펴보며 CompletableFuture를 활용해 작업을 비동기로 실행하고, 완료 후 후속 작업을 등록하며, 필요한 시점에 결과를 기다리는 과정을 경험하였다. 우리가 사용했던 주요 메서드들을 다시 정리하면 다음과 같다.

  • runAsync(): 반환값 없이 비동기 작업을 수행한다.
  • supplyAsync(): 반환값을 가지는 비동기 작업을 수행한다.
  • thenRun(): 비동기 작업 수행을 마치면 다음에 수행할 작업을 정의한다.
  • join(): 비동기 작업이 완료될 때까지 호출 스레드를 블로킹하며 기다린다.

 CompletableFuture는 Java 8에서 도입된 클래스로, 복잡한 비동기 작업을 선언적으로 구성할 수 있도록 돕는 강력한 도구이다. 단순히 비동기 작업을 실행하는 것에 그치지 않고, 작업 간의 연결(Chaining), 예외 처리, 결과 집계까지 폭넓게 다룰 수 있다는 점이 가장 큰 특징이다.

실전 예제: 샌드위치 만들기 (조합과 연결)

 "빵을 준비하고, 잼을 바른 뒤, 버터를 바르는" 일련의 과정을 비동기로 구현해 보자. 이 예제에서는 비동기 실행, 결과 전달, 최종 결과 대기라는 세 가지 핵심 기능을 확인할 수 있다.

@Slf4j
public class Basic {
    @Test
    public void completableFutureExample() {
        // 1) supplyAsync: 별도 스레드에서 비동기로 실행하여 "빵"을 반환함
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(1000); // 1초 소요되는 작업 시뮬레이션
            } catch (InterruptedException e) {}
            return "빵";
        });

        // 2) thenApply: 이전 단계의 결과를 받아 추가 작업을 수행함 (Chaining)
        // 흐름: "빵" -> "빵 + 잼" -> "빵 + 잼 + 버터"
        CompletableFuture<String> chainedFuture = future
                .thenApply(bread -> bread + " + 잼")
                .thenApply(breadWithJam -> breadWithJam + " + 버터");

        // 3) join: 최종 결과를 얻을 때까지 메인 스레드는 대기함 (Blocking)
        String result = chainedFuture.join();
        System.out.println("결과: " + result); // 출력: 결과: 빵 + 잼 + 버터
    }
}

2. 스레드 풀(Thread Pool)의 개념과 필요성

2.1. 웨이터 운영 방식의 비유

 음식점에 손님이 올 때마다 매번 새로운 웨이터를 즉석에서 고용한다고 가정해 보자. 고용 계약을 맺고 교육을 시키는 과정에서 막대한 시간과 비용이 소요될 것이며, 손님이 나가면 바로 해고해야 하므로 관리가 매우 어려워진다. 이러한 비효율을 해결하기 위해 대부분의 음식점은 다음과 같이 운영된다.

  1. 미리 정해진 수의 웨이터를 고용하여 대기시킨다.
  2. 손님이 오면 대기 중인 웨이터가 응대한다.
  3. 모든 웨이터가 바쁘다면 대기열에 손님을 세우거나 일시적으로 인력을 보강한다.
  4. 업무를 마친 웨이터는 다시 대기 상태로 돌아가 다음 손님을 맞이한다.

이것이 바로 스레드 풀(Thread Pool)의 개념이다.

2.2. 왜 스레드 풀을 사용해야 하는가?

 비동기 처리의 핵심은 현재 스레드를 차단하지 않는 것이지만, 그 작업 역시 결국 어딘가의 스레드에서 수행되어야 한다. 그러나 스레드의 생성과 소멸은 운영체제 수준에서 매우 비용이 큰 연산이므로, 무분별한 생성을 막아야 한다.

  • 오버헤드 감소: 매번 스레드를 생성/제거하지 않고 재사용함으로써 시스템 자원을 절약한다.
  • 예측 가능한 성능: 동시에 실행되는 스레드 수를 제한하여 시스템이 감당할 수 없는 부하가 걸리는 것을 방지한다.

3. 서비스 유형별 적정 스레드 풀 설정 전략

서버의 성격과 트래픽 패턴에 맞게 스레드 풀을 설정하는 것은 매우 중요하다. 기본적인 산정 공식은 다음과 같다.

예를 들어, 초당 100개의 요청(TPS 100)이 들어오고 평균 처리 시간이 0.5초라면, 약 50개의 스레드가 필요함을 추정할 수 있다.

3.1. 파라미터의 이해

  • corePoolSize: 기본적으로 항상 유지되는 스레드 수.
  • maxPoolSize: 부하 발생 시 최대로 확장 가능한 스레드 수.
  • queueCapacity: 작업 대기열의 크기.

3.2. 실전 설정 시나리오

 

3.2.1. 단기 Burst(급격한 트래픽 증가)가 있는 API

 특정 프로모션이나 이벤트 응모 API처럼 평소엔 한가하다가 순간적으로 요청이 몰리는 경우이다. 평소엔 자원을 아끼고 필요할 때 빠르게 확장해야 한다.

@Bean(name = "burstExecutor")
public Executor burstExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);     // 평소 자원 절약
    executor.setMaxPoolSize(100);     // 피크 시 100개까지 확장
    executor.setQueueCapacity(50);    // 대기열을 작게 잡아 빠르게 새 스레드 생성 유도
    executor.setThreadNamePrefix("Burst-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
}

핵심: 대기열(queueCapacity)을 낮게 설정하여 대기열이 차는 즉시 maxPoolSize까지 스레드가 생성되도록 유도한다.

3.2.2. 꾸준한 트래픽이 있는 API

 검색이나 데이터 수집 API처럼 일정한 요청이 지속적으로 발생하는 대형 레스토랑 같은 상황이다. 충분한 기본 인력을 유지하는 것이 효율적이다.

@Bean(name = "steadyExecutor")
public Executor steadyExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(50);      // 높은 기본 스레드 유지
    executor.setMaxPoolSize(60);       // 변동폭을 작게 설정
    executor.setQueueCapacity(500);    // 큰 대기열로 일시적 부하 흡수
    executor.setThreadNamePrefix("Steady-");
    executor.initialize();
    return executor;
}

핵심: corePoolSize를 높여 일정한 처리량을 보장하고, 큰 대기열로 미세한 부하 변동을 흡수한다.

3.2.3. 응답 시간이 긴 API (외부 API 호출 중심)

 배달 주문처럼 외부 요인에 의해 처리 시간이 길고 예측이 어려운 경우이다. 스레드가 외부 응답을 기다리느라 오래 점유되므로, 많은 수의 스레드를 유연하게 투입할 수 있어야 한다.

@Bean(name = "externalApiExecutor")
public Executor externalApiExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);       // 기본 자원 최소화
    executor.setMaxPoolSize(200);      // 외부 대기 스레드가 많아질 것에 대비해 크게 설정
    executor.setQueueCapacity(10);     // 빠른 포화로 신규 스레드 생성 촉진
    executor.setThreadNamePrefix("ExtAPI-");
    executor.initialize();
    return executor;
}

핵심: 외부 시스템의 지연으로 인해 스레드가 묶이는 상황에 대비하여 maxPoolSize를 넉넉하게 설정한다.


4. 스프링 환경에서의 비동기 처리: @Async

스레드 풀을 직접 관리하는 것은 유연하지만 코드가 복잡해질 수 있다. 스프링은 이를 위해 @Async 어노테이션을 제공한다.

4.1. 활성화 및 사용법

먼저 설정 클래스에 @EnableAsync를 추가하여 비동기 기능을 활성화해야 한다.

@Configuration
@EnableAsync
public class AsyncConfig { }
@Service 
public class MyService {
    @Async
    public void asyncMethod() {
        System.out.println("비동기 실행 중: " + Thread.currentThread().getName());
    }
}

4.2. @Async 사용 시 주의사항

4.2.1. 프록시 기반 동작과 내부 호출 이슈 (⭐)

 

 @Async는 AOP 프록시 기반으로 동작한다. 외부에서 빈을 호출할 때 스프링이 가로채서 비동기 처리를 수행하는 구조이다. 따라서 같은 클래스 내에서 메서드를 직접 호출하면 비동기가 적용되지 않는다.

@Service
public class MyService {
    public void syncMethod() {
        asyncMethod(); // 내부 호출 (this.asyncMethod()) -> 프록시를 거치지 않아 동기로 실행됨
    }
    
    @Async
    public void asyncMethod() {
        System.out.println("비동기 실행 중: " + Thread.currentThread().getName());
    }
}

4.2.2. 기본 실행자(SimpleAsyncTaskExecutor)의 위험성 (⭐⭐)

 별도의 설정을 하지 않으면 스프링은 SimpleAsyncTaskExecutor를 사용한다. 이는 스레드 풀이 아니라, 작업마다 스레드를 새로 생성한다. 스레드 재사용이 이루어지지 않아 자원이 낭비되고 성능이 급격히 저하될 위험이 있다.

 

따라서 아래와 같이 "커스텀 스레드 풀을 정의하고 명시"해야 한다. 즉, 운영 환경에서는 반드시 서비스 성격에 맞는 ThreadPoolTaskExecutor를 정의하고 @Async에 이름을 명시해야 한다.

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(15);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("AsyncThread-");
        executor.initialize();
        return executor;
    }
}
@Service 
public class MyService {
    @Async("asyncExecutor") // 사용할 스레드 풀 빈 이름 명시
    public void asyncMethod() {
        // 비동기 로직
    }
}

5. 결론

 비동기 처리는 단순히 작업을 뒤에서 실행하는 것이 아니라, 한정된 시스템 자원을 서비스 유형에 맞춰 얼마나 영리하게 배분하느냐의 문제이다.

  1. CompletableFuture를 통해 비동기 작업의 흐름을 조합한다.
  2. 서비스 성격(Burst, Steady, External)에 따라 스레드 풀 파라미터를 정밀하게 튜닝한다.
  3. @Async를 사용할 때는 프록시의 특성과 기본 실행자의 위험성을 인지하고 커스텀 설정을 적용한다.

이 세 가지가 조화를 이룰 때, 비로소 고성능 백엔드 시스템을 안정적으로 구축할 수 있다.

 

'Java > Core' 카테고리의 다른 글

[Async-3][Optimization] 실무 적용 (⭐)  (0) 2025.12.27
[Async-1][Optimization] 동기 vs 비동기, 블로킹 vs 논블로킹  (0) 2025.12.27
[Stream-4][Optimization] 실무 적용과 성과 증명 전략  (0) 2025.12.27
[Stream-3][Optimization] Filter Overhead를 활용한 성능 개선 사례  (0) 2025.12.27
[Stream-2][Optimization] 선택도(Selectivity)와 비용(Cost)의 이해  (0) 2025.12.27
'Java/Core' 카테고리의 다른 글
  • [Async-3][Optimization] 실무 적용 (⭐)
  • [Async-1][Optimization] 동기 vs 비동기, 블로킹 vs 논블로킹
  • [Stream-4][Optimization] 실무 적용과 성과 증명 전략
  • [Stream-3][Optimization] Filter Overhead를 활용한 성능 개선 사례
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

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

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Async-2][Optimization] CompletableFuture와 효율적인 스레드 풀 관리
상단으로

티스토리툴바