본문 바로가기

ThreadPoolExecutor의 스레드 수와 작업 큐

새 작업이 들어올 때 ThreadPoolExecutor가 스레드를 새로 만들지, 큐에 넣을지, 거부할지 정하는 순서를 따라가 보고, 큐 종류에 따라 최대 스레드 수가 어떻게 달리 쓰이는지와 풀 크기를 정하는 방법을 배웁니다.

이 장의 첫 글에서는 ThreadPoolExecutor를 직접 만들면서 스레드 수와 큐 용량을 정해 주었습니다. 그때는 스레드 수를 2로 고정했으므로 스레드가 언제 늘어나는지 따질 일이 없었습니다. 그런데 생성자에는 스레드 수를 정하는 인자가 corePoolSize와 maximumPoolSize 두 개 있습니다. 이름만 보면 평소에는 corePoolSize만큼 스레드를 두다가, 작업이 몰리면 maximumPoolSize까지 늘려 줄 것처럼 보입니다.

corePoolSize가 정하는 스레드를 코어 스레드(core thread)라고 부릅니다. CPU 코어와는 상관없는 이름으로, 풀이 일이 없을 때도 남겨 두는 기본 스레드라는 뜻입니다. 이 글의 도표에서는 같은 것을 '핵심 작업자'라고 적었습니다. 코어 수를 넘어 maximumPoolSize까지 더 만드는 스레드는 이 글에서 추가 스레드라고 부르겠습니다. 이제 작업이 몰릴 때 풀이 코어 스레드, 큐, 추가 스레드를 어떤 순서로 쓰는지 따라가 보고, 그 순서 때문에 생기는 함정과 풀 크기를 정하는 방법을 정리합니다.

최대 스레드 수를 100으로 정해도 늘지 않는 스레드

아래 프로그램은 코어 스레드 1개, 최대 스레드 100개인 풀을 만들고 50밀리초씩 걸리는 작업 20개를 쉬지 않고 제출합니다. 그다음 getPoolSize()로 풀에 있는 스레드 수를, getQueue().size()로 큐에서 기다리는 작업 수를 출력합니다. 작업이 한꺼번에 밀려 들어왔으니 스레드가 여러 개로 늘어날 것 같지만, 결과는 그렇지 않습니다.

bad/UnboundedQueueIgnoresMaximum.java
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();
    }
}

실행 결과는 다음과 같습니다. 최대 스레드 수를 100으로 정했고 작업은 20개이니, 기대대로라면 pool= 뒤의 숫자가 1보다 커야 합니다.

pool=1
queued=19

스레드는 하나뿐이고, 나머지 19개 작업은 모두 큐에서 차례를 기다립니다. 첫 번째 작업은 새로 만든 스레드가 바로 맡았는데, 그 스레드가 50밀리초 동안 잠들어 있으니 출력하는 순간까지 큐에서 아무것도 꺼내 가지 못한 것입니다. 마지막의 shutdownNow()는 남은 작업을 실행하지 않고 풀을 정리하려고 넣었습니다.

여러 번 실행해도 결과는 같았습니다. 다만 출력하기 전에 main이 50밀리초 넘게 멈추는 일이 생기면 그사이 첫 작업이 끝나 큐가 줄어들 수 있으므로, queued는 더 작게 나올 수 있습니다. 그래도 pool이 1보다 커지는 일은 없습니다. 최대 수 100을 믿고 '바쁘면 스레드가 늘어 지연이 줄겠지'라고 기대했다면, 그 기대는 큐를 고르는 순간 이미 어긋난 것입니다.

새 작업을 받을 때 풀이 확인하는 순서

이 결과는 ThreadPoolExecutor가 새 작업의 자리를 찾는 순서에서 나옵니다. execute로 작업이 들어오면 풀은 공식 문서에 적힌 대로 다음 순서를 따릅니다.

  1. 스레드 수가 corePoolSize보다 적으면 새 코어 스레드를 만들어 그 작업을 맡깁니다. 놀고 있는 스레드가 있어도 새로 만듭니다.
  2. 스레드가 이미 corePoolSize개 이상이면 작업을 큐에 넣어 봅니다.
  3. 큐가 작업을 받지 못하면, 스레드 수가 maximumPoolSize보다 적을 때만 추가 스레드를 만들어 그 작업을 맡깁니다.
  4. 스레드 수도 maximumPoolSize에 이르렀으면 작업을 거부하고 거부 처리기(RejectedExecutionHandler)를 호출합니다.

눈여겨볼 곳은 3번입니다. 추가 스레드는 큐가 작업을 받지 못할 때, 즉 큐가 가득 찼을 때에만 만들어집니다. 앞 예제의 LinkedBlockingQueue는 용량을 주지 않으면 Integer.MAX_VALUE개까지 받으므로 사실상 가득 차지 않습니다. 그래서 2번에서 언제나 성공하고 3번에는 도달하지 못합니다. 공식 문서도 무제한 큐를 쓰면 코어 수보다 많은 스레드는 만들어지지 않으며 maximumPoolSize는 아무 효과가 없다고 설명합니다.

