ExecutorService와 Executor 프레임워크 개요

java.util.concurrent Executor 프레임워크의 인터페이스 계층(Executor → ExecutorService → ThreadPoolExecutor), ExecutorService 생명주기, submit/invoke 차이, 올바른 종료 패턴을 설명합니다.

· 7 min read · PALDYN Team

지난 글에서 가상 스레드 환경에 맞는 컨텍스트 전파 방법으로 ScopedValue를 살펴봤습니다. 이번에는 멀티스레드 작업 실행의 핵심 기반인 Executor 프레임워크를 전체 관점에서 정리합니다. Thread를 직접 생성하고 관리하는 방식에서 벗어나, 작업 제출과 스레드 관리를 분리하는 이 프레임워크를 이해하면 안정적이고 확장 가능한 동시성 코드를 작성할 수 있습니다.

왜 Executor 프레임워크인가?

new Thread(task).start()를 반복하면 스레드 생성 비용(수 ms)이 누적되고, 동시 실행 스레드 수를 제어할 방법이 없습니다.

// 안티패턴: 요청마다 스레드 생성
for (Request req : requests) {
    new Thread(() -> handle(req)).start(); // 1만 요청 → 1만 스레드?
}

Executor 프레임워크는 스레드 풀을 재사용하고, 작업 큐를 통해 동시성 수준을 제어하며, 작업 결과(Future)를 추적하는 세 가지 문제를 한 번에 해결합니다.

Executor 프레임워크 인터페이스 계층

인터페이스 계층

Executor

가장 단순한 인터페이스로, 메서드 하나만 있습니다.

public interface Executor {
    void execute(Runnable command);
}

작업을 어디서, 어떻게 실행할지는 구현체가 결정합니다. 동일 스레드에서 즉시 실행할 수도 있고, 풀에서 실행할 수도 있습니다.

ExecutorService

Executor를 확장해 여러 기능을 추가합니다.

// 주요 메서드
Future<T> submit(Callable<T> task);      // 결과 추적
Future<?> submit(Runnable task);         // 결과 없음
List<Future<T>> invokeAll(Collection<Callable<T>> tasks); // 모두 완료 대기
T invokeAny(Collection<Callable<T>> tasks);               // 하나라도 완료 대기
void shutdown();
List<Runnable> shutdownNow();
boolean awaitTermination(long timeout, TimeUnit unit);

ScheduledExecutorService

지연 실행과 주기 실행을 추가합니다.

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

// 5초 후 한 번 실행
scheduler.schedule(() -> doWork(), 5, TimeUnit.SECONDS);

// 10초 후 시작, 이후 매 30초마다 실행
scheduler.scheduleAtFixedRate(() -> poll(), 10, 30, TimeUnit.SECONDS);

// 이전 실행 완료 후 15초 지연 후 재실행
scheduler.scheduleWithFixedDelay(() -> sync(), 0, 15, TimeUnit.SECONDS);

ExecutorService 생명주기

ExecutorService 생명주기

ExecutorService executor = Executors.newFixedThreadPool(4);

// 작업 제출
executor.submit(() -> doWork());

// 종료 요청 (새 작업 거부, 기존 작업은 완료)
executor.shutdown();

try {
    // 최대 60초 대기
    if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
        // 60초 안에 안 끝나면 강제 종료
        executor.shutdownNow();
    }
} catch (InterruptedException e) {
    executor.shutdownNow();
    Thread.currentThread().interrupt();
}

JDK 19+ 에서는 AutoCloseable을 구현하므로 try-with-resources를 사용할 수 있습니다.

// Java 19+ close() = shutdown() + awaitTermination()
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
    executor.submit(task1);
    executor.submit(task2);
} // try 블록 종료 시 자동으로 graceful shutdown

execute vs submit

// execute: Runnable만, 예외 삼킴
executor.execute(() -> {
    throw new RuntimeException("오류"); // UncaughtExceptionHandler로만 감지
});

