
JVM 위에서 동작하는 자바 어플리케이션은
내부에서 작업의 흐름 단위로 스레드를 사용하여 병렬 처리를 구현한다.
이때 모든 개별 작업마다 스레드를 직접 생성하고 관리하는 방식은 다음과 같은 문제를 가진다.
컨텍스트 스위칭 비용 증가
무분별한 자원 소비
예외처리 누락과 같은 코드 레벨의 문제
이번 포스팅에서는 이러한 스레드(Thread) 단위의 작업 흐름을 자동으로 관리하고
대규모 작업을 안전하게 병렬처리해주며
서버에서의 시스템 자원을 예측 가능하게 통제할 수 있는,
ExecutorService 인터페이스에 대해 다루어보도록 한다.
스레드 풀(Thread Pool)이란 단어의 의미 그대로 스레드들을 모아둔 것이라 생각할 수 있다.
사용 목적과 함께 풀어서 개념에 대해 다루어보면
스레드풀은 작업을 처리하기 위한 스레드를 매번 새로 생성하지 않고,
미리 만들어 둔 스레드 집합을 재사용하여 작업을 처리하는 구조라 볼 수 있다.
만약 서버에 100,000개의 사용자 요청이 들어온다 했을 때
이를 개별 스레드로 다루면 어떻게 될까?for (int i = 0; i < 99_999; i++) { Thread t = new Thread(); t.start(); }스레드 하나당 약
1MB의 스택 메모리를 소비하고 (100000MB ~=100GB)
스레드의 생성 및 소멸 시 발생하는 오버헤드 또한 100,000개가 누적되며
너무 많은 개별 스레드에 따라 컨텍스트 스위칭에 들어가는 비용이 커진다.
실제 작업의 처리 시간보다 다음 스레드를 결정하는 시간이 더 큰 문제가 발생한다.
결과적으로 너무 많은 멀티스레드 때문에 OOM이 발생하거나
컨텍스트 스위칭 문제로 CPU 사용률이 100%에 가깝게 고착된다.
이로 인해 처리량이 급감하고 응답 지연이 폭증하게 되어 서비스 불능 상태가 될 것이다.

위 그림처럼 스레드 풀은 고정된 수의 스레드가 작업을 꺼내 처리하는 구조로 동작한다.
작업이 완료되면 결과가 어플리케이션으로 반환되거나 종료되며
작업을 완료한 스레드는 다시 유휴 상태가 되어, 스레드 풀에 유지되는 방식이다.
이러한 스레드의 재사용을 통해 생성과 소멸 시 발생하는 오버헤드를 줄이고
각 스레드가 차지하는 자원의 낭비를 예방하게 스레드를 사용하는 구조를
스레드 풀(Thread Pool)이라 한다.
그렇다면, Application에서 각 수행해야 할 작업들이, Thread Pool로 넘어가 스레드에서 실행되는 과정에서 어떤 방식으로 작업을 선택하고 처리할까?
어플리케이션에 전달된 각 작업들은 블로킹 큐(Blocking Queue)에 저장되며
실행을 대기하는 작업들이 FIFO로 큐에 쌓이게 된다.
이러한 작업 큐는 스레드 수보다 많은 작업이 들어올 때
자동으로 작업을 대기시키는 버퍼(buffer) 역할을 동시에 수행한다.