이 순서는 비용이 싼 수단부터 쓴다고 생각하면 이해하기 쉽습니다. 스레드는 만들 때 시간이 들고, 만든 뒤에도 스택 메모리를 차지하며, 많아질수록 문맥 전환 비용이 늘어납니다. 그래서 풀은 평소에 일할 코어 스레드를 두고, 잠깐 몰린 작업은 큐에 줄을 세워 흡수합니다. 큐로도 감당하지 못할 때 비로소 추가 스레드를 만들고, 그마저 한도에 이르면 더 받지 않습니다. 그러니 maximumPoolSize는 '바쁠 때 늘리는 목표치'가 아니라 '큐가 넘친 뒤에 꺼내 쓰는 여유분'으로 읽어야 합니다.

추가 스레드는 일이 줄면 다시 사라집니다. 코어 수보다 많은 스레드가 keepAliveTime보다 오래 놀고 있으면 종료되어, 스레드 수가 코어 수로 돌아갑니다. 코어 스레드는 기본적으로 이 시간 제한을 받지 않고 계속 남아 있습니다.

작업 여섯 개로 순서 따라가기

이 순서를 눈으로 확인해 보겠습니다. 다음 풀은 코어 스레드 2개, 최대 스레드 4개에 용량이 2인 ArrayBlockingQueue를 씁니다. 모든 작업은 CountDownLatch가 풀릴 때까지 기다리므로, release.countDown()을 호출하기 전에는 어떤 작업도 끝나지 않습니다. 작업이 중간에 끝나 자리가 비는 일이 없으니, 제출할 때마다 풀이 어느 단계를 골랐는지가 출력에 그대로 드러납니다.

src/PoolSaturationSteps.java
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);
    }
}

실행 결과는 다음과 같습니다. 줄마다 해당 작업을 제출한 직후의 스레드 수와 큐에 쌓인 작업 수가 나옵니다. 제출할 때마다 늘어나는 쪽이 pool인지 queued인지를 차례로 따라가 보세요.

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

각 제출에서 풀이 확인한 조건과 고른 단계를 표로 정리하면 다음과 같습니다.

제출확인한 조건고른 단계poolqueued
1스레드 0개, 코어 수 2보다 적음코어 스레드 생성10
2스레드 1개, 코어 수 2보다 적음코어 스레드 생성20
3코어 수에 도달, 큐에 빈칸 있음큐에 넣음21
4큐에 빈칸 있음큐에 넣음22
5큐 가득 참, 스레드 2개로 최대 4보다 적음추가 스레드 생성32
6큐 가득 참, 스레드 3개로 최대 4보다 적음추가 스레드 생성42

핵심 2·큐 2·최대 4에서 여섯 번의 제출

래치로 작업 완료를 막고 여섯 submit이 core 2→queue 2→max 4를 만듭니다. 고정된 작업자/큐의 각 접수 변화가 명확한 실행 추적입니다.

핵심 2·큐 2·최대 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 < 2?core 미만핵심 생성task 1·2큐 여유?여유큐 적재task 3·4max < 4?max 미만추가 생성task 5·6max 도달거부 조건7번째 가정
핵심 작업자 다음에 큐를 먼저 채운다래치를 풀기 전 작업이 끝나지 않는 조건이다. 새 제출은 먼저 core 2 미만인지, 다음 큐 2칸에 여유가 있는지, 그다음 max 4 미만인지 검사한다. task 1·2는 핵심 작업자, 3·4는 큐, 5·6은 추가 작업자를 선택한다. 같은 포화 상태의 7번째 거부는 원 main에 없는 조건 확장이며 관측 출력이 아니다.예아니오래치 해제 전새 작업 제출core < 2?core 미만핵심 생성task 1·2큐 여유?여유큐 적재task 3·4max < 4?max 미만추가 생성task 5·6max 도달거부 조건7번째 가정

래치를 풀기 전 작업이 끝나지 않는 조건이다. 새 제출은 먼저 core 2 미만인지, 다음 큐 2칸에 여유가 있는지, 그다음 max 4 미만인지 검사한다. task 1·2는 핵심 작업자, 3·4는 큐, 5·6은 추가 작업자를 선택한다. 같은 포화 상태의 7번째 거부는 원 main에 없는 조건 확장이며 관측 출력이 아니다.

여섯 작업의 실제 수용 위치

여섯 작업의 실제 수용 위치래치를 풀기 전에는 작업이 끝나지 않는다. core 작업자에는 1·2, 큐에는 3·4, 큐가 찬 뒤 추가 작업자에는 5·6이 배정된다. 7번째 거부는 실행하지 않은 조건 확장이다.작업자 4명 · core 2 + 추가 2큐 2칸 · task 3, 4core 1task 1core 2task 2추가 1task 5추가 2task 6큐 앞칸task 3큐 뒤칸task 4
여섯 작업의 실제 수용 위치래치를 풀기 전에는 작업이 끝나지 않는다. core 작업자에는 1·2, 큐에는 3·4, 큐가 찬 뒤 추가 작업자에는 5·6이 배정된다. 7번째 거부는 실행하지 않은 조건 확장이다.작업자 4명 · core 2 + 추가 2큐 2칸 · task 3, 4core 1task 1core 2task 2추가 1task 5추가 2task 6큐 앞칸task 3큐 뒤칸task 4

