실무에서는 트래픽 패턴에 맞는 적절한 스레드 풀 전략을 선택해야 한다. Java는 Executors를 통해 세 가지 기본 전략을 제공하며, 각각 장단점이 명확하다
기본 전략 개요
단일 스레드 풀 (Single Thread Pool)
ExecutorService es = Executors.newSingleThreadPool();
// 내부 구현
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>())
특징
- 스레드 1개만 사용
- 큐 사이즈 무제한 (
LinkedBlockingQueue) - 간단한 테스트나 순차 처리용
고정 풀 전략 (Fixed Thread Pool)
ExecutorService es = Executors.newFixedThreadPool(2);
// 내부 구현
new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>())
핵심 특징
corePoolSize = maximumPoolSize(초과 스레드 없음)- 큐 사이즈 무제한
- 스레드 수 고정 → CPU/메모리 사용량 예측 가능
동작 방식
ExecutorService es = Executors.newFixedThreadPool(2);
for (int i = 1; i <= 6; i++) {
String taskName = "task" + i;
es.execute(new RunnableTask(taskName));
printState(es, taskName);
}
실행 흐름
task1 → [pool=1, active=1, queuedTasks=0] // 스레드1 생성 task2 → [pool=2, active=2, queuedTasks=0] // 스레드2 생성 (고정 수 도달) task3 → [pool=2, active=2, queuedTasks=1] // 큐에 저장 task4 → [pool=2, active=2, queuedTasks=2] // 큐에 저장 task5 → [pool=2, active=2, queuedTasks=3] // 큐에 저장 task6 → [pool=2, active=2, queuedTasks=4] // 큐에 저장
장점 – 안정성
- 리소스 사용량 예측 가능
- 시스템 과부하 방지
- 큐 사이즈 무제한으로 모든 요청 수용
단점과 위험 시나리오
상황 1 – 점진적 상용자 증가
- 사용자 증가 → 큐에 작업 누적 → 응답 시간 증가
- CPU/메모리는 여유 있지만 상용자는 느린 응답 경험
상황 2 – 급격한 트래픽 증가
- 이벤트 성공 → 요청 폭증 → 큐에 수만 건 적체
- 처리 시간: 큐에 10,000건 / 10개 스레드 * 1초 = 1,000초 대기
- 마지막 사용자는 16분 이상 대기하는 상황 발생
문제의 핵심
- 큐에 작업이 쌓이는 속도 > 처리 속도
- 결과: 서버는 안정적이지만 사용자 경험 약화
캐시 풀 전략 (Cached Thread Pool)
ExecutorService es = Executors.newCachedThreadPool();
// 내부 구현
new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>())
핵심 특징
corePoolSize = 0(기본 스레드 없음)maximumPoolSize = Integer.MAX_VALUE(초과 스레드 무제한)SynchronousQueue사용 (버퍼 크기 0)
SynchronousQueue의 특별한 메커니즘
일반 큐 vs SynchronousQueue
- 일반 큐: 생산자 → [버퍼에 저장] → 소비자가 꺼냄
- SynchronousQueue: 생산자 → (대기) → 소비자 직접 전달 (직거래)
- 버퍼 크기 = 0, 생산자-소비자 동기화
동작 원리
// 생산자(메인)가 작업 제출 es.execute(task1); // → 큐에 넣을 수 없음 (크기 0) // → 즉시 새 스레드 생성 // → 스레드가 task1 직접 받아서 실행
실행 예제
ThreadPoolExecutor es = new ThreadPoolExecutor(0, Integer.MAX_VALUE,
3, TimeUnit.SECONDS, new SynchronousQueue<>());
for (int i = 1; i <= 4; i++) {
es.execute(new RunnableTask("task" + i));
printState(es, "task" + i);
}
실행 결과
task1 → [pool=1, active=1, queuedTasks=0] // 스레드1 생성 task2 → [pool=2, active=2, queuedTasks=0] // 스레드2 생성 task3 → [pool=3, active=3, queuedTasks=0] // 스레드3 생성 task4 → [pool=4, active=4, queuedTasks=0] // 스레드4 생성 // 3초 후 (keepAliveTime 초과) [pool=0, active=0, queuedTasks=0, completedTasks=4] // 모든 초과 스레드 제거
스레드 생성 메커니즘
왜 기본 스레드 없이 초과 스레드만 생성되나?
- 작업 요청 →
corePoolSize(0)까지 스레드 생성 → 0개이므로 스킵 - core 초과 → 큐에 저장 시도 →
SynchronousQueue는 버퍼 크기 0 → 큐에 넣기 실패 - 큐 초과 → maximumPoolSize까지 초과 스레드 생성 →
Integer.MAX_VALUE이므로 무제한 생성 가능 - 결과: 모든 작업이 즉시 새 스레드 생성하여 처리
장점 – 빠른 응답
- 큐 대기 없음 → 즉시 처리
- 트래픽 변화에 유연하게 대응
- CPU/메모리 최대 활용
- 자동 확장 / 축소
- 요청 증가 → 스레드 증가 (최대 활용)
- 요청 감소 → 60초 후 스레드 제거 (자원 절약)
위험 시나리오
상황 1 – 점진적 증가
- 사용자 증가 → 스레드 증가 → CPU/메모리 사용량 증가
- 모니터링으로 감지 → 시스템 증설 (정상 대응)
상황 2 – 급격한 폭증 (치명적)
- 이벤트 → 요청 폭증 → 스레드 수천 개 생성 → CPU 100%, 메모리 고갈 → 컨텍스트 스위칭 오버헤드 증가 → 시스템 다운
- 메모리 계산
- 스레드 1개 = 최소 1MB 메모리
- 1,000개 스레드 = 1GB 이상 메모리 → 힙 메모리 압박 + GC 부하 증가
사용자 풀 전략
전략 설계 – 3단계 대응 시스템
- 일반 상황: 고정 스레드로 안정적 운영
- 긴급 상황: 초과 스레드 투입으로 빠른 처리
- 거절 상황: 요청 거절로 시스템 보호
ExecutorService es = new ThreadPoolExecutor(
100, // corePoolSize: 기본 100개
200, // maximumPoolSize: 최대 200개
60, TimeUnit.SECONDS, // keepAliveTime: 초과 스레드 60초 생존
new ArrayBlockingQueue<>(1000) // 큐 크기 1,000개 (중요!)
);
파라미터 의미
- 기본 스레드: 100개 (일반 상황 처리)
- 초과 스레드: 100개 (긴급 상황 추가 투입)
- 큐 크기: 1,000개 (버퍼)
처리 가능 작업
- 최소: 100개 (기본 스레드만)
- 일반: 1,100개 (기본 100 + 큐 1,000)
- 긴급: 1,200개 (기본 100 + 큐 1,000 + 초과 100)
- 초과: 거절 (
RejectedExecutionException)
시나리오별 동작
일반 상황 (1,000개 이하)
static final int TASK_SIZE = 1100; // 실행 결과 [pool=100, active=100, queuedTasks=1000, completedTasks=0] time: 11초 (1,100 ÷ 100 스레드)
동작
- 처음 100개 작업 → 스레드 100개 생성
- 다음 1,000개 작업 → 큐에 저장
- 스레드 100개 안정적으로 처리
긴급 상황 (1,101 ~ 1,200개)
static final int TASK_SIZE = 1200; // 실행 결과 [pool=200, active=200, queuedTasks=1000, completedTasks=0] time: 6초 (1,200 ÷ 200 스레드)
동작
- 기본 스레드 100개 생성 → 작업 실행
- 큐 1,000개 가득 참
- 1,101째 작업 도착 → 큐 가득 참 = 긴급 상황 → 초과 스레드 생성 시작
- 최종 200개 스레드로 처리 (2배 빠름)
task1100 → [pool=100, active=100, queuedTasks=1000] task1101 → [pool=101, active=101, queuedTasks=1000] // 초과 스레드 시작! task1102 → [pool=102, active=102, queuedTasks=1000] task1200 → [pool=200, active=200, queuedTasks=1000]
거절 상황 (1,201개 이상)
static final int TASK_SIZE = 1201; // 실행 결과 task1200 → [pool=200, active=200, queuedTasks=1000] task1201 → RejectedExecutionException
거절 이유
- 처리 중: 200개 (최대 스레드)
- 큐 대기: 1,000개 (최대 큐)
- 총 처리 기능: 1,200개 → 1,201번째부터 거절
실무 팁
성능 테스트로 값 결정
// CPU 사용량 목표 설정 일반: CPU 50% → corePoolSize = 100 긴급: CPU 80% → maximumPoolSize = 200 // 부하 테스트 - 평상시 트래픽: 100 TPS → core = 100 - 이벤트 피크: 200 TPS → max = 200 - 큐 크기: 평균 응답시간 × TPS = 큐 크기
거절 처리
try {
es.execute(task);
} catch (RejectedExecutionException e) {
// 사용자에게 알림
return "서버가 혼잡합니다. 잠시 후 다시 시도해주세요.";
}
실무에서 자주 하는 실수
치명적 실수 – 무제한 큐 + 최대 스레드 설정
// 잘못된 설정
ExecutorService es = new ThreadPoolExecutor(
100,
200, // 절대 도달하지 않음!
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>() // 무제한 큐
);
문제점 – 작업 요청 흐름
- 100개까지 → 스레드 생성
- 101개부터 → 큐에 무제한 저장
- 큐가 절대 가득 차지 않음
- maximumPoolSize 200은 의미 없음
- 결과: 100개 스레드로만 모든 작업 처리
왜 초과 스레드가 생성 안 되나?
초과 스레드 생성 조건
- 큐가 가득 참 →
LinkedBlockingQueue()→ 무제한 큐 → 큐가 절대 가득 차지 않음 → 초과 스레드 생성 조건 충족 불가
올바른 설정
// 올바른 설정 new ArrayBlockingQueue<>(1000) // 명시적 크기 제한
전략 비교 및 선택 가이드
| 전략 | corePoolSize | maximumPoolSize | 큐 | 적합한 상황 |
| 고정 풀 | N | N | 무제한 | 안정적, 예측 가능한 트래픽 |
| 캐시 풀 | 0 | 무제한 | 0 | 짧고 가벼운 작업, 변동 큰 트래픽 |
| 사용자 정의 | N | M (>N) | 제한 | 실무 대부분 (권장) |
선택 기준
// 백그라운드 배치: 고정 풀
Executors.newFixedThreadPool(10);
// API 서버 (트래픽 변동): 사용자 정의
new ThreadPoolExecutor(100, 200, 60, SECONDS,
new ArrayBlockingQueue<>(1000));
// 짧은 비동기 작업: 캐시 풀 (주의)
Executors.newCachedThreadPool();