스레드를 직접 만드는 대신 작업(Runnable·Callable)을 제출하고 실행은 풀에 맡기는 구조.
왜 직접 만들지 않나
// ❌ 요청마다 스레드를 만든다
new Thread(() -> handle(req)).start();
- 생성·소멸 비용이 매번 든다 (OS 스레드는 비싸다)
- 개수에 상한이 없다 → 트래픽이 늘면 OOM: unable to create new native thread
- 우선순위·이름·예외 처리를 매번 챙겨야 한다
풀을 직접 구성한다
var pool = new ThreadPoolExecutor(
10, // corePoolSize
20, // maximumPoolSize
60L, TimeUnit.SECONDS, // 유휴 스레드 생존 시간
new ArrayBlockingQueue<>(500), // 유한 큐 ← 중요
new ThreadPoolExecutor.CallerRunsPolicy()); // 거부 정책
작업이 들어오면
- ① core 미만이면 새 스레드를 만든다
- ② core 가 찼으면 큐에 넣는다
- ③ 큐도 찼으면 max 까지 스레드를 늘린다
- ④ 그것도 안 되면 거부 정책을 실행한다
②가 ③보다 먼저라는 게 직관과 다르다 — 큐가 무한이면 스레드는 core 이상 절대 안 늘어난다.
Executors 팩토리의 함정
Executors.newFixedThreadPool(10); // 큐가 무한(LinkedBlockingQueue) → 쌓이다 OOM
Executors.newCachedThreadPool(); // 스레드 상한이 Integer.MAX_VALUE → 스레드 폭증
둘 다 상한이 없다. 그래서 실무에서는 ThreadPoolExecutor를 직접 구성하는 것이 권장된다.
거부 정책
- AbortPolicy(기본) — RejectedExecutionException 을 던진다
- CallerRunsPolicy — 제출한 스레드가 직접 실행한다 → 자연스러운 배압(backpressure)
- DiscardPolicy — 조용히 버린다
- DiscardOldestPolicy — 큐의 가장 오래된 것을 버리고 넣는다
CallerRunsPolicy 는 제출 속도를 늦춰 시스템이 스스로 브레이크를 잡게 한다 — 유실이 허용되지 않는 곳의 실용적 선택이다.
풀 크기
- CPU 바운드 — 코어 수 + 1 근처
- I/O 바운드 — 더 크게 — 대기 시간 비율만큼 늘린다
- (다만 가상 스레드가 있으면 이 계산 자체가 필요 없어진다)
결과를 조합할 때
CompletableFuture<A> a = CompletableFuture.supplyAsync(this::loadA, pool);
CompletableFuture<B> b = CompletableFuture.supplyAsync(this::loadB, pool);
a.thenCombine(b, this::merge).join(); // 둘을 병렬로 부르고 합친다
supplyAsync에 풀을 안 넘기면 공용 ForkJoinPool을 쓴다 — 코어 수 - 1 크기이고 다른 병렬 스트림과 공유되므로, 블로킹 작업을 넣으면 전체가 막힌다.
반드시 종료한다
pool.shutdown(); // 새 작업은 거부, 진행 중인 건 끝낸다
if (!pool.awaitTermination(30, TimeUnit.SECONDS))
pool.shutdownNow(); // 강제 중단
면접 함정
- ❌ "newFixedThreadPool이 안전하다" → 큐가 무한이라 메모리가 터진다.
- ❌ "풀 크기를 크게 하면 빨라진다" → 컨텍스트 스위칭이 늘어 어느 지점부터 느려진다.