래치를 풀기 전에는 작업이 끝나지 않는다. core 작업자에는 1·2, 큐에는 3·4, 큐가 찬 뒤 추가 작업자에는 5·6이 배정된다. 7번째 거부는 실행하지 않은 조건 확장이다.

상태별 상세 해설
핵심 2·큐 2·최대 4에서 여섯 번의 제출
제출선택된 수용 경로poolqueued
task=1핵심 작업자 생성10
task=2핵심 작업자 생성20
task=3큐에 적재21
task=4큐에 적재22
task=5최대값 범위에서 작업자 추가32
task=6최대값 범위에서 작업자 추가42
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에 배정합니다.

  • task1: pool1,queued0
  • task2: pool2,queued0
  • 핵심 수 2까지는 직접 배정합니다.
큐 채움

task 3과 4를 큐에 넣습니다.

  • task3: pool2,queued1
  • task4: pool2,queued2
  • 핵심이 찬 뒤 큐의 두 칸을 먼저 사용합니다.
최대 worker 확장

task 5와 6으로 worker를 4까지 늘립니다.

  • task5: pool3,queued2
  • task6: pool4,queued2
  • 큐가 찬 상태에서 최댓값 범위 확장을 선택하며 일곱 번째는 이 조건에서 거부됩니다.

핵심 2·큐 2·최대 4에서 여섯 번의 제출

래치로 작업 완료를 막고 여섯 submit이 core 2→queue 2→max 4를 만듭니다. 고정된 작업자/큐의 각 접수 변화가 명확한 실행 추적입니다.

핵심 2·큐 2·최대 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 < 2?core 미만핵심 생성task 1·2큐 여유?여유큐 적재task 3·4max < 4?max 미만추가 생성task 5·6max 도달거부 조건7번째 가정
핵심 작업자 다음에 큐를 먼저 채운다래치를 풀기 전 작업이 끝나지 않는 조건이다. 새 제출은 먼저 core 2 미만인지, 다음 큐 2칸에 여유가 있는지, 그다음 max 4 미만인지 검사한다. task 1·2는 핵심 작업자, 3·4는 큐, 5·6은 추가 작업자를 선택한다. 같은 포화 상태의 7번째 거부는 원 main에 없는 조건 확장이며 관측 출력이 아니다.예아니오래치 해제 전새 작업 제출core < 2?core 미만핵심 생성task 1·2큐 여유?여유큐 적재task 3·4max < 4?max 미만추가 생성task 5·6max 도달거부 조건7번째 가정

래치를 풀기 전 작업이 끝나지 않는 조건이다. 새 제출은 먼저 core 2 미만인지, 다음 큐 2칸에 여유가 있는지, 그다음 max 4 미만인지 검사한다. task 1·2는 핵심 작업자, 3·4는 큐, 5·6은 추가 작업자를 선택한다. 같은 포화 상태의 7번째 거부는 원 main에 없는 조건 확장이며 관측 출력이 아니다.

여섯 작업의 실제 수용 위치

여섯 작업의 실제 수용 위치래치를 풀기 전에는 작업이 끝나지 않는다. core 작업자에는 1·2, 큐에는 3·4, 큐가 찬 뒤 추가 작업자에는 5·6이 배정된다. 7번째 거부는 실행하지 않은 조건 확장이다.작업자 4명 · core 2 + 추가 2큐 2칸 · task 3, 4core 1task 1core 2task 2추가 1task 5추가 2task 6큐 앞칸task 3큐 뒤칸task 4
여섯 작업의 실제 수용 위치래치를 풀기 전에는 작업이 끝나지 않는다. core 작업자에는 1·2, 큐에는 3·4, 큐가 찬 뒤 추가 작업자에는 5·6이 배정된다. 7번째 거부는 실행하지 않은 조건 확장이다.작업자 4명 · core 2 + 추가 2큐 2칸 · task 3, 4core 1task 1core 2task 2추가 1task 5추가 2task 6큐 앞칸task 3큐 뒤칸task 4

래치를 풀기 전에는 작업이 끝나지 않는다. core 작업자에는 1·2, 큐에는 3·4, 큐가 찬 뒤 추가 작업자에는 5·6이 배정된다. 7번째 거부는 실행하지 않은 조건 확장이다.

상태별 상세 해설
핵심 2·큐 2·최대 4에서 여섯 번의 제출
제출선택된 수용 경로poolqueued
task=1핵심 작업자 생성10
task=2핵심 작업자 생성20
task=3큐에 적재21
task=4큐에 적재22
task=5최대값 범위에서 작업자 추가32
task=6최대값 범위에서 작업자 추가42
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에 배정합니다.

  • task1: pool1,queued0
  • task2: pool2,queued0
  • 핵심 수 2까지는 직접 배정합니다.
