실무에서 스레드 풀이 포화 상태가 되면 어떻게 대응할지 정책을 정해야 한다. ThreadPoolExecutor는 4가지 기본 거절 정책을 제공하며, 커스텀 정책도 구현 가능하다
거절 정책이 필요한 이유
거절 상황
// 큐도 가득 참 + 최대 스레드 수 도달 = 거절
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, // core
1, // max
0, TimeUnit.SECONDS,
new SynchronousQueue<>() // 버퍼 크기 0
);
executor.submit(task1); ✅ 처리
executor.submit(task2); ❌ 거절 (RejectedExecutionException)
거절 발생 조건
- 스레드 풀이 최대 크기에 도달
- 작업 큐가 가득 참
- 더 이상 작업을 받을 수 없음
대응 필요성
- 개발자 관점: 로그 기록, 모니터링, 알람
- 사용자 관점: 적절한 에러 메시지, 재시도 유도
- 시스템 관점: 시스템 다운 방지
RejectedExecutionHandler 인터페이스
모든 거절 정책의 기반
public interface RejectedExecutionHandler {
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}
동작 방식
작업 거절 발생 → ThreadPoolExecutor가 rejectedExecution() 호출 → 설정된 정책에 따라 처리
AbortPolicy (기본 정책)
예외를 발생시켜 거절을 명시적으로 알림
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.SECONDS,
new SynchronousQueue<>(),
new ThreadPoolExecutor.AbortPolicy() // 기본값 (생략 가능)
);
executor.submit(new RunnableTask("task1"));
try {
executor.submit(new RunnableTask("task2"));
} catch (RejectedExecutionException e) {
log("요청 초과 -> " + e);
// 포기, 재시도, 사용자 알림 등 처리
}
실행 결과
[pool-1-thread-1] task1 시작
[ main] 요청 초과
[ main] RejectedExecutionException: Task ... rejected from
ThreadPoolExecutor[Running, pool size = 1, active threads = 1, ...]
내부 구현
public static class AbortPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
throw new RejectedExecutionException(
"Task " + r.toString() + " rejected from " + e.toString()
);
}
}
활용
try {
executor.submit(task);
} catch (RejectedExecutionException e) {
// 1. 사용자에게 알림
return "서버 혼잡. 잠시 후 다시 시도해주세요.";
// 2. 재시도 큐에 저장
retryQueue.offer(task);
// 3. 대체 서버로 전달
backupExecutor.submit(task);
// 4. 로그 및 모니터링
log.error("Task rejected", e);
metrics.incrementRejectionCount();
}
DiscardPolicy (조용히 버림)
예외 없이 조용히 작업을 버림
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.SECONDS,
new SynchronousQueue<>(),
new ThreadPoolExecutor.DiscardPolicy()
);
executor.submit(new RunnableTask("task1"));
executor.submit(new RunnableTask("task2")); // 조용히 버려짐
executor.submit(new RunnableTask("task3")); // 조용히 버려짐
실행 결과
[pool-1-thread-1] task1 시작 [pool-1-thread-1] task1 완료 // task2, task3는 아무 흔적 없이 사라짐
내부 구현
public static class DiscardPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
// 비어 있음 - 아무것도 하지 않음
}
}
사용 시나리오
// 실시간 로그 수집 (일부 손실 허용)
ExecutorService logCollector = new ThreadPoolExecutor(
10, 10, 0L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.DiscardPolicy()
);
// 실시간 모니터링 데이터 (최신 데이터만 중요)
ExecutorService metricsCollector = new ThreadPoolExecutor(
5, 5, 0L, TimeUnit.SECONDS,
new SynchronousQueue<>(),
new ThreadPoolExecutor.DiscardPolicy()
);
주의 사항
- 데이터 손실 가능
- 거절 사실 인지 불가
- 중요한 작업에는 부족함
CallerRunsPolicy (호출자가 실행)
거절 시 작업을 요청한 스레드가 직접 실행
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.SECONDS,
new SynchronousQueue<>(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.submit(new RunnableTask("task1"));
executor.submit(new RunnableTask("task2")); // main이 실행!
executor.submit(new RunnableTask("task3")); // main이 실행!
executor.submit(new RunnableTask("task4"));
실행 결과
[pool-1-thread-1] task1 시작 [ main] task2 시작 // main 스레드가 실행! [ main] task2 완료 [pool-1-thread-1] task1 완료 [ main] task3 시작 // main 스레드가 실행! [ main] task3 완료 [pool-1-thread-1] task4 시작 [pool-1-thread-1] task4 완료
내부 구현
public static class CallerRunsPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
r.run(); // 별도 스레드 생성 없이 직접 실행
}
}
}
핵심
r.run(); // 새 스레드 생성 X, 현재 스레드에서 실행 // vs new Thread(r).start(); // 새 스레드 생성
생산 속도 조절 메커니즘
일반적인 흐름 (예외 발생)
main: task1 제출 (0초) → task2 제출 (0초) → task3 제출 (0초) → task4 제출 (0초) 총 소요 시간: 0초 (즉시 반환)
CallerRunsPolicy 적용
main: task1 제출 (0초)
→ task2 제출 (거절)
→ main이 직접 실행 (1초 소요)
→ task3 제출 (0초)
→ task3 제출 (거절)
→ main이 직접 실행 (1초 소요)
→ task4 제출 (0초)
총 소요 시간: 2초 (생산 속도가 느려짐)
효과
- 생산자(main) 속도 저하 → 작업 제출 속도 감소 → 소비자(스레드 풀)가 따라잡을 시간 확보 → 시스템 과부하 방지
실무 활용
// API 요청 처리 (백프레셔 적용)
ExecutorService apiExecutor = new ThreadPoolExecutor(
50, 100, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new ThreadPoolExecutor.CallerRunsPolicy()
);
HTTP 요청 스레드가 직접 처리 → 새 요청 받는 속도 자동 조절 → 시스템 안정성 향상
장점
- 작업 손실 없음 (모든 작업 실행)
- 자동 백프레셔 (back pressure)
- 생산자 – 소비자 균형 유지
단점
- 생산자 스레드 블로킹
- 응답 시간 증가 가능
사용자 정의 정책
public class RejectMainV4 {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.SECONDS,
new SynchronousQueue<>(),
new MyRejectedExecutionHandler() // 커스텀 핸들러
);
executor.submit(new RunnableTask("task1"));
executor.submit(new RunnableTask("task2"));
executor.submit(new RunnableTask("task3"));
executor.close();
}
static class MyRejectedExecutionHandler
implements RejectedExecutionHandler {
static AtomicInteger count = new AtomicInteger(0);
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
int rejectedCount = count.incrementAndGet();
log("[경고] 거절된 누적 작업 수: " + rejectedCount);
}
}
}
실행 결과
[pool-1-thread-1] task1 시작 [ main] [경고] 거절된 누적 작업 수: 1 [ main] [경고] 거절된 누적 작업 수: 2 [pool-1-thread-1] task1 완료
추신 – 과도한 최적화 지양
- 초기 서비스: 고정 풀 전략으로 충분
- 성장 단계: 모니터링 후 필요시 조정
- 대규모: 사용자 정의 전략 + 세밀한 튜닝
- 핵심: 현재 상황에 맞는 최적화
“가장 좋은 최적화는 최적화하지 않는 것이다. 현재 상황에 맞는 전략을 선택하고, 모니터링을 통해 필요할 때 개선하라”