애플리케이션에서 동시성을 다루는 일은 생각보다 쉽지 않습니다. 스레드를 직접 만들기 시작하면 금방 시스템 자원이 부족해지고, 성능 저하나 예측 불가능한 문제까지 발생할 수 있습니다. 이런 위험을 줄이기 위해 가장 효과적인 방법이 바로 스레드 풀(Thread Pool)입니다.
이 글에서는 Java 표준 라이브러리에서 제공하는 다양한 스레드 풀 구현부터 Guava가 제공하는 고급 스레드 풀 기능까지 모두 정리합니다. 각 스레드 풀의 특징, 사용 목적, 구성 요소, 코드 예제까지 함께 설명하므로, 이 글을 읽고 나면 어떤 상황에서 어떤 스레드 풀을 선택해야 하는지 명확해질 것입니다.
스레드 풀(Thread Pool)의 기본 개념
Java에서 스레드는 운영체제의 리소스를 직접 사용합니다. 무작정 스레드를 생성하면 금방 리소스가 부족해지고, 오히려 작업 속도가 느려지는 상황이 생기기도 합니다. 그 이유는 운영체제가 스레드 간 문맥 전환(Context Switching)을 수행하기 때문입니다.
스레드 풀은 이런 문제를 해결하기 위한 핵심 구조입니다. 스레드를 매번 생성·소멸하지 않고, 미리 만들어둔 스레드를 재사용해 작업들을 처리합니다. 이를 통해 다음과 같은 이점을 얻을 수 있습니다.
- 스레드 개수를 일정 수준으로 제한
- 스레드 생성 비용 절감
- 작업 큐를 통해 작업 순서와 처리량 제어
- 전체 애플리케이션의 자원 안정성 확보
Java의 스레드 풀 구성 요소
Executor와 ExecutorService
Executor
Executor 인터페이스는 단 하나의 메서드 execute만 제공합니다. Runnable을 실행할 수 있는 간단한 계약입니다.
예시:
Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("Hello World"));
위 예시는 단일 스레드에서 모든 작업을 순차적으로 실행합니다.
ExecutorService
ExecutorService는 스레드 작업을 관리하기 위한 보다 풍부한 기능을 제공합니다.
- 작업 제출(submit)
- Future로 결과 가져오기
- 스레드 풀 종료 제어
예시:
ExecutorService executorService = Executors.newFixedThreadPool(10);
Future<String> future = executorService.submit(() -> "Hello World");
String result = future.get();
Callable을 사용하면 값을 반환할 수 있고, 예외 처리도 가능합니다.
ThreadPoolExecutor: 스레드 풀의 핵심 구현체
ThreadPoolExecutor는 다음 세 가지 핵심 파라미터를 기반으로 동작합니다.
- corePoolSize
- maximumPoolSize
- keepAliveTime
이 값들로 스레드가 언제 생성되고 언제 종료되는지를 제어할 수 있습니다.
newFixedThreadPool
항상 고정된 스레드 개수를 유지합니다.
ThreadPoolExecutor executor =
(ThreadPoolExecutor) Executors.newFixedThreadPool(2);
executor.submit(() -> { Thread.sleep(1000); return null; });
executor.submit(() -> { Thread.sleep(1000); return null; });
executor.submit(() -> { Thread.sleep(1000); return null; });
앞의 두 작업은 즉시 실행되고, 세 번째 작업은 큐에서 대기합니다.
newCachedThreadPool
스레드 개수 제한 없이 필요할 때마다 무제한 생성합니다.
ThreadPoolExecutor executor =
(ThreadPoolExecutor) Executors.newCachedThreadPool();
특징:
- corePoolSize = 0
- maximumPoolSize = Integer.MAX_VALUE
- keepAliveTime = 60초
- 내부적으로 SynchronousQueue 사용 (큐 크기 항상 0)
짧은 생명 주기의 작업이 많을 때 유용합니다.
newSingleThreadExecutor
단일 스레드로 모든 작업을 순차 실행합니다.
AtomicInteger counter = new AtomicInteger();
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> counter.set(1));
executor.submit(() -> counter.compareAndSet(1, 2));
작업 순서가 보장되므로 이벤트 루프 구현에 활용할 수 있습니다.
ScheduledThreadPoolExecutor: 작업 예약 실행
특정 시간 후 실행하거나, 일정 간격으로 반복 실행하는 작업에 사용합니다.
예시: 500ms 후 한 번 실행
ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
executor.schedule(() -> System.out.println("Hello World"),
500, TimeUnit.MILLISECONDS);
예시: 500ms 후 실행한 뒤 100ms 간격으로 반복
CountDownLatch lock = new CountDownLatch(3);
ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
ScheduledFuture<?> future = executor.scheduleAtFixedRate(() -> {
System.out.println("Hello World");
lock.countDown();
}, 500, 100, TimeUnit.MILLISECONDS);
lock.await(1000, TimeUnit.MILLISECONDS);
future.cancel(true);
ForkJoinPool: 재귀 작업을 위한 고성능 스레드 풀
ForkJoinPool은 작업을 여러 하위 작업으로 분할하고, 각 작업이 완료될 때까지 기다리는 구조에 적합합니다. 대표적으로 분할 정복(DFS, 트리 탐색 등)에 사용됩니다.
예시에서는 트리의 모든 값을 더하는 RecursiveTask 구현을 사용합니다.
public static class CountingTask extends RecursiveTask<Integer> {
private final TreeNode node;
public CountingTask(TreeNode node) { this.node = node; }
@Override
protected Integer compute() {
return node.value + node.children.stream()
.map(childNode -> new CountingTask(childNode).fork())
.collect(Collectors.summingInt(ForkJoinTask::join));
}
}
실행:
ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
int sum = forkJoinPool.invoke(new CountingTask(tree));
Guava의 스레드 풀 기능
Google Guava는 Java의 ExecutorService를 확장한 기능을 제공합니다.
Direct Executor
작업을 현재 스레드에서 즉시 실행합니다.
Executor executor = MoreExecutors.directExecutor();
AtomicBoolean executed = new AtomicBoolean();
executor.execute(() -> {
Thread.sleep(500);
executed.set(true);
});
별도 스레드를 만들지 않고, 단순 실행만 필요할 때 적합합니다.
Exiting Executor Service
JVM 종료 시 남아 있는 작업 때문에 종료가 지연되는 문제를 해결합니다. 데몬 스레드를 사용하고, 종료 훅(shutdown hook)을 등록합니다.
ThreadPoolExecutor executor =
(ThreadPoolExecutor) Executors.newFixedThreadPool(5);
ExecutorService executorService =
MoreExecutors.getExitingExecutorService(
executor, 100, TimeUnit.MILLISECONDS);
Listening Decorator (ListenableFuture)
Future 완료 시 콜백을 등록할 수 있는 ListenableFuture를 제공합니다.
예시: 두 작업 결과를 조합하기
ListeningExecutorService listeningExecutorService =
MoreExecutors.listeningDecorator(executorService);
ListenableFuture<String> future1 =
listeningExecutorService.submit(() -> "Hello");
ListenableFuture<String> future2 =
listeningExecutorService.submit(() -> "World");
String greeting = Futures.allAsList(future1, future2).get()
.stream()
.collect(Collectors.joining(" "));
어떤 스레드 풀을 선택해야 할까?
이 글에서는 Java의 다양한 스레드 풀과 Guava의 확장 기능을 정리했습니다. 핵심은 다음과 같습니다.
- 고정된 작업량: newFixedThreadPool
- 짧은 작업이 많고 개수를 예측하기 어렵다면: newCachedThreadPool
- 작업 순서가 중요하다면: newSingleThreadExecutor
- 예약/반복 작업: ScheduledThreadPoolExecutor
- 재귀형 분할 정복 알고리즘: ForkJoinPool
- JVM 종료와 안정성 고려: Guava Exiting Executor
- Future를 확장한 이벤트 기반 처리: ListenableFuture
스레드 풀은 애플리케이션 성능과 안정성을 결정짓는 핵심 요소입니다. 위 개념을 이해하고 적절한 스레드 풀을 선택하면, 시스템 리소스를 효율적으로 사용하면서 성능 병목을 줄일 수 있습니다.

'JAVA' 카테고리의 다른 글
| 엔터프라이즈 AI 전략, 왜 Python이 아니라 Java부터 시작해야 할까? (0) | 2025.12.23 |
|---|---|
| MyBatis Dynamic SQL 완전 정리: 타입 안전하게 SQL을 생성하는 방법 (0) | 2025.12.16 |
| Java 25의 Compact Object Headers(객체 헤더 압축) 완전 정복 - JEP 519 기반 메모리 절감 및 성능 향상 분석 (0) | 2025.12.02 |
| Java 25 완전 정복 가이드: 새로운 기능으로 배우는 생산성과 보안성의 진화 (0) | 2025.10.13 |
| Java, 어떻게 더 성장할까? – Brian Goetz가 말하는 ‘확장 가능한 언어’와 Witness 개념 (0) | 2025.09.23 |