큐 채움

task 3과 4를 큐에 넣습니다.

  • task3: pool2,queued1
  • task4: pool2,queued2
  • 핵심이 찬 뒤 큐의 두 칸을 먼저 사용합니다.
최대 worker 확장

task 5와 6으로 worker를 4까지 늘립니다.

  • task5: pool3,queued2
  • task6: pool4,queued2
  • 큐가 찬 상태에서 최댓값 범위 확장을 선택하며 일곱 번째는 이 조건에서 거부됩니다.

세 번째와 네 번째 제출에서 스레드가 늘지 않았다는 점이 중요합니다. 스레드가 2개뿐이고 4개까지 만들 수 있는데도, 풀은 큐에 빈칸이 있는 동안에는 큐를 먼저 씁니다. 큐의 두 칸이 모두 찬 다섯 번째 제출에서야 추가 스레드가 생깁니다. 래치 덕분에 어떤 작업도 끝나지 않으므로, 이 여섯 줄은 여러 번 실행해도 언제나 같았습니다.

이 상태에서 작업을 하나 더 내면 어떻게 될까요? 반복 조건을 i <= 7로 바꿔 실행하면, 여섯 줄이 출력된 다음 일곱 번째 execute에서 예외가 납니다.

Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task PoolSaturationSteps$$Lambda/0x00007f6e4c000a08@372f7a8d rejected from java.util.concurrent.ThreadPoolExecutor@3d4eac69[Running, pool size = 4, active threads = 4, queued tasks = 2, completed tasks = 0]
	at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2081)
	at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:841)
	at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1376)
	at PoolSaturationSteps.main(PoolSaturationSteps.java:16)

스레드 4개가 모두 래치에서 기다리고 큐의 두 칸도 찼으므로 4번 단계, 즉 거부로 넘어간 것입니다. 거부 처리기를 따로 주지 않으면 AbortPolicy가 쓰이고, 이 처리기는 RejectedExecutionException을 던집니다. 예외 메시지의 대괄호 안에는 거부 순간의 풀 상태가 찍혀 있어서 원인을 짐작하는 데 도움이 됩니다. Lambda/0x...와 @ 뒤의 16진수는 실행할 때마다 달라지고, 줄 번호는 JDK 버전에 따라 다를 수 있습니다.

이 변형은 예외 때문에 main이 release.countDown()에 이르지 못합니다. 래치에서 기다리는 스레드 4개가 그대로 남으므로 프로그램이 끝나지 않으니, 터미널에서 Ctrl+C로 멈추세요. 거부된 작업을 어떻게 다룰지는 다음 글에서 자세히 봅니다.

나중에 낸 작업이 먼저 실행된다

표를 다시 보면 작업 3과 4는 큐에서 기다리는데, 그보다 늦게 제출한 작업 5와 6은 새로 만든 추가 스레드가 곧바로 맡았습니다. 새 스레드는 큐에서 작업을 꺼내는 것이 아니라, 자신을 만들게 한 그 작업부터 실행하기 때문입니다. 작업이 시작될 때 번호와 스레드 이름을 출력하도록 바꿔서 확인해 보겠습니다.

lab/PoolStartOrder.java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public final class PoolStartOrder {
    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));
        for (int i = 1; i <= 6; i++) {
            int id = i;
            executor.execute(() -> {
                System.out.println("start " + id + " on " + Thread.currentThread().getName());
                try { release.await(); }
                catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            });
        }
        Thread.sleep(200);
        System.out.println("-- release");
        release.countDown();
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.SECONDS);
    }
}

한 번 실행한 결과입니다. -- release 줄을 기준으로 위쪽은 래치가 풀리기 전에 시작한 작업이고, 아래쪽은 그 뒤에 시작한 작업입니다.

start 6 on pool-1-thread-4
start 1 on pool-1-thread-1
start 5 on pool-1-thread-3
start 2 on pool-1-thread-2
-- release
start 3 on pool-1-thread-1
start 4 on pool-1-thread-3

래치가 풀리기 전에 시작한 작업은 1, 2, 5, 6입니다. 작업 3과 4는 래치가 풀린 뒤 어떤 스레드가 첫 작업을 끝내고 큐에서 꺼내 갔을 때 비로소 시작합니다. 처음 네 줄의 순서와 3, 4를 맡는 스레드 이름은 실행할 때마다 달라질 수 있고, 이 실행에서는 작업 6이 가장 먼저 출력되었습니다.

하지만 3과 4가 -- release 뒤에 나온다는 점은 바뀌지 않습니다. 앞 작업이 끝나야 큐에서 나올 수 있고, 앞 작업은 래치가 풀려야 끝나기 때문입니다. Thread.sleep(200)은 5와 6의 출력이 -- release보다 먼저 나올 시간을 주려고 넣었을 뿐, 그 순서를 정해 주지는 않습니다.

