스레드 풀 포화 정책
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();
}
}이 예제의 20개 제출은 큐 용량을 채우지 않으므로 작업자 수가 핵심 값 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);
}
}핵심 2·큐 2·최대 4에서 여섯 번의 제출
래치로 작업 완료를 막고 여섯 submit이 core 2→queue 2→max 4를 만듭니다. 고정된 작업자/큐의 각 접수 변화가 명확한 실행 추적입니다.
핵심 2·큐 2·최대 4에서 여섯 번의 제출
수용 순서 · 작업을 막아 둔 여섯 제출
핵심 2·큐 2·최대 4에서 여섯 번의 제출
핵심 작업자 2명을 만든 뒤 큐 2칸을 채우고, 다음 두 제출이 작업자를 4명으로 늘립니다.
핵심 작업자 다음에 큐를 먼저 채운다
래치를 풀기 전 작업이 끝나지 않는 조건이다. 새 제출은 먼저 core 2 미만인지, 다음 큐 2칸에 여유가 있는지, 그다음 max 4 미만인지 검사한다. task 1·2는 핵심 작업자, 3·4는 큐, 5·6은 추가 작업자를 선택한다. 같은 포화 상태의 7번째 거부는 원 main에 없는 조건 확장이며 관측 출력이 아니다.
여섯 작업의 실제 수용 위치
래치를 풀기 전에는 작업이 끝나지 않는다. core 작업자에는 1·2, 큐에는 3·4, 큐가 찬 뒤 추가 작업자에는 5·6이 배정된다. 7번째 거부는 실행하지 않은 조건 확장이다.
상태별 상세 해설
| 제출 | 선택된 수용 경로 | pool | queued |
|---|---|---|---|
| task=1 | 핵심 작업자 생성 | 1 | 0 |
| task=2 | 핵심 작업자 생성 | 2 | 0 |
| task=3 | 큐에 적재 | 2 | 1 |
| task=4 | 큐에 적재 | 2 | 2 |
| task=5 | 최대값 범위에서 작업자 추가 | 3 | 2 |
| task=6 | 최대값 범위에서 작업자 추가 | 4 | 2 |
- task=1
- 선택된 수용 경로
- 핵심 작업자 생성
- 풀 · 대기열
- pool=1 · queued=0
- task=2
- 선택된 수용 경로
- 핵심 작업자 생성
- 풀 · 대기열
- pool=2 · queued=0
- task=3
- 선택된 수용 경로
- 큐에 적재
- 풀 · 대기열
- pool=2 · queued=1
- task=4
- 선택된 수용 경로
- 큐에 적재
- 풀 · 대기열
- pool=2 · queued=2
- task=5
- 선택된 수용 경로
- 최대값 범위에서 작업자 추가
- 풀 · 대기열
- pool=3 · queued=2
- task=6
- 선택된 수용 경로
- 최대값 범위에서 작업자 추가
- 풀 · 대기열
- pool=4 · queued=2
원문에서 래치를 풀기 전까지 작업이 끝나지 않는 조건으로 도출한 값입니다. 그 상태에서 일곱 번째 작업을 제출하면 거부되지만, 원문은 여섯 번만 제출합니다.
이 예의 조건
- 래치를 풀기 전 작업이 끝나지 않아 다음 수용 상태가 고정됩니다. 원문은 6회 제출입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 28 | 최댓값 범위에서 작업자 추가 |
| 32 | 최댓값 범위에서 작업자 추가 |
| 57 | 최댓값 범위에서 작업자 추가 |
| 62 | 최댓값 범위에서 작업자 추가 |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 핵심 2·큐 2·최대 4에서 여섯 번의 제출 핵심 2·큐 2·최대 4에서 여섯 번의 제출 핵심 2·큐 2·최대 4에서 여섯 번의 제출 수용 순서 · 작업을 막아 둔 여섯 제출 핵심 2·큐 2·최대 4에서 여섯 번의 제출 핵심 작업자 2명을 만든 뒤 큐 2칸을 채우고, 다음 두 제출이 작업자를 4명으로 늘립니다. 핵심 2·큐 2·최대 4에서 여섯 번의 제출 제출 선택된 수용 경로 pool queued task=1 핵심 작업자 생성 1 0 task=2 핵심 작업자 생성 2 0 task=3 큐에 적재 2 1 task=4 큐에 적재 2 2 task=5 최댓값 범위에서 작업자 추가 3 2 task=6 최댓값 범위에서 작업자 추가 4 2 task=1 선택된 수용 경로 핵심 작업자 생성 풀 · 대기열 pool=1 · queued=0 task=2 선택된 수용 경로 핵심 작업자 생성 풀 · 대기열 pool=2 · queued=0 task=3 선택된 수용 경로 큐에 적재 풀 · 대기열 pool=2 · queued=1 task=4 선택된 수용 경로 큐에 적재 풀 · 대기열 pool=2 · queued=2 task=5 선택된 수용 경로 최댓값 범위에서 작업자 추가 풀 · 대기열 pool=3 · queued=2 task=6 선택된 수용 경로 최댓값 범위에서 작업자 추가 풀 · 대기열 pool=4 · queued=2 원문에서 래치를 풀기 전까지 작업이 끝나지 않는 조건으로 도출한 값입니다. 그 상태에서 일곱 번째 작업을 제출하면 거부되지만, 원문은 여섯 번만 제출합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 핵심 worker 생성 task 1과 2를 새 핵심 worker에 배정합니다. |
|
|
| 큐 채움 task 3과 4를 큐에 넣습니다. |
|
|
| 최대 worker 확장 task 5와 6으로 worker를 4까지 늘립니다. |
|
|
핵심 2·큐 2·최대 4에서 여섯 번의 제출
래치로 작업 완료를 막고 여섯 submit이 core 2→queue 2→max 4를 만듭니다. 고정된 작업자/큐의 각 접수 변화가 명확한 실행 추적입니다.
핵심 2·큐 2·최대 4에서 여섯 번의 제출
수용 순서 · 작업을 막아 둔 여섯 제출
핵심 2·큐 2·최대 4에서 여섯 번의 제출
핵심 작업자 2명을 만든 뒤 큐 2칸을 채우고, 다음 두 제출이 작업자를 4명으로 늘립니다.
핵심 작업자 다음에 큐를 먼저 채운다
래치를 풀기 전 작업이 끝나지 않는 조건이다. 새 제출은 먼저 core 2 미만인지, 다음 큐 2칸에 여유가 있는지, 그다음 max 4 미만인지 검사한다. task 1·2는 핵심 작업자, 3·4는 큐, 5·6은 추가 작업자를 선택한다. 같은 포화 상태의 7번째 거부는 원 main에 없는 조건 확장이며 관측 출력이 아니다.
여섯 작업의 실제 수용 위치
래치를 풀기 전에는 작업이 끝나지 않는다. core 작업자에는 1·2, 큐에는 3·4, 큐가 찬 뒤 추가 작업자에는 5·6이 배정된다. 7번째 거부는 실행하지 않은 조건 확장이다.
상태별 상세 해설
| 제출 | 선택된 수용 경로 | pool | queued |
|---|---|---|---|
| task=1 | 핵심 작업자 생성 | 1 | 0 |
| task=2 | 핵심 작업자 생성 | 2 | 0 |
| task=3 | 큐에 적재 | 2 | 1 |
| task=4 | 큐에 적재 | 2 | 2 |
| task=5 | 최대값 범위에서 작업자 추가 | 3 | 2 |
| task=6 | 최대값 범위에서 작업자 추가 | 4 | 2 |
- task=1
- 선택된 수용 경로
- 핵심 작업자 생성
- 풀 · 대기열
- pool=1 · queued=0
- task=2
- 선택된 수용 경로
- 핵심 작업자 생성
- 풀 · 대기열
- pool=2 · queued=0
- task=3
- 선택된 수용 경로
- 큐에 적재
- 풀 · 대기열
- pool=2 · queued=1
- task=4
- 선택된 수용 경로
- 큐에 적재
- 풀 · 대기열
- pool=2 · queued=2
- task=5
- 선택된 수용 경로
- 최대값 범위에서 작업자 추가
- 풀 · 대기열
- pool=3 · queued=2
- task=6
- 선택된 수용 경로
- 최대값 범위에서 작업자 추가
- 풀 · 대기열
- pool=4 · queued=2
원문에서 래치를 풀기 전까지 작업이 끝나지 않는 조건으로 도출한 값입니다. 그 상태에서 일곱 번째 작업을 제출하면 거부되지만, 원문은 여섯 번만 제출합니다.
이 예의 조건
- 래치를 풀기 전 작업이 끝나지 않아 다음 수용 상태가 고정됩니다. 원문은 6회 제출입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 28 | 최댓값 범위에서 작업자 추가 |
| 32 | 최댓값 범위에서 작업자 추가 |
| 57 | 최댓값 범위에서 작업자 추가 |
| 62 | 최댓값 범위에서 작업자 추가 |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 핵심 2·큐 2·최대 4에서 여섯 번의 제출 핵심 2·큐 2·최대 4에서 여섯 번의 제출 핵심 2·큐 2·최대 4에서 여섯 번의 제출 수용 순서 · 작업을 막아 둔 여섯 제출 핵심 2·큐 2·최대 4에서 여섯 번의 제출 핵심 작업자 2명을 만든 뒤 큐 2칸을 채우고, 다음 두 제출이 작업자를 4명으로 늘립니다. 핵심 2·큐 2·최대 4에서 여섯 번의 제출 제출 선택된 수용 경로 pool queued task=1 핵심 작업자 생성 1 0 task=2 핵심 작업자 생성 2 0 task=3 큐에 적재 2 1 task=4 큐에 적재 2 2 task=5 최댓값 범위에서 작업자 추가 3 2 task=6 최댓값 범위에서 작업자 추가 4 2 task=1 선택된 수용 경로 핵심 작업자 생성 풀 · 대기열 pool=1 · queued=0 task=2 선택된 수용 경로 핵심 작업자 생성 풀 · 대기열 pool=2 · queued=0 task=3 선택된 수용 경로 큐에 적재 풀 · 대기열 pool=2 · queued=1 task=4 선택된 수용 경로 큐에 적재 풀 · 대기열 pool=2 · queued=2 task=5 선택된 수용 경로 최댓값 범위에서 작업자 추가 풀 · 대기열 pool=3 · queued=2 task=6 선택된 수용 경로 최댓값 범위에서 작업자 추가 풀 · 대기열 pool=4 · queued=2 원문에서 래치를 풀기 전까지 작업이 끝나지 않는 조건으로 도출한 값입니다. 그 상태에서 일곱 번째 작업을 제출하면 거부되지만, 원문은 여섯 번만 제출합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 핵심 worker 생성 task 1과 2를 새 핵심 worker에 배정합니다. |
|
|
| 큐 채움 task 3과 4를 큐에 넣습니다. |
|
|
| 최대 worker 확장 task 5와 6으로 worker를 4까지 늘립니다. |
|
|
release를 풀기 전에는 실행 중인 작업이 끝나지 않습니다. 그 조건에서 (pool, queued)는 제출 1부터 (1,0), (2,0), (2,1), (2,2), (3,2), (4,2)로 바뀝니다. 핵심 2명에 도달한 뒤에는 최댓값 4보다 큐 적재가 먼저여서 세 번째·네 번째 제출에서 작업자를 늘리지 않습니다. 여섯 번째까지 수용한 뒤 같은 조건의 일곱 번째 제출은 AbortPolicy의 거부 예외를 만듭니다. 원 main은 여섯 번 제출하며 그 뒤 래치를 푸므로, 일곱 번째 거부는 원 실행의 출력이 아닌 조건 확장에 대한 추론입니다.
고정·탄력 풀 부하 비교
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());
}
}고정 풀은 추가 요청을 큐에서 기다리게 하고, 캐시 풀은 대기 중인 작업자에게 인계할 수 없을 때 작업자를 추가합니다. 스냅샷의 수치는 스케줄에 따라 달라지며, SynchronousQueue의 크기 0은 실행 중인 작업이 없다는 뜻이 아닙니다.
작은 실험 결과를 운영 상한으로 착각하지 말고 부하에서 메모리와 외부 연결 수를 측정합니다.
자원 상한에 맞춘 풀 전략 선택
| 전략 | 보호하는 것 | 주요 위험 |
|---|---|---|
| 고정 풀·큰 큐 | 서버 동시 실행 | 사용자 지연 누적 |
| 캐시 풀 | 빠른 제출 처리 | 스레드·의존성 폭주 |
| 유한 큐·유한 최댓값 | 자원과 대기 상한 | 거부 처리 필요 |
| 가상 스레드 | 스레드 대기 비용 | 외부 동시성 별도 제한 |
의존성 예산을 고려한 풀 크기
작업자가 데이터베이스 연결 하나를 점유한다면 풀의 최대 동시 실행 수가 연결 풀보다 큰 순간부터 스레드가 연결을 기다립니다.
이 대기는 처리량을 늘리지 못하면서 메모리와 문맥 교환만 사용합니다.
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));
}
}주어진 유한한 양수 입력에서 시작값은 ceil(80 × 0.5 × 1.5) = 60입니다. 폭주 여유를 포함한 추정값이며, 모든 작업의 대기가 0.5초 이하라는 보장은 아닙니다.
범용 설정 검증에 쓰려면 NaN, 무한대와 int 범위 초과도 따로 확인해야 합니다.
실제 작업 시간 분포와 거부율을 측정해 조정하며 장기 생산율이 처리율보다 높다면 용량 증가가 해결책이 아닙니다.
확인 문제
작업을 래치에서 계속 기다리게 두고 core=1, maximum=3, 큐 용량=1인 새 풀에 작업 다섯 개를 순서대로 제출한다고 가정하세요. 각 제출 직후의 (pool, queued)와 첫 거부 위치를 계산해 보세요.
확인 문제 해설
첫 제출은 (1,0), 두 번째는 (1,1)입니다. 세 번째는 큐가 찬 뒤 초과 작업자를 만들어 (2,1), 네 번째는 (3,1)이 됩니다. 다섯 번째에는 최대 작업자와 큐가 모두 찼으므로 거부되며 두 크기는 유지됩니다. 이 조건에서는 네 작업을 수용하지만 동시 실행은 세 작업입니다. 큐 용량 1은 실행 중인 작업 수에 포함하지 않습니다.
포화 설계의 최종 용량 규칙
핵심, 큐, 최댓값은 포화 순서로 연결됩니다.
큐 구현 하나가 최댓값의 실효성을 바꿀 수 있으므로 생성자 숫자를 따로 해석하지 마세요.
자원 상한과 허용 대기를 함께 정하고 거부 경로를 실제로 실행해 확인합니다.