Architecture

CachedThreadPool의 한계와 ThreadPoolExecutor 커스터마이징

백엔드 유성 2024. 10. 28. 00:45

CachedThreadPool의 동작 방식과 한계

Java의 CachedThreadPool은 많은 짧은 작업을 빠르게 처리하기 위해 설계되었지만, 동시 작업 수가 급증하거나 개별 작업이 오래 걸리는 경우에는 주의해야 합니다.

 

CachedThreadPool 구성 및 동작 방식 

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(
        0,                          // corePoolSize
        Integer.MAX_VALUE,          // maximumPoolSize
        60L, TimeUnit.SECONDS,      // keepAliveTime
        new SynchronousQueue<Runnable>() // 작업 큐
    );
}

 

  1. corePoolSize = 0
    • 초기에는 0개의 쓰레드만 유지합니다.
    • 새로운 작업이 오면 항상 “기존에 유휴 쓰레드가 남아 있는지” 우선 확인하고, 없으면 새 쓰레드를 만들어 실행합니다.
  2. maximumPoolSize = Integer.MAX_VALUE
    • 사실상 무제한으로 쓰레드를 생성할 수 있습니다.
  3. keepAliveTime = 60초
    • 한 번 생성된 쓰레드는 60초 동안 유휴 상태라면 자동으로 종료되고 풀에서 제거됩니다.
  4. 작업 큐 = SynchronousQueue
    • 버퍼가 전혀 없는(큐 용량이 0인) 큐입니다.
    • execute() 호출 시, “바로 실행할 수 있는 유휴 쓰레드”가 존재하지 않으면 곧바로 새 쓰레드를 생성합니다.
    • 반대로, take()(즉시 작업 요청) 시에 기다리는 execute() 쓰레드가 없다면 take() 쓰레드는 블로킹됩니다.

 

CachedThreadPool의 대표적인 문제 사례

과도한 쓰레드 생성

  • 시나리오
    • 짧은 시간에 10,000개의 요청이 동시에 들어오는 상황을 가정합시다.
    • CachedThreadPool은 유휴 쓰레드 없이 새 작업이 들어올 때마다 새 쓰레드를 만들기 때문에, 10,000개의 쓰레드를 한꺼번에 생성합니다.
    • 이 과정에서 메모리 고갈, 스레드 컨텍스트 스위칭 폭증 등으로 인해 애플리케이션 장애가 발생할 수 있습니다.

작업이 오래 걸리면 쓰레드 재사용이 불가능

  • 시나리오
    • 각 작업이 10초 이상 걸리는 I/O 바운드 혹은 CPU 바운드 작업이라면, 이미 생성된 쓰레드는 그만큼 오래 점유된 상태가 됩니다.
    • 그 동안에도 새로운 작업은 계속 들어오므로 매번 새로운 쓰레드를 생성하고, 결국 쓰레드 재사용이 전혀 이루어지지 않습니다.
    • 결과적으로 메모리 사용량이 크게 증가하고, CPU 스케줄링 비용이 커져 시스템 전체가 느려집니다

 

대안: ThreadPoolExecutor 커스터마이징

CachedThreadPool이 가진 “무제한 쓰레드 생성” 특성을 제어하려면, 직접 ThreadPoolExecutor를 생성하여 corePoolSize, maximumPoolSize, 큐 용량 등을 적절히 설정해야 합니다.

ExecutorService executor = new ThreadPoolExecutor(
    5,                      // corePoolSize: 5으로 설정하여 최소 쓰레드 개수를 지정
    10,                    // maximumPoolSize: 최대 10개로 제한
    30L,                    // keepAliveTime: 유휴 스레드는 30초 후 종료
    TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(100), // 제한된 크기의 큐를 사용해 작업 관리
    new ThreadPoolExecutor.AbortPolicy()
);

 

 

주요 파라미터 설명

  1. corePoolSize (5)
    • 최소한으로 유지할 쓰레드 개수입니다.
    • 애플리케이션 시작 후 “동시 작업 요청”이 발생하면 최대 5개의 쓰레드를 미리 생성하여 즉시 작업을 처리할 수 있습니다.
    • prestartAllCoreThreads()를 호출하면 애플리케이션 기동 시점에 5개의 쓰레드를 미리 띄울 수도 있습니다.
  2. maximumPoolSize (10)
    • 동시에 최대 10개의 쓰레드만 생성할 수 있습니다.
    • 이미 corePoolSize(5)만큼 쓰레드가 모두 작업 중이고, 작업 큐도 가득 차서 대기할 공간이 없을 때만 새로운 쓰레드를 만들어 최대 10개까지 확장합니다.
  3. keepAliveTime (30초)
    • corePoolSize를 초과하여 추가 생성된 쓰레드(6~10번 쓰레드)가 30초 동안 유휴 상태이면 자동 종료됩니다.
    • 기본적으로 corePoolSize 이하의 쓰레드는 종료되지 않습니다.
  4. LinkedBlockingQueue<Runnable>(100)
    • 대기 큐의 크기를 100개로 제한합니다.
    • corePoolSize(5)만큼 쓰레드가 이미 바쁘다면, 들어오는 최대 100개의 작업이 큐에 저장됩니다.
    • 100개 큐도 가득 차면, 초과 요청부터는 newThread를 생성해 처리하다가, 최대PoolSize(10)를 초과하면 RejectedExecutionException이 발생합니다.
  5. RejectedExecutionHandler = AbortPolicy
    • 큐도 가득 차 있고, 이미 maximumPoolSize(10)만큼 쓰레드가 모두 바쁠 때 새로운 작업 요청이 들어오면 예외를 던집니다.
    • 필요하다면 CallerRunsPolicy, DiscardPolicy, CallerRunsPolicy와 같은 다른 정책을 사용할 수 있습니다.

 

 