즉 스레드 풀은 제출한 순서대로 실행하겠다고 약속하지 않습니다. 실행 순서가 중요하다면 Executors.newSingleThreadExecutor()처럼 작업을 한 번에 하나씩 차례로 실행한다고 문서에 명시된 실행기를 쓰거나, 순서를 작업 쪽에서 직접 맞춰야 합니다.

큐 종류에 따라 달라지는 동작

풀의 2번 단계가 성공하느냐는 전적으로 큐에 달려 있습니다. 그래서 corePoolSize와 maximumPoolSize가 같아도 어떤 큐를 넘기느냐에 따라 풀의 성격이 완전히 바뀝니다. 공식 문서는 큐를 쓰는 방식을 세 가지로 나눕니다.

큐저장할 수 있는 작업추가 스레드가 생기는 때주의할 점
SynchronousQueue (직접 넘기기)없음, 놀고 있는 스레드에게 바로 넘김넘겨받을 스레드가 없을 때마다최대 수가 크면 스레드가 끝없이 늘 수 있음
용량을 정하지 않은 LinkedBlockingQueue사실상 무제한생기지 않음큐가 끝없이 길어질 수 있고 최대 수는 의미가 없음
ArrayBlockingQueue 같은 유한 큐정한 용량만큼큐가 가득 찼을 때가득 차면 거부가 생기고 크기를 정하기 어려움

SynchronousQueue는 생산자·소비자 글에서 잠깐 본 것처럼 칸이 하나도 없는 큐입니다. 그래서 큐에 넣는 시도는 그 순간 작업을 기다리는 스레드가 있을 때만 성공하고, 풀의 2번 단계는 '놀고 있는 스레드에게 직접 넘기기'가 됩니다. 넘겨받을 스레드가 없으면 곧바로 3번 단계로 가서 스레드를 새로 만들기 때문에, 이 큐는 최대 수를 넉넉히 줄 때 제대로 동작합니다. 반대로 유한 큐는 스레드 수와 대기 작업 수에 모두 상한을 두는 방법이지만, 상한에 닿으면 거부가 생기므로 그 처리 방법을 함께 정해야 합니다.

고정 풀과 캐시 풀

Executors의 팩터리 메서드가 만드는 풀도 결국 이 조합 가운데 하나입니다. JDK 21에서 두 팩터리가 만든 풀의 설정을 getter로 읽어 보면 다음과 같습니다.

팩터리 메서드corePoolSizemaximumPoolSizekeepAliveTime큐
newFixedThreadPool(n)nn0용량을 정하지 않은 LinkedBlockingQueue
newCachedThreadPool()0Integer.MAX_VALUE60초SynchronousQueue

고정 풀(fixed thread pool)은 스레드를 n개로 고정하고 나머지 작업을 무제한 큐에 쌓습니다. 캐시 풀(cached thread pool)은 작업을 쌓아 두지 않습니다. 놀고 있는 스레드가 없으면 스레드를 새로 만들고, 60초 동안 일이 없는 스레드는 정리합니다. 같은 부하를 두 풀에 주면 차이가 분명하게 보입니다. 아래 프로그램은 20밀리초씩 걸리는 작업 20개를 각 풀에 제출한 직후의 상태를 출력합니다.

src/PoolStrategySnapshot.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;

public final class PoolStrategySnapshot {
    static void print(String name, 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());
    }
}

실행 결과는 다음과 같습니다. 두 풀에 똑같이 작업 20개를 넣었으니, 줄마다 pool과 queued 값을 나란히 보면 각 풀이 작업을 스레드와 큐 가운데 어디로 보냈는지 알 수 있습니다.

fixed pool=2 queued=18
cached pool=20 queued=0

고정 풀은 스레드 2개가 처음 두 작업을 맡고 나머지 18개를 큐에 쌓았습니다. 캐시 풀은 제출할 때마다 넘겨받을 스레드가 없었기 때문에 작업 20개에 스레드 20개를 만들었습니다. 이 환경에서는 여러 번 실행해도 같았지만, 제출하는 동안 앞 작업이 끝나면 고정 풀의 queued나 캐시 풀의 pool이 더 작게 나올 수 있습니다. 캐시 풀의 queued=0은 실행 중인 작업이 없다는 뜻이 아니라, 칸이 없는 SynchronousQueue의 size()가 언제나 0이라는 뜻입니다. 캐시 풀이 얼마나 바쁜지는 스레드 수로 봐야 합니다.

print 메서드는 풀 상태를 읽으려고 ExecutorService를 ThreadPoolExecutor로 형변환합니다. 두 팩터리가 실제로 ThreadPoolExecutor 객체를 돌려주기 때문에 동작하지만, 메서드의 반환 타입은 ExecutorService이므로 이 형변환은 구현에 기대는 실습용 코드입니다. 예를 들어 newSingleThreadExecutor()는 풀을 감싼 다른 객체를 돌려주므로 같은 형변환이 실패합니다.

