Architecture

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

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

CachedThreadPool의 동작 방식과 한계

Java의 CachedThreadPool은 다음과 같은 특성을 지니며 높은 처리량을 지원하지만, 동시 작업 수가 많을 때 주의가 필요합니다.

 

    /**
     * 필요한 경우 새 스레드를 생성하지만, 기존에 생성된 스레드가 있으면
     * 이를 재사용하는 스레드 풀을 생성합니다. 이 풀은 많은 짧은 비동기 작업을 
     * 수행하는 프로그램의 성능을 일반적으로 향상시킵니다.
     * {@code execute} 호출 시 기존에 생성된 스레드가 사용 가능하다면 이를 재사용합니다.
     * 사용 가능한 기존 스레드가 없으면 새 스레드를 생성하여 풀에 추가합니다.
     * 60초 동안 사용되지 않은 스레드는 종료되고 캐시에서 제거됩니다.
     * 따라서 오랫동안 유휴 상태인 풀은 자원을 소비하지 않습니다. 비슷한 특성을 가지지만
     * 세부 설정이 다른 풀(예: 타임아웃 매개변수 조정)은 {@link ThreadPoolExecutor} 
     * 생성자를 사용하여 생성할 수 있습니다.
     *
     * @return 새로 생성된 스레드 풀
     */
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

 

 

CachedThreadPool 구성 및 동작 방식 

  • 최소 쓰레드 수 (corePoolSize): 0으로 설정되어 필요 시에만 쓰레드를 생성합니다.
  • 최대 쓰레드 수 (maximumPoolSize): Integer.MAX_VALUE로 설정되어 제한 없이 쓰레드를 생성할 수 있습니다.
  • 유휴 시간 (keepAliveTime): 60초로 설정되어 유휴 상태의 쓰레드는 자동으로 종료됩니다.
  • 작업 큐 (SynchronousQueue): 대기열 없이 작업이 들어오면 즉시 사용 가능한 쓰레드에 할당하거나 새 쓰레드를 생성합니다.

CachedThreadPool 의 문제점

  • CachedThreadPoolmaximumPoolSize가 매우 크기 때문에 작업량이 급격히 증가하면 쓰레드가 무한정 생성될 수 있습니다.
  • 동시 작업이 과도한 환경에서는 수백 개 이상의 쓰레드가 생성되며, 메모리 부족이나 성능 저하의 원인이 될 수 있습니다.

그러므로 ThreadPoolExecutor를 커스터마이징 해서 사용해보겠습니다.

 

대안: ThreadPoolExecutor 커스터마이징

ThreadPoolExecutor를 직접 사용하여 CachedThreadPool과 유사하게 동작하지만, 최대 스레드 수를 제한하거나 대기 시간을 줄여 과도한 스레드 생성을 방지할 수 있습니다.

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

 

ThreadPoolExecutor를 직접 커스터마이징하여, CachedThreadPool의 유연함을 유지하면서도 최대 쓰레드 수 제한을 통해 과도한 쓰레드 생성을 방지할 수 있습니다.

 

설정 값 의미

  • corePoolSize: 초기 최소 쓰레드 개수가 아니라, 도달 시 그 이하로 줄어들지 않는 기준입니다.
  • maximumPoolSize: 최대 쓰레드 수로, 초과 시 작업이 거부될 수 있으므로 필요 시 RejectedExecutionHandler 설정을 고려합니다.
  • LinkedBlockingQueue: 큐의 크기는 쓰레드 증가 조건에 영향을 미칩니다. 기본 생성자로 사용하면 Integer.MAX_VALUE로 설정되므로 corePoolSize만큼의 쓰레드만 생성됩니다. 큐 크기를 적절히 설정해 줘야 합니다.

 

Thread Pool 설정 제안

다음은 Thread Pool 설정 시 제가 괜찮다고 생각했던 설정입니다.

    • corePoolSize < LinkedBlockingQueueSize << maximumPoolSize
    • LinkedBlockingQueueSize는 corePoolSize보다 대략 30% 높은 값으로 설정합니다.
    • LinkedBlockingQueueSizemaximumPoolSize절대 동일한 값이 아니어야 하며, 둘 사이의 차이는 약 70% 정도로 설정합니다.

