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 의 문제점
- CachedThreadPool은 maximumPoolSize가 매우 크기 때문에 작업량이 급격히 증가하면 쓰레드가 무한정 생성될 수 있습니다.
- 동시 작업이 과도한 환경에서는 수백 개 이상의 쓰레드가 생성되며, 메모리 부족이나 성능 저하의 원인이 될 수 있습니다.
그러므로 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% 높은 값으로 설정합니다.
- LinkedBlockingQueueSize와 maximumPoolSize는 절대 동일한 값이 아니어야 하며, 둘 사이의 차이는 약 70% 정도로 설정합니다.
예시 (corePoolSize = 5, LinkedBlockingQueueSize = 10, maximumPoolSize = 20)
작업은 다수의 Runnable이 한꺼번에 들어오는 상황을 가정합니다. 또한, 각 작업의 소요 시간은 랜덤으로 처리됩니다.
다음은 예시 설정대로 Thread Pool이 동작하는 방식입니다.
- 작업 개수 5개: corePoolSize인 5개의 쓰레드가 생성되고 각 쓰레드에서 작업을 처리합니다.
- 작업 개수 8개: 큐가 비어 있는 상태에서 8개의 작업이 들어오면, 5개의 쓰레드에서 처리됩니다. 각 쓰레드에는 작업이 2, 2, 2, 1, 1의 비율로 할당됩니다. (값이 할당되는 위치는 보장되지 않으며, BlockingQueue에서 take 하는 쓰레드가 실행됩니다.)
- 작업 개수 11개: 작업이 추가로 들어오면 새로운 쓰레드 하나가 생성되고, 기존 5개의 쓰레드와 함께 11개의 작업을 처리합니다. 각 쓰레드에 작업은 2, 2, 2, 2, 2, 1개씩 분배됩니다.
- 작업 개수 16개: 총 16개의 작업이 들어오면, 기존 corePoolSize(5개) 쓰레드와 함께 maximumPoolSize 한도 내에서 새로운 쓰레드가 생성되어 11개의 쓰레드가 작업을 처리하게 됩니다.
- 유휴 쓰레드 정리: 작업이 끝나고 일정 시간이 지나면 유휴 상태의 쓰레드는 interrupt 되어, corePoolSize의 기본 쓰레드 5개만 남고 나머지는 정리됩니다.
- ##### 작업이 Queue에 다 차면 추가되는 Task(Runnable)은 Queue에 추가되지 않고 새로운 쓰레드를 생성해서 처리한다 로 보시면 됩니다.
LinkedBlockingQueueSize와 maximumPoolSize 간격의 중요성
- LinkedBlockingQueueSize가 corePoolSize보다 크게 설정되어야 새로운 작업이 들어왔을 때 기존 쓰레드가 이를 처리하게 됩니다.
- 반면, LinkedBlockingQueueSize와 maximumPoolSize가 비슷하면 대기열이 차기 전까지 corePoolSize가 확장되지 않고, 특정 작업이 maximumPoolSize에 가까워질 때 여러 쓰레드가 급작스럽게 생성될 수 있습니다. 이로 인해 작업이 거절되는 상황이 발생할 수 있습니다.
쓰레드 생성 비용과 조절 필요성
쓰레드 생성에는 다음과 같은 비용이 발생하므로, minPoolSize와 maxPoolSize를 적절히 조정해야 합니다
- 메모리 할당: 각 쓰레드는 고유한 스택 메모리를 필요로 합니다. 스택 메모리를 생성할 때 여유 공간을 두므로 메모리 사용량이 증가합니다.
- 컨텍스트 스위칭 비용: 쓰레드가 많아질수록 CPU 전환이 빈번해져 실제 작업에 쓸 시간이 줄어듭니다.
- OS와 JVM의 초기화 비용: 쓰레드 생성 시 OS와 JVM 모두 초기화 과정이 필요해 성능에 영향을 줄 수 있습니다.
- GC(가비지 컬렉션) 영향: 많은 쓰레드로 인해 객체 참조가 복잡해져 GC 비용이 증가할 수 있습니다.
마지막으로, 쓰레드 풀 설정은 시스템의 성능을 크게 좌우할 수 있는 중요한 요소입니다. 특히 동적 작업 처리가 많은 환경에서는 CachedThreadPool을 신중하게 사용하고, 필요한 경우 ThreadPoolExecutor를 통해 커스터마이징하는 것이 바람직합니다.
적절한 corePoolSize, maximumPoolSize 및 LinkedBlockingQueue 크기를 설정하여, 효율적으로 자원을 활용하면서 안정적인 성능을 보장하는 쓰레드 풀을 설계해 보세요. 이를 통해 시스템 과부하 상황에서도 일관된 성능을 유지하고, 불필요한 자원 소비를 방지할 수 있습니다.
감사합니다.
'Architecture' 카테고리의 다른 글
코어 수에 따른 프로세스와 쓰레드 개수의 관계와 성능 영향 (1) | 2024.11.04 |
---|---|
대규모 트래픽에 대비한 아키텍처 확장 전략 (0) | 2024.11.02 |
의존성 역전 원칙(DIP): 유연하고 확장 가능한 코드 설계의 핵심 (0) | 2024.02.05 |
좋은 코드를 위한 5가지 핵심 원칙: SOLID부터 리팩토링까지 (0) | 2024.01.21 |
쉽게 이해하는 SOLID 원칙: 유지보수성을 높이는 객체지향 설계 방법 (2) | 2024.01.02 |