두 풀의 위험은 정반대입니다. 고정 풀은 스레드 수를 일정하게 지키지만, 작업이 처리 속도보다 꾸준히 빨리 들어오면 큐가 끝없이 길어집니다. 큐가 길어진 만큼 나중에 온 작업은 오래 기다리고, 큐에 쌓인 작업 객체가 메모리를 채웁니다. 캐시 풀은 작업을 기다리게 하지 않는 대신 같은 상황에서 스레드를 끝없이 만듭니다. 플랫폼 스레드마다 스택 메모리가 필요하고, 늘어난 스레드가 한꺼번에 데이터베이스나 외부 서비스로 몰려가 그쪽까지 무너뜨릴 수 있습니다. 공식 문서도 두 방식 모두 작업이 처리 속도보다 평균적으로 빨리 들어오면 큐나 스레드가 끝없이 늘 수 있다고 경고합니다.

그래서 요청이 계속 들어오는 서버에서는 보통 유한 큐와 유한한 최대 수를 함께 쓰고, 둘 다 찼을 때의 거부 처리를 따로 정합니다. 네 가지 구성을 비교하면 다음과 같습니다.

구성막아 주는 것남는 위험
고정 풀과 무제한 큐스레드 수가 늘어나는 것큐가 길어지며 응답이 계속 늦어지고 메모리가 늘어남
캐시 풀작업이 큐에서 기다리는 것스레드와 외부 자원 사용이 끝없이 늘어남
유한 큐와 유한한 최대 수스레드 수와 대기 작업 수가 함께 늘어나는 것가득 차면 거부가 생기므로 처리 방법을 정해야 함
가상 스레드기다리는 동안 플랫폼 스레드를 붙잡는 비용데이터베이스 같은 외부 자원의 동시 사용은 따로 제한해야 함

더 깊이: 스레드가 생기고 사라지는 시점

코어 스레드는 풀을 만들 때 미리 생기지 않습니다. 여섯 작업 예제에서 첫 제출 뒤에 pool=1이 나온 것처럼, 작업이 들어올 때 하나씩 만들어집니다. 서버를 띄우자마자 첫 요청을 빠르게 받고 싶다면 prestartAllCoreThreads()로 코어 스레드를 미리 만들어 둘 수 있습니다.

execute가 큐에 작업을 넣을 때는 자리가 날 때까지 기다리는 put이 아니라 곧바로 결과를 돌려주는 offer를 씁니다. JDK 21의 ThreadPoolExecutor.execute를 보면 offer가 false를 돌려줄 때 추가 스레드 생성을 시도하고, 그것도 실패하면 거부합니다. 그래서 제출하는 스레드가 풀 때문에 막혀 기다리는 일은 없습니다. 가득 찼을 때 제출자를 기다리게 하려면 다른 방법이 필요하며, 다음 글에서 다룹니다.

코어 수를 0으로 두면 1번 단계는 언제나 건너뛰고 바로 큐에 넣습니다. 이때 풀에 스레드가 하나도 없으면 큐의 작업이 영영 실행되지 않으므로, execute는 큐에 넣은 뒤 스레드 수가 0인지 확인해 하나를 만듭니다. 캐시 풀은 이 구성에 SynchronousQueue를 붙여, 넘겨받을 스레드가 없을 때마다 큐가 거절하게 해서 스레드를 늘립니다. 그런데 new ThreadPoolExecutor(0, 10, 30, TimeUnit.SECONDS, new LinkedBlockingQueue<>())처럼 무제한 큐를 붙이면 큐가 거절하는 일이 없습니다. 이 구성으로 10밀리초짜리 작업 20개를 실행해 보면, 작업을 실행한 스레드는 pool-1-thread-1 하나뿐입니다.

keepAliveTime은 기본적으로 추가 스레드에만 적용됩니다. 코어 스레드도 일이 없으면 정리되게 하려면 allowCoreThreadTimeOut(true)를 호출하는데, 이때 keepAliveTime이 0이면 IllegalArgumentException이 납니다. 고정 풀처럼 keepAliveTime을 0으로 만든 풀에 이 메서드를 호출하면 'Core threads must have nonzero keep alive times'라는 메시지를 볼 수 있습니다.

풀 크기와 큐 길이를 정하는 방법

풀 크기를 정할 때는 작업 하나가 무엇을 붙잡고 있는지부터 봅니다. 작업마다 데이터베이스 연결을 하나씩 쓰는데 연결 풀에 연결이 10개뿐이라면, 작업을 동시에 30개 실행해도 20개는 연결을 기다리기만 합니다. 이렇게 기다리는 스레드는 처리량을 늘리지 못하고 메모리와 문맥 전환 비용만 씁니다. HTTP 클라이언트의 대상별 연결 수, 파일 핸들, 외부 API의 초당 호출 한도도 마찬가지입니다. 그래서 풀이 동시에 실행하는 작업 수는 이런 한도 가운데 가장 작은 것을 넘지 않게 잡습니다.