ExecutorService는 Thread Pool 기반의,
작업 실행을 관리해주는 고수준 스레드 실행 서비스 인터페이스이다.
public interface ExecutorService extends Executor, AutoCloseable {
...
Thread를 직접 생성해 실행 로직을 관리하는 기존 과정을 추상화하여
작업 실행, 스레드 생성, 재사용, 종료까지를 자동으로 관리하는 실행 환경을 제공한다.
해당 인터페이스는 내부적으로
Blocking QueueThread Pool을 함께 관리하며, 외부에서 전달된 작업(Runnable, Callable)을 받아
스레드 풀 내에서 효율적으로 처리하는 구조로 동작한다.

결과적으로 ExecutorServcie는 실행할 작업과 스레드 실행 관리를 분리해주는 실행 서비스이자 일종의 스레드 관리자라 할 수 있다.
ExecutorService를 사용하는 일련의 과정을 통해 아래 코드를 이해하여 보겠다.
public class ExecutorServiceEx {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.execute(() -> threadMethod("method A"));
executorService.execute(() -> threadMethod("method B"));
executorService.shutdown();
}
private static void threadMethod(String s) {
System.out.println(s + ": " + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(s + "] finished");
}
}
실행 결과는 다음과 같다. (실행마다 다른 결과가 나올 수 있다)
method A: pool-1-thread-1 method B: pool-1-thread-2 method A finished method B finished
1. ExecutorService 생성
Executors 유틸리티 클래스를 통해 ExecutorService의 구현 객체를 생성한다.
ExecutorService executorService = Executors.newFixedThreadPool(2);
이때 newFixedThreadPool() 메서드의 실제 코드를 살펴보면 다음과 같은데
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
매개변수 nThreads를 통해 넘겨준 int값을 활용하여
ThreadPoolExecutor 객체를 생성하여 반환함을 알 수 있다.
public class ThreadPoolExecutor extends AbstractExecutorService {
...
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
...
ThreadPoolExecutor 클래스는 AbstractExecutorService 추상클래스의 자손이며
public abstract class AbstractExecutorService implements ExecutorService {
...
AbstractExecutorService 추상클래스는 ExecutorService의 구현체기에
결과적으로
ExecutorService executorService = Executors.newFixedThreadPool(2);
처럼 ExecutorService를 참조변수로 하는 executorService에
스레드 풀의 크기가 2인 ExecutorService의 구현체가 할당된 것이다.
이와 함께 Executors 유틸리티 클래스는 다음과 같은 주요 메서드들을 통해
ExecutorService 인터페이스의 구현체를 만들 수 있다.
newFixedThreadPool(int nThreads)
지정한 개수(n)의 스레드를 고정으로 유지하는 풀을 생성
newCachedThreadPool()
필요한 만큼 스레드를 무한정 생성하고, 일정 시간 유휴 상태면 스레드를 제거
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
newSingleThreadExecutor()public static ExecutorService newSingleThreadExecutor() {
return newSingleThreadExecutor(defaultThreadFactory());
}
newScheduledThreadPool(int corePoolSize)public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
//overloading
public static ScheduledExecutorService newScheduledThreadPool(
int corePoolSize, ThreadFactory threadFactory) {
return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
}
2. 작업 제출
executorService.execute(() -> threadMethod("method A"));
executorService.execute(() -> threadMethod("method B"));
ExecutorService는 Rnnable, Callable을 작업 단위로 받아 실행한다.
이때 작업을 제출하는 방식에 따라서 반환값 처리, 예외처리 방식이 달라진다.
execute(Runnable)
: 오직 작업을 실행만하며 실행 결과, 예외 정보는 호출자에게 전달하지 않는다.
때문에 실행 중 예외가 발생해도 잡아내기 어려우며, 작업의 정상 종료 여부도 알 수 없다.
주로 결과가 중요하지않은 단순 비동기 작업(로그 기록, 알림 전송)에 사용한다.
submit(Runnable or Callable)
: 작업 실행이 끝나면 Future 객체를 반환한다.
Future객체를 통해 결과를 비동기로 조회하거나, 예외 여부를 확인할 수 있다.
데이터 처리, 외부 API 응답 대기, 비즈니스 로직 실행 결과와 같은
작업 결과를 확인해야 하거나 예외를 추적해야하는 경우 사용한다.
제출된 작업은 스레드 풀에 존재하는 유휴 스레드가 실행하는데
설령 스레드가 1개라도 실행에는 문제가 없다.ExecutorService executorService = Executors.newFixedThreadPool(1); executorService.execute(() -> threadMethod("method A")); executorService.execute(() -> threadMethod("method B"));단지 스레드 사용의 주 목적 중 하나인 동시성을 구현할 수 없을 뿐
작업 수행이 종료된 후 유휴 스레드가 되어 새로운 작업(method B)을 실행한다.
예시 코드에서 제출이 완료된 이후 다음 메서드를 두 개의 스레드에서 실행하게 되며
private static void threadMethod(String s) {
System.out.println(s + ": " + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(s + "] finished");
}
}
각 스레드는 스레드의 컨텍스트 스위칭이 일어나
Concurrency로 메서드가 실행된다.
3. 실행 종료 처리
executorService.shutdown();
위 코드를 main함수에서 제외하고 실행하여 보면 프로그램이 계속 실행되며 멈추지 않는다.
ExecutorService를 명시적으로 종료하지 않으면 JVM이 종료되지 않기에
모든 작업이 끝난 뒤에는 반드시 자원을 해제해야 한다.
shutdown()
: Executor의 종료를 요청하고,
현재 큐에 있는 작업은 모두 완료한 후 종료합니다.
즉, BlockingQueue에 대기 중인 작업은 유지되며, 추가 작업은 거부한다.
일반적인 ExecutorService의 종료처리 방식이다.
shutdownNow()
: 현재 대기 중인 작업은 취소하고,
실행 중인 스레드에는 인터럽트를 요청하여 가능한 한 빠르게 종료
BlockingQueue에 있는 요소를 모두 비우며, 실행 중인 스레드에 interrupt를 보낸다.
예외 상황이나 강제 종료 필요 시에 사용한다.
OOM(OutOfMemoryError)은 자바 프로그램이 사용할 수 있는 메모리를 모두 소진하여,
더 이상 객체를 생성할 수 없을 때 발생하는 오류이다.
거부 정책은 이러한 메모리 관리 정책과 관련된 것으로
ExecutorService에서 BlockingQueue가 가득 찼을 때
새로운 작업(Runnable, Callable 구현한 작업)을 어떻게 처리할 지 정하는 전략이다.
그렇다면 개발자가 직접 거부 정책을 설정할 수 있을까?
가능하다.
ExecutorService 구현체인 ThreadPoolExecutor에 RejectedExecutionHandler를 직접 구현해서 넘길 수 있다.
RejectedExecutionHandler customPolicy = (task, executor) -> {
System.out.println("사용자 정의 거부 정책: " + task.toString());
};
ExecutorService executor = new ThreadPoolExecutor(
2, // corePoolSize
4, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(10), // BlockingQueue
customPolicy // 커스텀 거부 정책
);
사용자 정의를 통한 거부 정책 설정 이외에도
기본적으로 다음과 같은 거부 정책을 설정할 수 있다.
AbortPolicy: 기본값, RejectedExecutionException 예외를 던진다
CallerRunsPolicy: 작업을 제출한 스레드가 직접 실행한다
DiscardPolicy: 조용히 그냥 버린다
DiscardOldestPolicy: 큐에서 가장 오래된 작업은 버리고, 새 작업을 추가한다