스레드 풀 거부 정책이 실행되는 메커니즘
Java의 ThreadPoolExecutor에서 거부 정책(RejectedExecutionHandler)이 활성화되는 시점은 단순히 '바쁠 때'가 아닙니다. 정확히는 시스템의 처리 용량이 한계에 도달했을 때이며, 다음 세 가지 조건이 모두 충족될 때 발생합니다.
- 코어 스레드 풀 포화: 설정된
corePoolSize만큼의 스레드가 모두 실행 중일 때. - 대기 큐 가득 참:
BlockingQueue의 용량이 모두 소진되어 더 이상 작업을 쌓을 수 없을 때. - 최대 스레드 수 도달:
maximumPoolSize까지 스레드가 생성되었으나 여전히 모든 스레드가 작업 중일 때.
예를 들어, 코어 스레드 5개, 최대 스레드 10개, 큐 용량 20개인 환경에서 50개의 요청이 동시에 들어오면, 5개는 즉시 실행, 20개는 대기, 5개는 추가 스레드로 실행되지만 나머지 20개는 거부 정책에 따라 처리됩니다. 이때 적절한 전략이 없으면 데이터 유실이나 시스템 마비로 이어질 수 있습니다.
JDK 표준 거부 정책의 특징과 한계
Java는 RejectedExecutionHandler 인터페이스를 통해 4가지 기본 전략을 제공합니다.
1. AbortPolicy (기본값): 즉시 예외 발생
작업을 거부하는 즉시 RejectedExecutionException을 던집니다. 작업을 제출한 쪽에서 이 예외를 어떻게 처리하느냐에 따라 유실 여부가 결정됩니다.
ThreadPoolExecutor service = new ThreadPoolExecutor(
4, 8, 30L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10),
new ThreadPoolExecutor.AbortPolicy()
);
결제 시스템처럼 작업 유실이 절대 발생해서는 안 되며, 유실 발생 시 즉각적으로 시스템이 인지해야 하는 경우에 적합합니다.
2. DiscardPolicy: 소리 없는 삭제
거부된 작업을 아무런 조치 없이 버립니다. 예외도 발생하지 않으므로 추적이 불가능합니다.
로그 수집이나 단순 통계 등 누락되어도 비즈니스에 영향이 없는 부가적인 작업에만 제한적으로 사용해야 합니다.
3. DiscardOldestPolicy: 오래된 작업 교체
큐에서 가장 오래 대기한 작업(Head)을 삭제하고, 현재 들어온 새 작업을 큐에 넣으려고 시도합니다.
실시간 데이터 대시보드처럼 최신 정보가 과거 정보보다 중요한 시스템에서 유리하지만, 삭제되는 작업이 무엇인지 알 수 없으므로 주의가 필요합니다.
4. CallerRunsPolicy: 제출자에게 부담 전가
작업을 제출한 스레드(예: Tomcat의 요청 처리 스레드)가 직접 해당 작업을 실행하게 합니다. 작업 유실을 방지하는 가장 간단한 방법입니다.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(50),
new ThreadPoolExecutor.CallerRunsPolicy()
);
작업 유실은 막을 수 있으나, 제출자 스레드가 작업을 처리하는 동안 새로운 요청을 받지 못하므로 전체적인 시스템 처리 속도(Throughput)가 저하되는 '백프레셔(Back-pressure)' 효과가 발생합니다.
고급 아키텍처: 작업 유실 제로를 위한 커스텀 전략
표준 전략만으로 부족한 복잡한 비즈니스 환경에서는 커스텀 핸들러를 구현하여 유연성을 확보해야 합니다.
방법 1: 메시지 큐(MQ)를 활용한 비동기 재시도
스레드 풀이 가득 찼을 때 작업을 버리는 대신 RabbitMQ, Kafka, Redis 등 외부 저장소에 적재한 후 나중에 다시 처리하는 방식입니다.
public class TaskBufferingHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 작업을 직렬화하여 외부 MQ에 전송
String taskInfo = serializeTask(r);
messageQueueProvider.sendToRetryQueue("FAILED_TASK_TOPIC", taskInfo);
log.warn("스레드 풀 포화로 작업을 MQ에 백업함: {}", taskInfo);
}
}
이 방식은 작업 유실을 방지하면서도 제출자 스레드를 차단하지 않아 응답 성능을 유지할 수 있는 가장 강력한 방법입니다. 단, 작업의 순서가 보장되지 않을 수 있고 멱등성(Idempotency) 처리가 필수적입니다.
방법 2: 동적 자원 확장 (Dynamic Scaling)
거부 시점에 임계치 내에서 큐의 크기나 스레드 수를 일시적으로 늘리는 방식입니다.
public class ElasticRejectionHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
if (executor.getMaximumPoolSize() < 50) { // 하드웨어 임계치 내 확장
executor.setMaximumPoolSize(executor.getMaximumPoolSize() + 5);
executor.execute(r);
return;
}
// 확장 한도 초과 시 CallerRunsPolicy로 폴백
new ThreadPoolExecutor.CallerRunsPolicy().rejectedExecution(r, executor);
}
}
실무 적용 시 고려해야 할 핵심 포인트
- 멱등성 설계: MQ를 이용한 재시도나
CallerRunsPolicy사용 시, 동일한 작업이 중복 실행될 가능성을 고려하여 비즈니스 로직에 유니크 키 체크(Redis/DB)를 도입해야 합니다. - 모니터링 연동: 어떤 정책을 사용하든 거부 정책이 실행된다는 것은 시스템 부하가 임계점에 도달했다는 신호입니다. 이를 반드시 메트릭(Prometheus/Grafana)으로 시각화하고 알람을 설정해야 합니다.
- 제출 전 검사: 작업을 제출하기 전에
executor.getQueue().remainingCapacity()를 확인하여 사전에 부하를 제어하는 'Fail-fast' 전략을 검토하십시오.
요약 및 전략 선정 기준
| 비즈니스 성격 | 권장 전략 | 주의사항 |
|---|---|---|
| 핵심 금융/주문 처리 | MQ 백업 후 재시도 | 인프라 복잡도 증가, 멱등성 필수 |
| 일반적인 API 서비스 | CallerRunsPolicy | 전체 응답 시간 지연 가능성 |
| 실시간 데이터 수집 | DiscardOldestPolicy | 데이터 유실 감내 필요 |
| 비중요 로그/알림 | DiscardPolicy | 추적 불가능 |