스레드 풀 포화 정책
ThreadPoolExecutor가 핵심 생성, 큐 적재, 최대값 확장, 거부로 이동하는 순서를 추적하고 고정 풀과 캐시 풀의 상반된 위험을 비교합니다.
ThreadPoolExecutor는 작업이 오면 무조건 최대값까지 스레드를 늘리지 않습니다. 먼저 핵심 미만이면 작업자를 만들고, 핵심이 차면 큐에 넣습니다. 큐도 가득 찬 뒤에야 최대값까지 초과 작업자를 만들며, 둘 다 찼을 때 거부 정책을 호출합니다.
따라서 큰 무제한 큐를 사용하면 최대값 값이 사실상 쓰이지 않을 수 있습니다. 반대로 SynchronousQueue와 큰 최대값을 조합한 캐시 풀은 큐 대신 스레드를 빠르게 늘려 자원 상한을 위협합니다.
maximumPoolSize 단독 확장의 한계
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public final class UnboundedQueueIgnoresMaximum {
public static void main(String[] args) throws InterruptedException {
var executor = new ThreadPoolExecutor(1, 100, 30, TimeUnit.SECONDS,
new LinkedBlockingQueue<>());
for (int i = 0; i < 20; i++) {
executor.execute(() -> {
try { Thread.sleep(50); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
System.out.println("pool=" + executor.getPoolSize());
System.out.println("queued=" + executor.getQueue().size());
executor.shutdownNow();
}
}풀은 대개 핵심 1에 머물고 나머지는 무제한 큐에 쌓입니다. 최대값 100이 지연을 줄여 줄 것이라는 기대가 큐 선택 때문에 성립하지 않습니다.
포화 순서의 의미
- poolSize가 핵심보다 작으면 큐보다 새 작업자 생성이 우선한다.
- 핵심이 차면 workQueue의
offer성공 여부를 본다. - 유한 큐가 거부한 뒤에만 최대값 범위의 초과 작업자를 만든다.
- 최대값도 찼으면 RejectedExecutionHandler로 제출자 정책을 실행한다.
- keepAliveTime은 유휴 초과 작업자를 줄이며 핵심 시간 제한은 별도 설정이다.
- 큐 크기와 작업자 수를 독립적으로 정하지 말고 지연·메모리·의존성 상한으로 묶는다.
포화 단계를 눈으로 확인하는 유한 풀
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public final class PoolSaturationSteps {
public static void main(String[] args) throws InterruptedException {
CountDownLatch release = new CountDownLatch(1);
var executor = new ThreadPoolExecutor(2, 4, 1, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2));
Runnable blocked = () -> {
try { release.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
for (int i = 1; i <= 6; i++) {
executor.execute(blocked);
System.out.printf("task=%d pool=%d queued=%d%n", i,
executor.getPoolSize(), executor.getQueue().size());
}
release.countDown();
executor.shutdown();
executor.awaitTermination(1, TimeUnit.SECONDS);
}
}처음 두 작업은 핵심, 다음 둘은 큐, 마지막 둘은 최대값 확장으로 갑니다. 일곱 번째 제출은 거부됩니다. 래치로 작업을 붙잡아 단계가 지나가기 전에 상태를 관찰합니다.
고정·탄력 풀 부하 비교
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
public final class PoolStrategySnapshot {
static void print(String name, java.util.concurrent.ExecutorService service) {
ThreadPoolExecutor pool = (ThreadPoolExecutor) service;
for (int i = 0; i < 20; i++) {
pool.execute(() -> {
try { Thread.sleep(20); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
System.out.printf("%s pool=%d queued=%d%n", name,
pool.getPoolSize(), pool.getQueue().size());
pool.shutdownNow();
}
public static void main(String[] args) {
print("fixed", Executors.newFixedThreadPool(2));
print("cached", Executors.newCachedThreadPool());
}
}고정 풀은 작업자 수를 지키고 큐를 늘리며, 캐시 풀은 큐 없이 작업자 수를 늘립니다. 작은 실험 결과를 운영 상한으로 착각하지 말고 부하에서 메모리와 외부 연결 수를 측정합니다.
자원 상한에 맞춘 풀 전략 선택
| 전략 | 보호하는 것 | 주요 위험 |
|---|---|---|
| 고정 풀·큰 큐 | 서버 동시 실행 | 사용자 지연 누적 |
| 캐시 풀 | 빠른 제출 처리 | 스레드·의존성 폭주 |
| 유한 큐·유한 최대값 | 자원과 대기 상한 | 거부 처리 필요 |
| 가상 스레드 | 스레드 대기 비용 | 외부 동시성 별도 제한 |
의존성 예산을 고려한 풀 크기
작업자가 데이터베이스 연결 하나를 점유한다면 풀의 최대 동시 실행 수가 연결 풀보다 큰 순간부터 스레드가 연결을 기다립니다. 이 대기는 처리량을 늘리지 못하면서 메모리와 문맥 교환만 사용합니다. HTTP 클라이언트의 대상별 연결 제한, 파일 핸들, 외부 API의 초당 허용량도 같은 방식으로 실행 상한에 반영합니다.
CPU 중심 작업은 코어 수 부근에서 출발하고, 대기가 섞인 작업은 대기 시간과 계산 시간의 비율을 근거로 조금 늘릴 수 있습니다. 그러나 공식을 그대로 운영값으로 확정하지 않습니다. 대표 부하에서 처리량이 더 이상 늘지 않는 지점, 지연 상위 백분위가 급격히 커지는 지점, 의존성 오류가 시작되는 지점을 함께 찾아 안전 여유를 둡니다.
큐는 순간 변동을 흡수할 뿐 지속적인 과부하를 해결하지 않습니다. 큐 안에서 보낸 시간도 사용자 응답 예산에 포함하고, 오래 기다린 작업은 실행 전에 기한을 확인해 가치가 사라졌다면 취소합니다. 오래된 요청을 실행하느라 최신 요청까지 늦어지는 현상을 막을 수 있습니다.
서로 다른 지연 특성을 가진 작업을 한 풀에 섞으면 느린 작업이 모든 작업자를 점유할 수 있습니다. 짧은 조회와 긴 보고서 생성처럼 서비스 수준이 다르면 실행기를 분리하고 각자 용량과 거부 정책을 둡니다. 분리는 스레드를 많이 만드는 목적이 아니라 한 종류의 장애가 다른 종류의 지연 예산을 소진하지 못하게 하는 격리입니다.
조정은 한 번에 한 변수만 바꾸고 같은 입력 분포로 전후를 비교합니다. 처리량 평균만 보면 짧은 작업이 긴 작업의 악화를 가릴 수 있으므로 큐 대기와 실행 시간을 분리해 p50, p95, p99를 기록합니다. 활성 작업자 수가 최대에 붙어 있는 시간과 거부율도 함께 보면 풀이 부족한지 의존성이 느린지 구별하기 쉽습니다.
가상 스레드를 사용해도 데이터베이스나 외부 서비스의 동시성은 무한해지지 않습니다. 블로킹 스레드 비용은 낮아지지만 실제 병목 앞에는 세마포어 또는 제한된 연결 풀을 두어 허용량을 표현해야 합니다. 플랫폼 스레드 풀 숫자를 지운 자리에 업무 자원 상한이 남아 있는지 점검하세요.
연습 문제
초당 80건을 처리하고 큐 대기를 0.5초로 제한하려는 풀의 정상 큐 크기 시작값을 계산하세요. 순간 폭주 계수 1.5를 적용합니다.
정답과 해설
public final class ExecutorQueueEstimateSolution {
static int estimate(double throughputPerSecond, double allowedSeconds, double burst) {
if (throughputPerSecond <= 0 || allowedSeconds <= 0 || burst < 1) {
throw new IllegalArgumentException();
}
return (int) Math.ceil(throughputPerSecond * allowedSeconds * burst);
}
public static void main(String[] args) {
System.out.println("queue=" + estimate(80, 0.5, 1.5));
}
}시작값은 60입니다. 실제 작업 시간 분포와 거부율을 측정해 조정하며 장기 생산률이 처리율보다 높다면 용량 증가가 해결책이 아닙니다.
포화 설계의 최종 용량 규칙
핵심, 큐, 최대값은 포화 순서로 연결됩니다. 큐 구현 하나가 최대값의 실효성을 바꿀 수 있으므로 생성자 숫자를 따로 해석하지 마세요. 자원 상한과 허용 대기를 함께 정하고 거부 경로를 실제로 실행해 확인합니다.