Executors 팩토리 메서드 완전 정리
Executors 클래스의 팩토리 메서드 5종(Fixed/Cached/Single/Scheduled/VirtualThread)의 내부 구현, 장단점, 실운영 함정을 비교하고 각 메서드 선택 기준을 명확히 정리합니다.
지난 글에서 ThreadPoolExecutor의 작업 수락 흐름과 파라미터를 자세히 살펴봤습니다. 이번에는 ThreadPoolExecutor를 편리하게 생성하는 Executors 팩토리 메서드를 하나씩 분석합니다. 편리하지만 잘못 선택하면 OOM이나 성능 저하로 이어지므로, 각 메서드의 내부 구현과 함정을 명확히 이해해야 합니다.
팩토리 메서드 한눈에 보기
newFixedThreadPool(int nThreads)
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(
nThreads, nThreads, // core = max = n
0L, TimeUnit.MILLISECONDS, // keepAlive 없음
new LinkedBlockingQueue<>() // 무제한 큐!
);
}
용도: 동시에 실행되는 작업 수를 제한해야 할 때.
함정: 내부적으로 new LinkedBlockingQueue<>()(용량 제한 없음)를 사용합니다. 처리 속도보다 제출 속도가 빠르면 큐가 무한히 커져 OOM이 발생할 수 있습니다.
// 안전한 대안: 유계 큐 + 거부 정책
int n = Runtime.getRuntime().availableProcessors();
ExecutorService safe = new ThreadPoolExecutor(
n, n,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(1000), // 상한 지정
new ThreadPoolExecutor.CallerRunsPolicy()
);
newCachedThreadPool()
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(
0, Integer.MAX_VALUE, // core=0, max=무제한!
60L, TimeUnit.SECONDS,
new SynchronousQueue<>()
);
}
SynchronousQueue는 버퍼가 없어서 작업이 들어오면 즉시 스레드가 필요합니다. 사용 가능한 스레드가 없으면 새 스레드를 생성하고, 60초 유휴 상태면 제거합니다.
용도: 수명이 짧고 수가 많은 비동기 작업, 적당한 부하의 서버.
함정: 부하 급증 시 스레드가 무제한 생성됩니다. 스레드 생성 속도가 처리 속도보다 빠르면 시스템이 스레드로 가득 찹니다.
// newCachedThreadPool이 맞는 상황
// - 요청당 처리 시간이 10ms 미만
// - 요청 수가 예측 가능하고 폭발적이지 않음
// 피해야 하는 상황
// - DB 쿼리처럼 응답 시간이 수백ms인 작업
// - 갑작스러운 트래픽 폭증 가능성이 있는 환경
newSingleThreadExecutor()
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService(
new ThreadPoolExecutor(
1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>() // 무제한 큐
)
);
}
용도: 작업 순서가 중요하거나 직렬화가 필요할 때.
특이점: FinalizableDelegatedExecutorService로 래핑되어 있어서 반환된 ExecutorService를 ThreadPoolExecutor로 캐스팅할 수 없습니다. 이는 외부에서 풀 크기를 변경하지 못하도록 의도적으로 막은 것입니다. 또한 스레드가 비정상 종료되면 새 스레드를 자동으로 생성해 순서를 유지합니다.
ExecutorService single = Executors.newSingleThreadExecutor();
// 이 작업들은 반드시 제출 순서대로 실행됨
single.submit(() -> step1());
single.submit(() -> step2()); // step1 완료 후 실행
single.submit(() -> step3()); // step2 완료 후 실행
newScheduledThreadPool(int corePoolSize)
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
용도: 지연 실행, 주기 실행.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// 한 번만 지연 실행
scheduler.schedule(() -> sendReminder(), 1, TimeUnit.HOURS);
// 주기 실행 (이전 시작 기준)
ScheduledFuture<?> periodicTask = scheduler.scheduleAtFixedRate(
() -> syncData(),
0, // 초기 지연
10, // 주기
TimeUnit.SECONDS
);
// 주기 실행 (이전 완료 기준)
scheduler.scheduleWithFixedDelay(
() -> cleanup(),
5, // 초기 지연
30, // 완료 후 다음 실행까지 지연
TimeUnit.SECONDS
);
중요: scheduleAtFixedRate()와 scheduleWithFixedDelay() 모두 작업이 예외를 던지면 이후 실행이 조용히 중단됩니다. 반드시 예외를 처리해야 합니다.
// 안전한 주기 작업 패턴
scheduler.scheduleAtFixedRate(() -> {
try {
doWork();
} catch (Exception e) {
log.error("Scheduled task failed", e);
// 예외를 삼켜서 이후 실행이 계속되도록 함
}
}, 0, 10, TimeUnit.SECONDS);
newVirtualThreadPerTaskExecutor() (Java 21+)
public static ExecutorService newVirtualThreadPerTaskExecutor() {
// 작업마다 새 가상 스레드를 생성
return ThreadPerTaskExecutor.create(
Thread.ofVirtual().factory()
);
}
용도: I/O 집약 작업에서 최대 처리량.
동작: 작업마다 가상 스레드 하나를 생성합니다. 가상 스레드는 경량(초기 수 KB)이므로 수백만 개를 생성해도 부담이 없습니다. I/O 대기 시 캐리어(플랫폼) 스레드를 반납하므로 적은 수의 OS 스레드로 높은 동시성을 달성합니다.
// Java 21+ 권장 패턴: try-with-resources
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
fetchFromDatabase(i); // I/O 집약
})
);
} // 모든 가상 스레드 완료 후 자동 종료
주의: CPU 집약 작업(암호화, 이미지 처리 등)에는 오히려 성능이 나빠질 수 있습니다. 가상 스레드는 synchronized 블록이나 native 메서드에서 캐리어 스레드에 “고정(pin)“되어 효율이 떨어집니다.
팩토리 메서드 선택 가이드
// Q1: 가상 스레드를 사용할 수 있나? (Java 21+)
// + I/O 집약 작업인가?
if (java21 && ioIntensive) {
return Executors.newVirtualThreadPerTaskExecutor();
}
// Q2: 작업 순서 보장이 필요한가?
if (needOrdering) {
return Executors.newSingleThreadExecutor();
}
// Q3: 지연/주기 실행이 필요한가?
if (scheduling) {
return Executors.newScheduledThreadPool(n);
}
// Q4: 수명이 매우 짧은 작업인가?
if (veryShortTasks && predictableLoad) {
return Executors.newCachedThreadPool();
}
// Q5: 기본 선택
return new ThreadPoolExecutor(coreN, maxN, keepAlive, unit,
new LinkedBlockingQueue<>(bound), factory, handler);
실운영에서는 팩토리 메서드보다 ThreadPoolExecutor 직접 생성이 더 안전합니다. 팩토리 메서드는 무제한 큐나 무제한 스레드를 숨기고 있어서 예측 불가능한 문제가 생길 수 있습니다. 팩토리 메서드는 프로토타이핑이나 테스트 코드에서 사용하고, 프로덕션 코드에는 파라미터를 명시적으로 설정하는 것을 권장합니다.
지난 글: 스레드 풀 완전 분석: ThreadPoolExecutor 내부 동작
다음 글: Future와 FutureTask: 비동기 결과 추적
읽어주셔서 감사합니다. 😊