자바에서 멀티스레드 프로그래밍을 할 때 new Thread()를 통해 직접 스레드를 생성하고 관리하는 방식은 여러 한계를 지닌다. 스레드 생성과 파괴에 소요되는 오버헤드가 크고, 무분별한 스레드 생성은 시스템 자원 고갈로 이어질 수 있다.
자바 5부터 도입된 Executor 프레임워크는 이러한 스레드 생성 및 관리 작업을 캡슐화하여 개발자가 스레드를 직접 다루지 않고도 작업을 효율적으로 비동기 처리할 수 있도록 지원하는 표준 인프라다.
new Thread(() -> { ... }).start() 방식을 사용할 때 발생하는 대표적인 문제점은 다음과 같다.
OutOfMemoryError가 발생하거나 CPU 컨텍스트 스위칭 오버헤드가 극대화된다.Executor 프레임워크는 작업 제출(Task Submission)과 작업 실행(Task Execution)을 분리하는 구조를 가진다. 개발자는 '수행할 작업'만 정의하여 제출하고, 작업의 실제 스레드 할당 및 실행은 프레임워크가 담당한다.
이름이 유사하여 헷갈리기 쉬운 Executor, ExecutorService, Executors의 관계와 역할을 정리하면 다음과 같다.
| 이름 | 종류 | 주요 역할 |
|---|---|---|
Executor | 인터페이스 | 최상위 인터페이스. execute() 메서드 하나만 갖고 있으며, 작업 실행의 기본 규격을 정의한다. |
ExecutorService | 인터페이스 | Executor를 상속받은 확장 인터페이스. 작업 제출(submit), 스레드 풀 종료(shutdown) 등 실제 스레드 풀 관리와 제어를 담당한다. |
Executors | 팩토리 클래스 (유틸리티) | 인터페이스가 아닌 일반 클래스다. ExecutorService 구현체 객체(ThreadPoolExecutor 등)를 쉽게 생성해 주는 공장(Factory) 역할을 한다. |
Executor (인터페이스)
▲ 상속(extends)
│
ExecutorService (인터페이스)
▲ 구현(implements)
│
ThreadPoolExecutor (구체 클래스) ◄── 생성(Factory) ── Executors (유틸리티 클래스)
Executor: 가장 기본이 되는 작업 실행 규격이다.ExecutorService: Executor를 상속받아 완성된 스레드 풀 관리용 인터페이스다.Executors: ExecutorService를 직접 구현한 것이 아니라, 실제 구현체인 ThreadPoolExecutor 같은 객체를 간편하게 만들어서 반환해 주는 팩토리 클래스다.Executors 유틸리티 클래스가 제공하는 주요 팩토리 메서드는 다음과 같다.
// 1. 고정 크기 스레드 풀 생성
ExecutorService fixedPool = Executors.newFixedThreadPool(4);
// 2. 가변 크기 스레드 풀 생성
ExecutorService cachedPool = Executors.newCachedThreadPool();
// 3. 단일 스레드 풀 생성
ExecutorService singlePool = Executors.newSingleThreadExecutor();
newFixedThreadPool(int nThreads): 지정한 개수만큼의 스레드를 고정으로 생성하여 재사용한다. 작업이 몰리면 큐(Queue)에 쌓아두고 순차 처리한다.newCachedThreadPool(): 필요에 따라 스레드를 동적으로 생성한다. 놀고 있는 스레드가 있다면 재사용하고, 60초 동안 사용되지 않은 스레드는 제거한다.newSingleThreadExecutor(): 단 하나의 스레드로 모든 작업을 순차적으로(FIFO) 처리한다.import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorExample {
public static void main(String[] args) {
// 스레드 2개를 가진 고정 스레드 풀 생성
ExecutorService executor = Executors.newFixedThreadPool(2);
// 5개의 작업 제출
for (int i = 1; i <= 5; i++) {
final int taskId = i;
executor.execute(() -> {
System.out.println(Thread.currentThread().getName() + " - 작업 " + taskId + " 수행 중");
});
}
// 작업 완료 후 ExecutorService 종료
executor.shutdown();
}
}
pool-1-thread-1 - 작업 1 수행 중
pool-1-thread-2 - 작업 2 수행 중
pool-1-thread-1 - 작업 3 수행 중
pool-1-thread-2 - 작업 4 수행 중
pool-1-thread-1 - 작업 5 수행 중
pool-1-thread-1, pool-1-thread-2)을 확인해 보면 새로 스레드를 계속 만드는 대신, 스레드 풀에 등록된 단 2개의 스레드가 5개의 작업을 나누어 교대로 처리한 것을 볼 수 있다.참고: execute() vs submit()
위 예시에서 사용한 execute()는 반환값이 없는(Runnable) 작업 전달에 사용한다. 만약 작업 실행 후 결과값을 반환받아야 한다면 submit() 메서드를 사용하며, 이는 Callable과 Future 를 알아야한다.
스레드 풀 내부의 스레드는 작업이 끝나도 기본적으로 대기 상태로 유지되므로, 애플리케이션이 종료되지 않고 계속 대기할 수 있다. 따라서 사용이 끝나면 반드시 종료 메서드를 호출해야 한다.
shutdown(): 현재 진행 중인 작업 및 큐에 대기 중인 작업까지 모두 마친 후 스레드 풀을 안전하게 종료한다.shutdownNow(): 즉시 종료를 시도한다. 대기 중인 작업 목록을 반환하고 진행 중인 작업에는 interrupt()를 발생시킨다.shutdown()은 "종료 명령"만 내리고 즉시 다음 코드로 넘어가기 때문에, 진행 중인 작업들이 실제로 모두 끝날 때까지 메인 스레드가 기다리게 하려면 awaitTermination()을 함께 사용하는 패턴이 자주 쓰인다.
executor.shutdown(); // 추가 작업 받기를 중단하고 완료 후 종료 요청
// 최대 60초 동안 기존 작업들이 완수되기를 기다림
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow(); // 시간 초과 시 강제 종료
}
shutdown()을 호출해야 한다.