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는 다음 순서대로 정확히 처리합니다:

  1. 스레드 수가 corePoolSize보다 작으면, pool이 새로운 스레드를 생성합니다.
  2. 그렇지 않으면, pool이 작업을 큐에 넣습니다.
  3. 큐가 가득 찼고 스레드 수가 maximumPoolSize보다 작으면, pool이 추가 스레드를 생성합니다.
  4. 두 제한에 모두 도달하면, 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 또는 ForkJoinPool CPU-bound 작업.
  • Virtual thread와 Semaphore Java 21의 I/O-bound 작업.
  • Single thread executor 순서가 필요할 때.
  • Scheduled pool 정기적 작업.

Production에서는 제한이 있는 큐, 명확한 rejection policy, 인식 가능한 스레드 이름으로 ThreadPoolExecutor 또는 ThreadPoolTaskExecutor를 직접 구성하세요. 마지막으로, pool 크기를 공식에만 의존하지 말고 load test로 검증해야 합니다.

javaconcurrencyexecutorservicethread-poolspring-boot