ExecutorService와 thread pool: 각 작업에 맞는 타입 선택하기
1. 개요
new Thread()로 매 작업마다 새로운 스레드를 만드는 것은 간단하지만, 부하가 증가할 때 제어하기 어렵습니다. ExecutorService는 스레드를 재사용하고 작업을 큐에 배치함으로써 이 문제를 해결합니다. Executors 클래스는 여러 종류의 pool을 제공하고, 잘못된 타입을 선택하면 OutOfMemoryError가 발생하거나 애플리케이션이 멈출 수 있습니다.
이 글은 각 pool 타입을 살펴보고, ThreadPoolExecutor가 작업을 내부에서 어떻게 처리하는지, pool 크기를 어떻게 선택할지, Spring Boot 3.x에서 어떻게 구성할지를 다룹니다. 읽고 난 후, CPU-bound 작업, I/O-bound 작업, 또는 정기적인 작업에 어떤 pool을 사용해야 하는지 알게 될 것입니다.
2. ExecutorService 기본
다음은 결과를 반환하는 작업을 실행하기 위해 간단한 pool을 사용하는 예제입니다:
import java.util.concurrent.*;
public class OrderReportApp {
public static void main(String[] args) throws Exception {
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
Future<Integer> total = executor.submit(() -> countOrders("2026-10"));
System.out.println("총 주문 수: " + total.get());
}
}
static int countOrders(String month) {
return 128; // database 조회 시뮬레이션
}
}
총 주문 수: 128
코드가 작동하는 방식:
submit()은Callable을 받고Future<Integer>를 즉시 반환합니다. 작업은 pool의 한 스레드에서 실행됩니다.total.get()은 현재 스레드를 결과가 나올 때까지 블로킹합니다.- Java 19부터
ExecutorService는AutoCloseable을 구현합니다.try블록을 벗어날 때,close()는shutdown()을 호출하고 모든 작업이 완료될 때까지 기다립니다.
주의: executor를 닫지 않으면, daemon이 아닌 스레드들이 JVM을 살려 놓습니다.
main프로그램이 종료되지 않습니다.
3. Executors의 다양한 pool 타입
3.1. newFixedThreadPool(n)
이 pool은 정확히 n개의 스레드를 가집니다. 초과 작업은 제한이 없는 LinkedBlockingQueue에 들어갑니다.
ExecutorService invoiceExecutor = Executors.newFixedThreadPool(8);
이 타입은 동시에 실행되는 작업 수를 제한하고 싶을 때 적합합니다. 예를 들어 CPU-bound 작업의 경우입니다. 위험은 큐에 있습니다. producer가 작업을 처리 속도보다 빠르게 푸시하면, 큐는 heap이 소진될 때까지 계속 팽창합니다.
3.2. newCachedThreadPool()
이 pool은 고정된 스레드를 가지지 않습니다. SynchronousQueue를 사용하므로, 실제 큐가 없습니다. 새로운 각 작업은 유휴 스레드에 할당되거나, 없으면 pool이 새로운 스레드를 생성합니다. 60초 이상 유휴 스레드는 제거됩니다.
ExecutorService notificationExecutor = Executors.newCachedThreadPool();
이 pool은 많은 단기 작업과 낮은 불규칙한 부하에 적합합니다. 부하가 급증하면, 수천 개의 platform 스레드를 생성할 수 있습니다. 기본적으로 각 스레드는 약 1 MB의 스택을 차지하므로, 애플리케이션은 쉽게 OutOfMemoryError: unable to create native thread에 마주할 수 있습니다.
3.3. newSingleThreadExecutor()
이 pool은 단 하나의 스레드만 가지며, 작업은 들어온 순서대로 정확히 순차적으로 실행됩니다.
ExecutorService auditLogExecutor = Executors.newSingleThreadExecutor();
auditLogExecutor.submit(() -> System.out.println("주문 #1 생성됨"));
auditLogExecutor.submit(() -> System.out.println("주문 #1 결제됨"));
주문 #1 생성됨
주문 #1 결제됨
두 로그 라인은 항상 제출된 순서대로 출력됩니다. 이 타입은 로그, 파일 기록, 또는 상태 업데이트를 lock 없이 순서대로 해야 할 때 유용합니다. 스레드가 exception으로 인해 중단되면, pool은 대체할 새로운 스레드를 생성합니다. 이것의 큐도 제한이 없습니다.
3.4. newScheduledThreadPool(n)
이 타입은 예약 또는 반복 작업에 사용됩니다:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
() -> System.out.println("인벤토리 동기화 " + java.time.LocalTime.now()),
0, 5, TimeUnit.SECONDS);
scheduleAtFixedRate는 5초마다 작업을 실행하며, 이전 실행의 시작 시점부터 카운트합니다. 반대로 scheduleWithFixedDelay는 이전 실행이 완료된 후부터 5초를 카운트합니다. 작업의 실행 시간이 불안정할 때는 scheduleWithFixedDelay를 선택해야 합니다.
주의: 정기 작업이 exception을 던지면, 이후 모든 실행이 조용히 취소되며, 로그가 없습니다. 항상 작업 본문을
try/catch로 감싸세요.
3.5. newWorkStealingPool()
이 메서드는 CPU 수와 같은 스레드 수를 가진 ForkJoinPool을 반환합니다. 각 스레드는 자신의 큐를 가집니다. 유휴 스레드는 다른 스레드의 큐에서 작업을 “훔쳐” 실행합니다.
ExecutorService pricingExecutor = Executors.newWorkStealingPool();
이 타입은 재귀적으로 분할할 수 있는 CPU-bound 작업에 적합합니다. 예를 들어, 큰 데이터셋에 대한 병렬 계산입니다. pool의 스레드는 daemon이고, pool은 실행 순서를 보장하지 않습니다.
3.6. newVirtualThreadPerTaskExecutor() (Java 21)
이 타입에서는 각 작업이 새로운 virtual 스레드에서 실행됩니다. Virtual 스레드는 매우 저렴하므로, 수십만 개를 만들 수 있습니다.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int customerId = i;
executor.submit(() -> {
Thread.sleep(100); // CustomerService에 HTTP 호출 시뮬레이션
return customerId;
});
}
}
위 코드는 10,000개의 작업을 100 ms 동안 블로킹시키고 수백 ms 내에 완료합니다. 동일한 작업량을 10개 스레드의 fixed pool로 하면 약 100초가 걸립니다.
이것은 HTTP 호출, 데이터베이스 조회 또는 파일 읽기와 같은 I/O-bound 작업에 기본으로 좋은 선택입니다. 기억해야 할 세 가지가 있습니다:
- Virtual 스레드를 pool하지 마세요. 이들은 한 번 사용하고 버리기 위해 만들어졌습니다.
- 외부 리소스에 대한 부하를 제한하고 싶다면
Semaphore를 사용하세요. 예를 들어, 데이터베이스가 20개 연결만 지원하면, pool 크기에만 의존하지 마세요. - Java 21에서 블로킹 작업이 있는
synchronized블록은 virtual 스레드를 carrier 스레드에 “pin”시킵니다. 이 경우 virtual 스레드의 이점이 사라집니다. 이러한 섹션에서는ReentrantLock을 선호하세요.
Semaphore를 사용하여 동시 쿼리 수를 제한하는 예제:
Semaphore dbPermits = new Semaphore(20);
executor.submit(() -> {
dbPermits.acquire();
try {
return orderRepository.findByCustomerId(customerId);
} finally {
dbPermits.release();
}
});
4. ThreadPoolExecutor 직접 구성하기
Executors의 factory 메서드는 종종 위험한 파라미터를 숨깁니다: 제한 없는 큐 또는 제한 없는 스레드 수. Production 시스템에서는 모든 것을 제어하기 위해 ThreadPoolExecutor를 직접 인스턴스화해야 합니다:
ThreadFactory factory = Thread.ofPlatform().name("payment-", 0).factory();
ThreadPoolExecutor paymentExecutor = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, TimeUnit.SECONDS, // 코어를 초과하는 스레드를 위한 keepAlive
new ArrayBlockingQueue<>(100), // 제한이 있는 큐
factory, // 이름 설정: payment-0, payment-1...
new ThreadPoolExecutor.CallerRunsPolicy());
새로운 작업이 들어올 때, ThreadPoolExecutor는 다음 순서대로 정확히 처리합니다:
- 스레드 수가
corePoolSize보다 작으면, pool이 새로운 스레드를 생성합니다. - 그렇지 않으면, pool이 작업을 큐에 넣습니다.
- 큐가 가득 찼고 스레드 수가
maximumPoolSize보다 작으면, pool이 추가 스레드를 생성합니다. - 두 제한에 모두 도달하면, pool이
RejectedExecutionHandler를 호출합니다.
결과적으로 maximumPoolSize는 큐가 가득 찼을 때만 효과가 있습니다. 제한 없는 큐를 사용하면, pool은 절대 corePoolSize를 초과하지 않을 것입니다. 이것은 매우 흔한 오해의 원천입니다.
Java는 네 가지 rejection policy를 기본 제공합니다:
| Policy | 동작 |
|---|---|
AbortPolicy (기본값) |
RejectedExecutionException 던지기 |
CallerRunsPolicy |
execute()를 호출한 스레드가 직접 작업을 실행하여 back-pressure 제공 |
DiscardPolicy |
작업을 버리고 아무것도 보고하지 않음 |
DiscardOldestPolicy |
큐의 가장 오래된 작업을 버리고 재시도 |
ThreadFactory를 통해 스레드 이름을 설정하면 thread dump와 로그가 훨씬 읽기 쉬워집니다. payment-3과 pool-7-thread-3을 비교해 보세요.
5. 올바른 타입 선택하기
5.1. Pool 크기
CPU-bound 작업의 경우, 스레드 수는 대략 코어 수와 같아야 합니다:
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuExecutor = Executors.newFixedThreadPool(cores);
코어 수보다 많은 스레드는 context switch 오버헤드만 증가시킵니다.
Platform 스레드에서 실행되는 I/O-bound 작업의 경우, 대략적인 공식을 사용할 수 있습니다:
스레드 수 ≈ 코어 수 × (1 + 대기 시간 / 계산 시간)
예제: 8코어 머신, 각 요청은 I/O 대기 90 ms, 계산 10 ms. 결과는 8 × (1 + 9) = 80 스레드입니다. 이것은 단지 시작점일 뿐입니다. load test를 통해 실제 성능을 측정해야 합니다.
5.2. 결정 테이블
| 작업 | 선택 |
|---|---|
| 무거운 CPU 계산 | newFixedThreadPool(cores) 또는 ForkJoinPool |
| 대량의 API 호출, DB 쿼리 | newVirtualThreadPerTaskExecutor() + Semaphore |
| 순차 순서 필요 | newSingleThreadExecutor() |
| 정기적 작업 | newScheduledThreadPool(n) |
| Production, back-pressure 필요 | 제한이 있는 큐를 사용한 ThreadPoolExecutor |
| 급증 부하, 제한 없음 | newCachedThreadPool() 피하기 |
6. Spring Boot 3.x에서 Thread pool
먼저 Maven dependency가 필요합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
다음으로, 이메일 전송을 위한 별도의 executor를 선언합니다:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "emailExecutor")
public ThreadPoolTaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("email-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
return executor;
}
}
@Service
public class EmailService {
@Async("emailExecutor")
public void sendOrderConfirmation(String customerEmail) {
System.out.println(Thread.currentThread().getName() + " " + customerEmail + "로 메일 전송");
}
}
email-1 an.nguyen@example.com로 메일 전송
주의할 몇 가지 사항:
ThreadPoolTaskExecutor의 기본queueCapacity는Integer.MAX_VALUE입니다. 이 값을 설정하지 않으면,maxPoolSize가 의미 없어집니다. 섹션 4에서 설명했듯이요.setWaitForTasksToCompleteOnShutdown(true)는 애플리케이션 종료 시 진행 중인 이메일이 중단되지 않도록 합니다.@Async, Tomcat,@Scheduled에 virtual 스레드를 사용하고 싶으면, 설정 한 줄만 추가하면 됩니다:
spring.threads.virtual.enabled=true
7. Pool 동작 테스트
다음 테스트는 섹션 4에서 설명한 처리 순서가 맞는지 확인합니다: core 스레드 먼저, 큐, 추가 스레드, 마지막으로 거부.
import org.junit.jupiter.api.Test;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class PaymentExecutorTest {
@Test
void shouldRejectWhenThreadsAndQueueAreFull() throws Exception {
CountDownLatch release = new CountDownLatch(1);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 2, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.AbortPolicy());
Runnable blockingPayment = () -> {
try {
release.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
try {
executor.execute(blockingPayment); // 1: core 스레드 생성
executor.execute(blockingPayment); // 2: 큐에 들어감
executor.execute(blockingPayment); // 3: 큐 가득참 -> 두 번째 스레드 생성
assertThat(executor.getPoolSize()).isEqualTo(2);
assertThat(executor.getQueue()).hasSize(1);
assertThatThrownBy(() -> executor.execute(blockingPayment))
.isInstanceOf(RejectedExecutionException.class);
} finally {
release.countDown();
executor.shutdown();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
}
CountDownLatch는 모든 작업을 블로킹 상태로 유지하므로, pool의 상태가 안정적일 때 검사할 수 있습니다. 네 번째 작업은 자리가 없으므로, AbortPolicy가 exception을 던집니다. finally 블록은 latch를 해제하고 pool을 닫으므로, test는 스레드 누수를 남기지 않습니다.
8. 흔한 실수
- 제어되지 않는 producer에
Executors.newFixedThreadPool사용. 제한 없는 큐는 팽창하다가 OOM을 일으킵니다. 제한이 있는ArrayBlockingQueue를 사용하세요. - 매 요청마다 새로운 pool 생성. Pool은 공유되어야 하며, bean이거나
static final필드로 선언되어야 합니다. 생성 후 닫지 않으면 스레드 누수가 발생합니다. - Exception 무시.
submit()을 사용하면, exception이Future에 보관됩니다.get()을 호출하지 않으면, 에러를 절대 볼 수 없습니다.get()을 호출하거나 작업 내에서 에러를 즉시 잡으세요. - 작은 pool에서 부모 작업이 자식 작업을 기다릴 때 deadlock. 예제: 2개 스레드 pool, 두 부모 작업이 모두 큐에 있는 자식 작업에
get()을 호출합니다. 자식 작업을 실행할 스레드가 남지 않아 전체 시스템이 멈춥니다. - Virtual 스레드에 무거운
ThreadLocal설정. 수백만 개의 virtual 스레드가 생길 수 있고, 각각 하나의 사본을 유지하므로 메모리가 빠르게 증가합니다. shutdownNow()호출하되 작업이 interrupt를 처리하지 않음. 이 경우 작업이 계속 실행됩니다. 긴 작업은Thread.currentThread().isInterrupted()를 확인해야 합니다.
주의: 복잡한 다단계 종속 처리 흐름에는
ExecutorService를 사용하지 마세요. 이 경우CompletableFuture를 사용해야 하며, 이는 여전히 직접 구성한 executor에서 실행될 수 있습니다.
9. 결론
각 thread pool 타입은 고유한 문제를 해결합니다:
- Fixed pool 또는
ForkJoinPoolCPU-bound 작업. - Virtual thread와
SemaphoreJava 21의 I/O-bound 작업. - Single thread executor 순서가 필요할 때.
- Scheduled pool 정기적 작업.
Production에서는 제한이 있는 큐, 명확한 rejection policy, 인식 가능한 스레드 이름으로 ThreadPoolExecutor 또는 ThreadPoolTaskExecutor를 직접 구성하세요. 마지막으로, pool 크기를 공식에만 의존하지 말고 load test로 검증해야 합니다.