예시 (corePoolSize = 5, LinkedBlockingQueueSize = 10, maximumPoolSize = 20)

작업은 다수의 Runnable이 한꺼번에 들어오는 상황을 가정합니다. 또한, 각 작업의 소요 시간은 랜덤으로 처리됩니다.

다음은 예시 설정대로 Thread Pool이 동작하는 방식입니다.

  1. 작업 개수 5개: corePoolSize인 5개의 쓰레드가 생성되고 각 쓰레드에서 작업을 처리합니다.
  2. 작업 개수 8개: 큐가 비어 있는 상태에서 8개의 작업이 들어오면, 5개의 쓰레드에서 처리됩니다. 각 쓰레드에는 작업이 2, 2, 2, 1, 1의 비율로 할당됩니다. (값이 할당되는 위치는 보장되지 않으며, BlockingQueue에서 take 하는 쓰레드가 실행됩니다.)
  3. 작업 개수 11개: 작업이 추가로 들어오면 새로운 쓰레드 하나가 생성되고, 기존 5개의 쓰레드와 함께 11개의 작업을 처리합니다. 각 쓰레드에 작업은 2, 2, 2, 2, 2, 1개씩 분배됩니다.
  4. 작업 개수 16개: 총 16개의 작업이 들어오면, 기존 corePoolSize(5개) 쓰레드와 함께 maximumPoolSize 한도 내에서 새로운 쓰레드가 생성되어 11개의 쓰레드가 작업을 처리하게 됩니다.
  5. 유휴 쓰레드 정리: 작업이 끝나고 일정 시간이 지나면 유휴 상태의 쓰레드는 interrupt 되어, corePoolSize의 기본 쓰레드 5개만 남고 나머지는 정리됩니다.
  6. ##### 작업이 Queue에 다 차면 추가되는 Task(Runnable)은 Queue에 추가되지 않고 새로운 쓰레드를 생성해서 처리한다 로 보시면 됩니다.

 

LinkedBlockingQueueSize와 maximumPoolSize 간격의 중요성

  • LinkedBlockingQueueSize가 corePoolSize보다 크게 설정되어야 새로운 작업이 들어왔을 때 기존 쓰레드가 이를 처리하게 됩니다.
  • 반면, LinkedBlockingQueueSizemaximumPoolSize가 비슷하면 대기열이 차기 전까지 corePoolSize가 확장되지 않고, 특정 작업이 maximumPoolSize에 가까워질 때 여러 쓰레드가 급작스럽게 생성될 수 있습니다. 이로 인해 작업이 거절되는 상황이 발생할 수 있습니다.

 

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

쓰레드 생성에는 다음과 같은 비용이 발생하므로, minPoolSizemaxPoolSize를 적절히 조정해야 합니다

    • 메모리 할당: 각 쓰레드는 고유한 스택 메모리를 필요로 합니다. 스택 메모리를 생성할 때 여유 공간을 두므로 메모리 사용량이 증가합니다.
    • 컨텍스트 스위칭 비용: 쓰레드가 많아질수록 CPU 전환이 빈번해져 실제 작업에 쓸 시간이 줄어듭니다.
    • OS와 JVM의 초기화 비용: 쓰레드 생성 시 OS와 JVM 모두 초기화 과정이 필요해 성능에 영향을 줄 수 있습니다.
    • GC(가비지 컬렉션) 영향: 많은 쓰레드로 인해 객체 참조가 복잡해져 GC 비용이 증가할 수 있습니다.

 

마지막으로, 쓰레드 풀 설정은 시스템의 성능을 크게 좌우할 수 있는 중요한 요소입니다. 특히 동적 작업 처리가 많은 환경에서는 CachedThreadPool을 신중하게 사용하고, 필요한 경우 ThreadPoolExecutor를 통해 커스터마이징하는 것이 바람직합니다.

 

적절한 corePoolSize, maximumPoolSizeLinkedBlockingQueue 크기를 설정하여, 효율적으로 자원을 활용하면서 안정적인 성능을 보장하는 쓰레드 풀을 설계해 보세요. 이를 통해 시스템 과부하 상황에서도 일관된 성능을 유지하고, 불필요한 자원 소비를 방지할 수 있습니다.

 

감사합니다.