CPU 쪽에서 출발점을 잡는 방법은 컨텍스트 스위칭과 스레드 수에서 보았습니다. 계산만 하는 작업은 코어 수 근처에서 시작하고, 기다리는 시간이 섞인 작업은 계산 시간과 대기 시간의 비율에 따라 조금 더 많이 시작합니다. 하지만 이렇게 구한 값은 시험을 시작할 값일 뿐입니다. 실제 부하와 비슷한 조건에서 스레드 수를 바꿔 가며 처리량이 더 늘지 않는 지점, 응답 시간의 꼬리가 갑자기 길어지는 지점, 외부 자원에서 오류가 나기 시작하는 지점을 찾고, 그보다 여유 있게 정합니다.

큐는 짧은 순간의 몰림을 흡수할 뿐, 계속되는 과부하를 해결하지는 못합니다. 작업이 들어오는 속도가 처리 속도보다 오랫동안 빠르면 유한 큐는 결국 가득 차고, 무제한 큐는 메모리가 바닥날 때까지 길어집니다. 또 큐에서 기다린 시간도 사용자가 느끼는 응답 시간에 그대로 들어갑니다. 큐에서 3초를 기다린 뒤 0.1초 만에 끝난 요청은 사용자에게 3.1초짜리 요청입니다.

그래서 오래 기다린 작업은 실행하기 직전에 마감 시간이 지났는지 확인하고, 이미 의미가 없어졌다면 실행하지 않고 끝내는 편이 낫습니다. 응답을 기다리던 사용자가 이미 떠난 요청을 처리하느라 새 요청까지 늦어지는 일을 막을 수 있기 때문입니다.

응답 시간 특성이 다른 작업은 풀을 나누는 것이 좋습니다. 짧은 조회와 몇 분씩 걸리는 보고서 생성을 한 풀에 섞으면, 보고서 작업이 스레드를 모두 차지했을 때 조회까지 큐에서 기다려야 합니다. 작업 종류마다 실행기를 따로 두고 각자 용량과 거부 정책을 정하면, 한 종류의 작업이 느려져도 다른 종류의 응답 시간까지 잡아먹지 못합니다. 이렇게 나누는 목적은 스레드를 더 많이 만드는 것이 아니라 한쪽의 장애가 다른 쪽으로 번지지 않게 막는 데 있습니다.

조정할 때는 한 번에 한 가지 값만 바꾸고 같은 입력 분포로 전후를 비교합니다. 평균만 보면 짧은 작업들이 긴 작업의 악화를 가릴 수 있으므로, 큐에서 기다린 시간과 실행 시간을 나눠 백분위수로 기록합니다. 응답 시간을 짧은 것부터 늘어놓았을 때 50%, 95%, 99% 위치에 있는 값을 각각 p50, p95, p99라고 합니다. 활성 스레드 수가 최대에 붙어 있는 시간과 거부 비율도 함께 보면, 풀이 작은 것인지 풀이 기다리는 외부 자원이 느린 것인지 구별하기 쉬워집니다.

가상 스레드를 쓰면 기다리는 스레드의 비용은 크게 줄지만, 데이터베이스나 외부 서비스가 동시에 받아 줄 수 있는 요청 수는 그대로입니다. 플랫폼 스레드 풀의 크기가 하던 '동시 실행 수 제한' 역할이 사라지므로, 실제 병목 앞에 세마포어(Semaphore)나 크기가 정해진 연결 풀을 두어 허용량을 따로 정해야 합니다. 이 방법은 가상 스레드와 ScopedValue에서 다룹니다.

정리

  • execute는 코어 스레드 생성, 큐에 넣기, 추가 스레드 생성, 거부의 순서로 새 작업의 자리를 찾습니다. 추가 스레드는 큐가 가득 찼을 때만 생깁니다.
  • 용량을 정하지 않은 LinkedBlockingQueue를 쓰면 스레드는 코어 수를 넘지 않고, maximumPoolSize는 아무 효과가 없습니다.
  • SynchronousQueue는 작업을 저장하지 않고 넘기기만 하므로, 놀고 있는 스레드가 없으면 스레드가 늘어납니다. 캐시 풀이 이 구성입니다.
  • 고정 풀은 큐가, 캐시 풀은 스레드가 끝없이 늘 수 있습니다. 둘 다 막으려면 유한 큐와 유한한 최대 수를 쓰고 거부 처리를 정합니다.
  • 추가 스레드는 큐를 거치지 않고 자신을 만든 작업부터 실행하므로, 풀은 제출 순서대로 실행하지 않을 수 있습니다.
  • 풀 크기는 외부 자원의 한도와 측정 결과로 정하고, 큐에서 기다린 시간도 응답 시간에 포함해 생각합니다.

연습 문제

1. 다섯 번 제출했을 때의 상태 계산하기

