한 줄 정의
스레드를 미리 생성해두고 작업 큐에서 태스크를 꺼내 실행하는 동시성 관리 패턴. 스레드 생성/소멸 비용을 제거하고, 동시에 실행되는 스레드 수를 제한하여 시스템 자원을 보호한다.
쉽게 말하면
콜센터의 상담원과 같습니다. 전화(요청)가 올 때마다 상담원(스레드)을 채용하고 해고하는 대신, 미리 고용해두고 재사용합니다. 상담원이 전부 통화 중이면 대기열(큐)에서 기다리고, 대기열마저 가득 차면 “나중에 다시 걸어주세요”(거부 정책)가 됩니다.
왜 이걸 알아야 하는가?
- 서버 성능 튜닝의 핵심:
Tomcat의
maxThreads, HikariCP의maximumPoolSize,@Async의 스레드 풀 — 서버 애플리케이션의 동시성은 대부분 스레드 풀 설정으로 결정됩니다. 이 값을 근거 없이 설정하면 풀 고갈(요청 대기)이나 자원 낭비(불필요한 컨텍스트 스위칭)가 발생합니다. - 장애 분석의 기본: “응답이 갑자기 느려졌다”는 장애의 상당수가 스레드 풀 고갈입니다. thread dump에서 풀의 모든 스레드가 WAITING/BLOCKED 상태라면 풀 사이즈가 아니라 블로킹 지점이 문제라는 것을 판단할 수 있어야 합니다.
- Virtual Thread 시대에도 여전히 필요: Java Virtual Thread가 등장했지만, 커넥션 풀(DB 연결 수 제한)이나 외부 API rate limit처럼 하위 자원에 상한이 있는 경우에는 여전히 동시성 제어가 필요합니다. “스레드를 무한히 만들 수 있다”는 것이 “무한히 만들어야 한다”는 뜻은 아닙니다.
왜 이렇게 설계했는가?
- 해결하는 문제: 요청마다 스레드를 생성하면 발생하는 두 가지 비용을 제거합니다. 첫째, 스레드 생성/소멸 자체의 오버헤드(커널 오브젝트 할당, 스택 메모리 할당)이고, 둘째, 스레드 수가 무한히 늘어나면서 발생하는 메모리 고갈과 스케줄러 부하입니다.
- 이게 없다면:
요청마다
new Thread()를 호출하는 방식이 됩니다. 트래픽이 급증하면 스레드가 수만 개 생성되고, 스레드당 1MB 스택 × 10,000개 = 10GB로 OOM이 발생합니다. 스레드 수가 많아지면 OS 스케줄러의 컨텍스트 스위칭 비용도 급격히 증가하여 실제 작업보다 스위칭에 CPU를 더 쓰게 됩니다. - 핵심 설계 원칙: 생성 비용 상각(amortize) — 스레드를 재사용하여 생성/소멸 비용을 요청 수만큼 나눕니다. 자원 상한(bounded) — 최대 스레드 수를 제한하여 시스템이 감당할 수 있는 범위 안에서 동작하도록 보장합니다.
어떻게 동작하는가?
기본 구조
스레드 풀은 세 가지 핵심 요소로 구성됩니다:
- Worker Thread: 미리 생성된 스레드들로, 작업이 없으면 큐에서 대기합니다.
- Task Queue (작업 큐): 실행할 작업(Runnable/Callable)이 대기하는 큐입니다. Worker가 모두 바쁘면 여기에 쌓입니다.
- 관리 정책: 스레드 수 조절, 큐가 가득 찼을 때의 거부 정책 등을 결정합니다.
Java의 ThreadPoolExecutor
Java에서 스레드 풀의 핵심 구현체는 ThreadPoolExecutor이며, 모든 Executors 팩토리 메서드가 내부적으로 이것을 사용합니다.
new ThreadPoolExecutor(
corePoolSize, // 기본 유지 스레드 수
maximumPoolSize, // 최대 스레드 수
keepAliveTime, // 초과 스레드의 유휴 생존 시간
unit, // keepAliveTime 단위
workQueue, // 작업 대기 큐
threadFactory, // 스레드 생성 방식
handler // 거부 정책
);작업 제출 시 동작 흐름:
- 현재 스레드 수 <
corePoolSize이면 새 스레드를 생성하여 작업을 실행합니다. - 현재 스레드 수 ≥
corePoolSize이면 작업을workQueue에 넣습니다. workQueue가 가득 찼고 현재 스레드 수 <maximumPoolSize이면 새 스레드를 생성합니다.workQueue가 가득 찼고 현재 스레드 수 =maximumPoolSize이면RejectedExecutionHandler가 호출됩니다.
거부 정책 (RejectedExecutionHandler):
| 정책 | 동작 | 용도 |
|---|---|---|
AbortPolicy (기본) | RejectedExecutionException 발생 | 명시적 에러 처리 |
CallerRunsPolicy | 제출한 스레드가 직접 실행 | 배압(backpressure) 효과 |
DiscardPolicy | 작업을 조용히 버림 | 유실 허용 작업 |
DiscardOldestPolicy | 큐의 가장 오래된 작업을 버리고 새 작업 삽입 | 최신 데이터 우선 |
Executors 팩토리 메서드
| 팩토리 메서드 | core | max | 큐 | 특성 |
|---|---|---|---|---|
newFixedThreadPool(n) | n | n | LinkedBlockingQueue (무한) | 스레드 수 고정, 큐가 무한히 쌓일 수 있음 |
newCachedThreadPool() | 0 | Integer.MAX_VALUE | SynchronousQueue | 유휴 60초 후 제거, 스레드 수 제한 없음 |
newSingleThreadExecutor() | 1 | 1 | LinkedBlockingQueue (무한) | 순서 보장, 단일 스레드 |
newScheduledThreadPool(n) | n | Integer.MAX_VALUE | DelayedWorkQueue | 지연/주기 실행 |
주의:
newFixedThreadPool과newCachedThreadPool은 각각 큐 무한 적재와 스레드 무한 생성의 위험이 있으므로, 운영 환경에서는ThreadPoolExecutor를 직접 생성하여 큐 크기와 최대 스레드 수를 명시적으로 설정하는 것이 안전합니다.
스레드 풀 사이징 (Sizing)
스레드 풀의 최적 크기는 워크로드 특성에 따라 완전히 달라집니다.
CPU-bound 작업: 스레드가 대부분의 시간을 연산에 사용하므로, 코어 수보다 많은 스레드는 컨텍스트 스위칭만 유발합니다. 최적 스레드 수 ≈ CPU 코어 수 (또는 코어 수 + 1, 페이지 폴트 등 우발적 블로킹 대비).
I/O-bound 작업: 스레드가 대부분의 시간을 I/O 대기에 쓰므로, 대기 시간 동안 다른 스레드가 CPU를 사용할 수 있습니다. 최적 스레드 수 ≈ 코어 수 × (1 + 대기 시간 / 연산 시간).
Little’s Law를 활용한 사이징:
시스템이 안정 상태일 때 동시 요청 수(L) = 처리량(λ) × 평균 응답 시간(W)이 성립합니다.
목표 처리량이 초당 1,000건이고 평균 응답 시간이 200ms라면, 필요한 동시 스레드 수 = 1,000 × 0.2 = 200개입니다.
다만 이 공식은 이상적인 조건을 가정하므로, 실제로는 부하 테스트를 통해 검증하고 여유를 두는 것이 필수입니다.
사이징에서 흔한 실수:
- “스레드를 많이 만들면 빨라진다”는 착각입니다. CPU-bound 작업에서 코어 수의 10배 스레드를 만들면 오히려 컨텍스트 스위칭으로 성능이 떨어집니다.
- DB 커넥션 풀 크기를 무시하고 스레드 풀만 늘리면, 스레드 200개가 커넥션 10개를 놓고 경쟁하며 대부분의 시간을 lock 대기에 씁니다.
시각화
스레드 풀 동작 구조
graph LR subgraph Clients["요청"] R1["Task 1"] R2["Task 2"] R3["Task 3"] R4["Task 4"] end subgraph Queue["작업 큐"] Q["Task 3 → Task 4"] end subgraph Pool["스레드 풀 (max=2)"] T1["Worker 1: Task 1 실행 중"] T2["Worker 2: Task 2 실행 중"] end R1 --> T1 R2 --> T2 R3 --> Q R4 --> Q Q --> T1 Q --> T2
ThreadPoolExecutor 작업 제출 흐름
flowchart TD Submit["작업 제출"] --> CheckCore{"스레드 수 < corePoolSize?"} CheckCore -->|Yes| NewCore["새 스레드 생성 → 실행"] CheckCore -->|No| CheckQueue{"큐에 공간 있음?"} CheckQueue -->|Yes| Enqueue["큐에 작업 추가"] CheckQueue -->|No| CheckMax{"스레드 수 < maximumPoolSize?"} CheckMax -->|Yes| NewMax["새 스레드 생성 → 실행"] CheckMax -->|No| Reject["거부 정책 실행"]
정리
| 기준 | FixedThreadPool | CachedThreadPool | Virtual Thread Executor |
|---|---|---|---|
| 스레드 수 | 고정 | 무제한 (자동 확장/축소) | 작업당 1개 (자동 생성) |
| 큐 | 무한 LinkedBlockingQueue | SynchronousQueue (큐 없음) | 없음 (즉시 실행) |
| 위험 | 큐 무한 적재 → 메모리 | 스레드 무한 생성 → OOM | 하위 자원 고갈 (DB 커넥션 등) |
| 적합한 상황 | 예측 가능한 부하 | 짧은 비동기 작업 | I/O-bound 대량 동시성 |
| 스레드 비용 | 높음 (1MB/스레드) | 높음 (1MB/스레드) | 낮음 (수 KB/스레드) |
실무에서 만난 사례
- Tomcat 스레드 풀:
기본
maxThreads=200으로, 요청마다 풀에서 스레드 하나를 꺼내 할당하는 thread-per-request 모델입니다. DB 쿼리가 느려져서 스레드 200개가 전부 JDBC 응답 대기 중이면 201번째 요청부터 큐에 쌓이고, 이것이 “스레드 풀 고갈” 장애의 전형적인 패턴입니다. - HikariCP 커넥션 풀과의 관계:
Tomcat 스레드 200개인데 HikariCP
maximumPoolSize=10이면, 동시에 DB를 쓸 수 있는 스레드는 10개뿐이고 나머지 190개는 커넥션 획득 대기에 묶입니다. 스레드 풀과 커넥션 풀의 크기는 함께 조율해야 합니다. - Spring @Async의 기본 풀:
@Async의 기본 executor는SimpleAsyncTaskExecutor로, 호출마다 새 스레드를 생성하여 풀이 아닙니다. 운영 환경에서는 반드시ThreadPoolTaskExecutor를 빈으로 등록하고 풀 크기를 설정해야 합니다.
관련 개념
출처
- Brian Goetz, “Java Concurrency in Practice” Chapter 8: Applying Thread Pools
- Doug Lea, “A Java Fork/Join Framework”
- HikariCP Wiki: About Pool Sizing