스레드 풀 동작 예시

  1. 초기 상태
    • 스레드 0개, 큐 비어 있음
  2. 첫 5개 요청
    • corePoolSize(5)만큼 스레드를 생성하여 즉시 처리
    • 큐에는 아무 것도 쌓지 않음
  3. 다음 100개 요청
    • 5개 스레드가 모두 바쁘면, 들어오는 요청 100개는 큐에 저장
    • 큐 크기가 100이므로 이 시점에서는 여유 큐 공간 활용
  4. 추가 요청이 들어올 때
    • 이미 스레드 5개가 바쁘고, 큐도 모두 차 있는 상태
    • 이때 새로운 요청마다 새로운 스레드를 생성해 최대 10개까지 확장
    • 10개 스레드가 모두 바쁘면, 이제부터 더는 스레드를 생성하지 않고 RejectedExecutionException을 발생시킵니다.
  5. 스레드 축소
    • corePoolSize(5)를 초과해 생성된 스레드(6~10번)는
    • 마지막 작업 완료 후 30초 동안 대기 상태가 되면 자동 종료
    • corePoolSize 이하 스레드는 계속 유지

 


 

RejectedExecutionHandler 정책

  1. AbortPolicy (기본)
    • 큐와 스레드 풀 모두 한계에 도달하면 RejectedExecutionException을 main 쓰레드에 던집니다.
  2. CallerRunsPolicy (특정 상황에서 유용)
    • 거절된 작업을 호출한(제출한) 스레드(main 등)에서 직접 실행
    • 이로 인해 생산자(main) 스레드가 작업을 처리하는 동안,
    • 새로운 작업을 큐에 제출하지 못해 자연스럽게 백프레셔가 발생합니다.
      (작업이 누락되면 안되는 경우, 지연을 발생시켜 처리하므로 유용합니다.)
  3. DiscardPolicy
    • 거절된 작업을 그냥 버립니다. (아무런 예외도 던지지 않음)
  4. DiscardOldestPolicy
    • 큐에서 가장 오래된 작업을 버리고, 새로운 작업을 큐에 추가
  • 커스텀 정책을 작성하려면 RejectedExecutionHandler 인터페이스를 구현해 사용하세요.

 

쓰레드 생성 비용과 조절 필요성

쓰레드를 많이 쓸수록 시스템 리소스에 다음과 같은 부담이 생깁니다.

  1. 메모리 할당 비용
    • 각 쓰레드는 고유한 스택 메모리를 할당받습니다. (기본 스택 크기는 JVM과 운영체제 JVM 옵션에 따라 다름)
    • 스레드 수가 늘어날수록 JVM 힙 외에 OS 레벨 메모리가 크게 사용됩니다.
  2. 컨텍스트 스위칭(문맥 교환) 비용
    • CPU가 여러 쓰레드를 번갈아가며 스케줄링해야 하므로, OS 스케줄러 오버헤드가 커집니다.
    • 실제로 작업 처리 시간보다 쓰레드 전환에 낭비되는 시간이 더 많아질 수 있습니다.
  3. JVM·운영체제 초기화 비용
    • 쓰레드 생성 시 OS 커널 스레드를 생성하고 JVM 내부 구조를 초기화 해야 하므로 짧은 시간에 많은 스레드 생성은 성능 저하를 유발합니다.
  4. Garbage Collection(GC) 영향
    • 많은 쓰레드를 운영하면 힙 메모리 객체 참조가 복잡해져 GC 트리거가 잦아질 수 있으며,
    • GC pause가 길어져 전체 시스템 반응성이 떨어질 수 있습니다.

 

최종 결론 및 권장 사항

CachedThreadPool은 짧고 경량화된 작업을 빠르게 처리할 때 유리하지만, 무제한 쓰레드 생성으로 인해 동시 요청이 폭증하거나 작업당 처리 시간이 길면 시스템 장애 위험이 큽니다.

 

ThreadPoolExecutor를 직접 커스터마이징하면, corePoolSize, maximumPoolSize, 큐 크기, keepAliveTime을 적절히 조절하여 과도한 쓰레드 생성을 방지할 수 있습니다.

 

애플리케이션 특성(작업량, 작업당 평균 실행 시간, 허용 대기 시간 등)에 맞춰 수치를 튜닝하세요.

 

실제 운영 환경에서는 아래 조건을 종합적으로 관찰하며, 쓰레드 풀 파라미터를 반복 테스트해야 합니다.

  • JVM Heap, GC 모니터링
  • 쓰레드 덤프 분석
  • CPU 사용률 및 스케줄링 대기 시간
  • 애플리케이션 레벨 메트릭(큐 대기 시간, 처리 TPS 등)

 

지금까지 CachedThreadPool의 한계와, 이를 보완하기 위해 ThreadPoolExecutor를 커스터마이징하는 방법을 살펴보았습니다.적절한 corePoolSize, maximumPoolSize, queue 크기, 그리고 RejectedExecutionHandler 정책을 조합하면,동적 작업량이 많은 환경에서도 안정적이고 일관된 성능을 유지할 수 있습니다.