모든 작업이 래치에서 계속 기다린다고 합시다. corePoolSize가 1, maximumPoolSize가 3이고 용량 1인 ArrayBlockingQueue를 쓰는 새 풀에 작업 다섯 개를 차례로 제출합니다. 제출할 때마다 (pool, queued)가 어떻게 바뀌는지, 몇 번째 제출에서 처음 거부되는지 구하세요.

정답과 해설

첫 제출은 스레드가 코어 수 1보다 적으므로 코어 스레드를 만들어 (1, 0)이 됩니다. 두 번째는 코어 수에 이르렀으니 큐에 넣어 (1, 1)입니다. 세 번째는 큐가 가득 차서 받지 못하므로 추가 스레드를 만들어 (2, 1)이 되고, 네 번째도 같은 이유로 (3, 1)이 됩니다. 다섯 번째는 큐도 가득 찼고 스레드도 최대인 3개이므로 거부되며, 상태는 (3, 1) 그대로입니다.

이 풀은 작업 네 개를 받아들였지만 동시에 실행하는 것은 세 개이고, 하나는 큐에서 기다립니다. 큐 용량은 대기 중인 작업 수이지 실행 중인 작업 수가 아니기 때문입니다. PoolSaturationSteps의 설정을 이렇게 바꾸고 다섯 번째 제출의 예외를 잡아 상태를 출력하도록 해 실행해 보면 위 계산과 같은 결과가 나옵니다.

2. 고정 풀의 최대 수를 늘리면

newFixedThreadPool(4)로 만든 풀을 ThreadPoolExecutor로 형변환해 setMaximumPoolSize(8)을 호출했습니다. 이제 작업이 몰리면 스레드가 8개까지 늘어날까요? 50밀리초짜리 작업 50개를 한꺼번에 제출한 직후의 스레드 수와 대기 작업 수를 예상해 보세요.

정답과 해설

늘어나지 않습니다. 최대 수는 8로 바뀌었지만 고정 풀의 큐는 용량을 정하지 않은 LinkedBlockingQueue이므로, 2번 단계에서 언제나 성공하고 추가 스레드를 만드는 3번 단계에 이르지 못합니다. 스레드 4개가 처음 네 작업을 맡고 나머지 46개는 큐에서 기다립니다.

exercise/FixedPoolMaximumSolution.java
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;

public final class FixedPoolMaximumSolution {
    public static void main(String[] args) {
        var pool = (ThreadPoolExecutor) Executors.newFixedThreadPool(4);
        pool.setMaximumPoolSize(8);
        for (int i = 0; i < 50; i++) {
            pool.execute(() -> {
                try { Thread.sleep(50); }
                catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            });
        }
        System.out.printf("max=%d pool=%d queued=%d%n",
                pool.getMaximumPoolSize(), pool.getPoolSize(), pool.getQueue().size());
        pool.shutdownNow();
    }
}
max=8 pool=4 queued=46

바쁠 때 스레드가 늘어나게 하려면 큐가 가득 찰 수 있어야 합니다. 고정 풀의 큐는 만든 뒤에 바꿀 수 없으므로, 용량을 정한 큐를 넘겨 ThreadPoolExecutor를 직접 만들어야 합니다.

3. 큐 용량의 첫 시험값 정하기

초당 80건을 처리하는 풀에서 큐 대기 시간을 0.5초 안팎으로 맞추려 합니다. 순간적으로 몰리는 양을 생각해 1.5배의 여유를 둔다면, 큐 용량을 얼마로 시작하면 될까요? 여유를 둔 대가도 함께 설명하세요.

정답과 해설

풀이 초당 80건을 처리하면 0.5초 동안 큐에서 40건이 빠져나갑니다. 따라서 용량이 40인 큐의 맨 끝에 선 작업은 대략 0.5초를 기다립니다. 여기에 여유 1.5배를 곱하면 시작값은 60입니다.

exercise/ExecutorQueueEstimateSolution.java
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));
    }
}
queue=60

여유를 두면 순간적으로 몰릴 때 거부가 줄어드는 대신, 큐 끝에 선 작업은 60 ÷ 80 = 0.75초 정도 기다릴 수 있습니다. 즉 이 값은 거부와 대기 시간 사이에서 고른 출발점이지, 모든 작업이 0.5초 안에 시작된다는 뜻이 아닙니다. 처리 속도가 일정하다는 가정도 들어 있으므로, 실제 작업 시간 분포와 거부율을 재 보며 조정해야 합니다. 작업이 들어오는 속도가 오랫동안 처리 속도보다 빠르다면 큐를 늘리는 것은 해결책이 아닙니다.

이 메서드를 범용 설정 검사에 쓰려면 검사를 더 보강해야 합니다. NaN은 <= 0 비교가 거짓이라 첫 검사를 통과하고, 너무 큰 값은 int 범위를 넘습니다.

다음 글

스레드 풀의 작업 거부 정책에서는 4번 단계, 즉 스레드와 큐가 모두 찼을 때 작업을 어떻게 처리할지 고르는 방법을 다룹니다.