// submit: Runnable/Callable, Future로 예외 추적
Future<?> future = executor.submit(() -> {
    throw new RuntimeException("오류");
});
try {
    future.get(); // ExecutionException 발생 → 원인 예외 접근 가능
} catch (ExecutionException e) {
    Throwable cause = e.getCause(); // 원래 RuntimeException
}

비동기 작업의 예외를 처리해야 한다면 반드시 submit()을 사용하고 Future.get()으로 확인해야 합니다. execute()로 제출된 작업의 예외는 기본적으로 무시됩니다.

invokeAll / invokeAny

List<Callable<String>> tasks = List.of(
    () -> fetchFromA(),
    () -> fetchFromB(),
    () -> fetchFromC()
);

// 모두 완료될 때까지 블로킹
List<Future<String>> results = executor.invokeAll(tasks);
for (Future<String> r : results) {
    System.out.println(r.get()); // 이미 완료된 상태
}

// 가장 먼저 완료된 하나의 결과만 반환
String fastest = executor.invokeAny(tasks);

invokeAll()은 타임아웃 버전도 있어서 invokeAll(tasks, 10, TimeUnit.SECONDS) 처럼 사용할 수 있습니다. 타임아웃 내에 완료되지 않은 작업은 취소됩니다.

Executors 팩토리 메서드

// 고정 크기 스레드 풀
ExecutorService fixed = Executors.newFixedThreadPool(8);

// 필요에 따라 스레드를 생성/재사용, 60초 미사용 시 제거
ExecutorService cached = Executors.newCachedThreadPool();

// 단일 스레드 (순서 보장)
ExecutorService single = Executors.newSingleThreadExecutor();

// 스케줄링 가능
ScheduledExecutorService sched = Executors.newScheduledThreadPool(2);

// Java 21: 작업당 가상 스레드 하나
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();

newCachedThreadPool()은 부하가 낮을 때 효율적이지만, 갑작스러운 부하 급증 시 스레드가 무제한 생성될 수 있어 주의가 필요합니다.

ThreadPoolExecutor 직접 생성

세밀한 제어가 필요하면 Executors 대신 ThreadPoolExecutor를 직접 생성합니다.

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    4,                      // corePoolSize: 항상 유지할 스레드 수
    8,                      // maximumPoolSize: 최대 스레드 수
    60L, TimeUnit.SECONDS,  // keepAliveTime: 유휴 스레드 제거 시간
    new LinkedBlockingQueue<>(100), // workQueue: 작업 대기열
    new ThreadFactory() {   // threadFactory: 스레드 이름 지정 등
        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, "worker-" + counter.getAndIncrement());
            t.setDaemon(false);
            return t;
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy() // 포화 정책
);

포화 정책(RejectedExecutionHandler)은 큐와 최대 스레드 수가 모두 꽉 찼을 때의 동작을 결정합니다.

정책동작
AbortPolicy (기본)RejectedExecutionException throw
CallerRunsPolicy제출자 스레드가 직접 실행
DiscardPolicy조용히 버림
DiscardOldestPolicy큐 가장 오래된 작업 버리고 재시도

정리

클래스/인터페이스용도
Executor가장 단순한 실행 추상화
ExecutorService생명주기 + Future
ScheduledExecutorService지연/주기 실행
ThreadPoolExecutor세밀한 풀 제어
ForkJoinPool분할정복, work-stealing
Executors팩토리 메서드

Executor 프레임워크는 Java 동시성 프로그래밍의 기반입니다. 다음 글에서는 스레드 풀의 내부 동작과 크기 조정 전략을 더 깊이 살펴봅니다.


지난 글: ScopedValue: 가상 스레드 시대의 컨텍스트 전파

다음 글: 스레드 풀 완전 분석: ThreadPoolExecutor 내부 동작


읽어주셔서 감사합니다. 😊