
스레드를 직접 new Thread()로 생성하고 관리하다 보니
생성 비용, 반환값 회수, 스레드 재사용 같은 문제가 계속 발생했다.
김영한 쌤의 자바 고급1편 강의로 Executor 프레임워크를 공부하면서
스레드 풀 기반으로 이 문제들을 정리했고
그 과정에서 시도했던 방법들과 최종적으로 이해한 내용을 기록해본다.

문제 상황
스레드를 직접 다룰 때 크게 세 가지가 걸림돌이었음
첫째, 스레드 생성 비용이 큼
스레드 하나를 만들 때마다 메모리(스택)를 할당하고 OS 커널에 스레드를 등록하는 비용이 드는데
요청이 들어올 때마다 스레드를 새로 만들면 이 비용이 반복됨
둘째, 스레드 관리가 어려움
지금 몇 개의 스레드가 돌고 있는지
언제 종료해야 하는지를 직접 추적해야 함
스레드가 무한정 늘어나면 시스템 자원이 고갈됨
셋째, Runnable은 반환값이 없음
run() 메서드는 반환 타입이 void라서 작업 결과를 받아오려면 별도의 공유 변수와 동기화 코드를 직접 짜야 했음
게다가 run()은 체크 예외를 던질 수도 없음
아래는 스레드를 직접 생성했을 때의 코드임
package thread.executor;
public class DirectThreadMain {
public static void main(String[] args) throws InterruptedException {
// 작업마다 스레드를 직접 생성
Thread thread1 = new Thread(new MyTask("작업1"));
Thread thread2 = new Thread(new MyTask("작업2"));
thread1.start();
thread2.start();
// 결과를 받으려면 join 후 공유 변수를 별도로 확인해야 함
thread1.join();
thread2.join();
System.out.println("메인 종료");
}
static class MyTask implements Runnable {
private final String name;
public MyTask(String name) {
this.name = name;
}
@Override
public void run() {
// 반환값을 돌려줄 방법이 없음
System.out.println(name + " 실행, thread=" + Thread.currentThread().getName());
}
}
}
배운 점 스레드를 직접 생성하는 방식은 "생성-실행-관리-회수"를 전부 개발자가 떠안는 구조인데 스레드는 만드는 것보다 재사용하고 관리하는 게 핵심이라는 걸 여기서 처음 체감함
1번째 시도: ExecutorService 도입
시도한 이유
자바는 이런 문제를 해결하려고 java.util.concurrent에 Executor 프레임워크를 제공한다.
핵심은 "작업 등록"과 "작업 실행"을 분리하는 것인데
개발자는 작업(Runnable, Callable)만 등록하고, 실제로 어떤 스레드가 언제 실행할지는 프레임워크가 스레드 풀로 관리한다.
적용 방법
ExecutorService에 작업을 submit하면 미리 만들어 둔 스레드 풀의 스레드가 작업을 가져가 실행한다.
Callable을 넘기면 반환값을 Future로 받을 수 있음
package thread.executor;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class ExecutorServiceMain {
public static void main(String[] args) throws Exception {
// 스레드 2개짜리 풀 생성
ExecutorService es = Executors.newFixedThreadPool(2);
// Callable: 반환값이 있는 작업
Future<Integer> future1 = es.submit(new SumTask(1, 50));
Future<Integer> future2 = es.submit(new SumTask(51, 100));
// Future.get()은 작업이 끝날 때까지 블로킹하며 결과를 기다림
Integer sum1 = future1.get();
Integer sum2 = future2.get();
System.out.println("sum1 + sum2 = " + (sum1 + sum2));
es.close(); // Java 19+ : 우아한 종료
}
static class SumTask implements Callable<Integer> {
private final int start;
private final int end;
public SumTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
public Integer call() {
int sum = 0;
for (int i = start; i <= end; i++) {
sum += i;
}
return sum;
}
}
}
Runnable과 달리 Callable은 반환값을 주고 체크 예외도 던질 수 있음
작업 등록 시점에 바로 결과가 나오는 게 아니라, 미래 시점에 결과를 담아 주는 약속이 Future임
future.get()을 호출하면 그 작업이 끝날 때까지 기다렸다가 결과를 반환함
한 가지 주의할 점은 ExecutorService.close()가 Java 19부터 추가된 메서드라는 것인데
현재 나는 레거시 고도화로 Java 17을 쓰는 경우라 close()가 없으므로
아래 최종 해결 섹션의 shutdown() + awaitTermination() 우아한 종료 패턴을 대신 써야 한다.
이 글의 예제에 등장하는 close()는 전부 이 우아한 종료 패턴으로 치환할 수 있음
한계
ExecutorService와 팩토리 메서드는 편하지만, 내부적으로 스레드 풀이 정확히 어떻게 동작하는지 모르고 쓰면 실무에서 위험함 Executors.newFixedThreadPool 같은 팩토리가 어떤 값을 세팅하는지 알아야 상황에 맞게 튜닝할 수 있음
배운 점 Executor 프레임워크의 본질은 "작업(등록)과 실행(스레드)의 분리"임. 개발자는 무엇을 할지만 정하고, 스레드 관리는 프레임워크에 위임하는 구조임
2번째 시도: ThreadPoolExecutor로 풀 직접 제어
시도한 이유
Executors의 팩토리 메서드는 결국 ThreadPoolExecutor를 특정 설정으로 생성해 주는 지름길임
스레드 풀이 어떻게 동작하는지 이해하려면 ThreadPoolExecutor의 파라미터를 직접 봐야 함
적용 방법
ThreadPoolExecutor의 핵심 파라미터는 네 가지임
package thread.executor;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolExecutorMain {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // corePoolSize: 기본 스레드 수
4, // maximumPoolSize: 최대 스레드 수
3000, TimeUnit.MILLISECONDS, // keepAliveTime: 초과 스레드 생존 시간
new ArrayBlockingQueue<>(2) // 작업 대기 큐 (크기 2)
);
for (int i = 1; i <= 6; i++) {
String taskName = "task" + i;
executor.execute(() -> {
System.out.println(taskName + " 실행, thread="
+ Thread.currentThread().getName());
sleep(1000);
});
}
executor.close();
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
각 파라미터의 의미는 다음과 같다.
corePoolSize : 풀이 유지하는 기본 스레드 수
maximumPoolSize : 풀이 늘릴 수 있는 최대 스레드 수
keepAliveTime : corePoolSize를 초과해서 만들어진 스레드가 놀 때 얼마나 살아있다가 정리될지를 정함
BlockingQueue : 스레드가 바쁠 때 작업이 대기하는 큐
스레드 풀에 작업이 들어올 때의 동작 순서가 중요한데
먼저 corePoolSize만큼 스레드를 만들어 작업을 처리함
core 스레드가 전부 바쁘면 작업을 큐에 넣음
큐까지 가득 차면 그제서야 maximumPoolSize까지 스레드를 추가로 만듦
스레드도 최대치이고 큐도 꽉 차면 그때는 작업을 거부함
여기서 헷갈렸던 점은 "큐가 먼저 차고 나서야 최대 스레드까지 늘어난다"는 순서인데
큐가 남아있으면 maximumPoolSize까지 스레드가 늘어나지 않음
배운 점 Executors.newFixedThreadPool(n)은 corePoolSize와 maximumPoolSize를 둘 다 n으로 두고, 큐 크기가 무제한(LinkedBlockingQueue)인 ThreadPoolExecutor임
이 사실을 알고 나니 팩토리 메서드가 더 이상 마법처럼 보이지 않음👀
3번째 시도: Executors 팩토리 전략 이해
시도한 이유
실무에서는 상황에 맞는 스레드 풀 전략을 골라야하는데 Executors가 제공하는 대표 팩토리 메서드가 각각 어떤 ThreadPoolExecutor 설정인지 정리함
적용 방법
package thread.executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorStrategyMain {
public static void main(String[] args) {
// 1) 고정 풀: 스레드 수를 n개로 고정. CPU/메모리 자원이 예측 가능
ExecutorService fixed = Executors.newFixedThreadPool(4);
// 2) 캐시 풀: core 0, max 무제한. 요청이 많으면 스레드를 계속 늘림
ExecutorService cached = Executors.newCachedThreadPool();
// 3) 단일 풀: 스레드 1개로 순차 처리
ExecutorService single = Executors.newSingleThreadExecutor();
fixed.close();
cached.close();
single.close();
}
}
newFixedThreadPool(n)은 스레드 수를 n으로 고정하는 안정적인 전략임
스레드 수가 정해져 있어 자원 사용을 예측할 수 있지만, 요청이 몰리면 큐에 계속 쌓여 응답이 늦어질 수 있음
newCachedThreadPool()은 corePoolSize가 0이고 maximumPoolSize가 사실상 무제한임
SynchronousQueue를 써서 작업을 큐에 쌓지 않고 바로 스레드를 만들어 처리함
처리 속도가 빠르지만, 요청이 폭증하면 스레드가 무한정 생겨 시스템이 위험해질 수 있음
newSingleThreadExecutor()는 스레드 1개로 작업을 순서대로 처리함
순차 실행이 보장돼야 하는 경우에 씀
실무에서는 이 팩토리 메서드를 그대로 쓰기보다,
ThreadPoolExecutor를 직접 생성해 큐 크기와 최대 스레드를 명확히 제한하는 걸 권장한다.
고정 풀은 큐가 무제한이라 요청이 쌓여 장애가 날 수 있고, 캐시 풀은 스레드가 무제한이라 자원이 고갈될 수 있기 때문임
배운 점 "빠른 처리 vs 안정적인 자원 관리" 는 트레이드오프임
어느 쪽도 공짜가 아니며, 서비스 특성에 맞게 큐 크기와 스레드 수를 직접 정하는 게 안전함

최종 해결: 우아한 종료와 예외 정책
스레드 풀을 잘 만드는 것만큼 잘 끝내는 것도 중요함.
애플리케이션을 종료할 때 진행 중인 작업은 마무리하고, 새 작업은 더 받지 않아야 함.
이걸 우아한 종료(graceful shutdown)라고 한다.
ExecutorService의 종료 메서드는 다음과 같음
shutdown()은 새 작업은 거부하되, 이미 큐에 있거나 실행 중인 작업은 끝까지 처리하고 종료함
shutdownNow()는 실행 중인 작업에 인터럽트를 걸고 큐에 대기 중인 작업은 반환하며 즉시 종료를 시도함
awaitTermination()은 지정한 시간 동안 종료를 기다림
강의에서 소개한 우아한 종료 패턴은 아래와 같음
먼저 shutdown()으로 정상 종료를 시도하고, 시간 안에 끝나지 않으면 shutdownNow()로 강제 종료하는 방식
package thread.executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
public class GracefulShutdown {
static void shutdownAndAwait(ExecutorService es) {
// 1) 새 작업은 받지 않고, 기존 작업은 처리하도록 요청
es.shutdown();
try {
// 2) 10초간 정상 종료를 기다림
if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
// 3) 시간 초과 시 강제 종료
es.shutdownNow();
// 4) 강제 종료 후에도 한 번 더 대기
if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
System.out.println("서비스 정상 종료 실패");
}
}
} catch (InterruptedException e) {
// 현재 스레드가 인터럽트되면 강제 종료
es.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
작업이 큐에 가득 찼는데 스레드도 최대치라 더 받을 수 없을 때는 거부 정책(RejectedExecutionHandler)이 동작한다.
ThreadPoolExecutor가 제공하는 기본 정책은 네 가지임
AbortPolicy는 기본값으로, 작업을 거부하면서 RejectedExecutionException을 던짐
DiscardPolicy는 새 작업을 조용히 버림
CallerRunsPolicy는 작업을 제출한 스레드(호출자)가 직접 작업을 실행함
호출자가 직접 처리하는 동안 새 요청이 느려지므로 자연스럽게 유량이 조절되는 효과가 있음
마지막으로 RejectedExecutionHandler를 직접 구현해 사용자 정의 정책을 만들 수도 있음
package thread.executor;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class RejectPolicyMain {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1,
0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
// 호출자 스레드가 직접 실행하는 정책
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 1; i <= 5; i++) {
String taskName = "task" + i;
executor.execute(() -> {
System.out.println(taskName + ", thread="
+ Thread.currentThread().getName());
sleep(1000);
});
}
executor.close();
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
ThreadPoolExecutor (Java SE 17 & JDK 17)
All Implemented Interfaces: Executor, ExecutorService Direct Known Subclasses: ScheduledThreadPoolExecutor An ExecutorService that executes each submitted task using one of possibly several pooled threads, normally configured using Executors factory method
docs.oracle.com
배운 점
- Executor 프레임워크의 핵심은 "작업 등록과 실행의 분리"다.
개발자는 Runnable/Callable로 무엇을 할지만 정하고, 스레드 생성·재사용·관리는 스레드 풀에 위임함 - 스레드 풀 동작 순서는 core 스레드 → 큐 → max 스레드 → 거부 순 이다.
"큐가 먼저 차야 max까지 늘어난다"는 순서를 놓치면 튜닝을 잘못하게 됨 - Executors 팩토리 메서드는 편하지만 큐나 스레드가 무제한인 경우가 있어 실무에선 위험할 수 있다.
ThreadPoolExecutor를 직접 생성해 큐 크기와 최대 스레드를 명시적으로 제한하고, 상황에 맞는 거부 정책과 우아한 종료를 함께 설계하는 게 안전함.
'📍 Lαɴɢυαɢe > Jαvα' 카테고리의 다른 글
| 백엔드 개발자가 반드시 잡는 예외 모음 (feat. IDE가 시키는 try~catch 탈출) (0) | 2026.08.10 |
|---|---|
| 네트워크 타임아웃 예외가 발생하는 원리 (feat. ConnectException, SocketTimeoutException) (0) | 2026.08.09 |
| Java7의 날짜계산 : Date, Calendar, SimpleDateFormat(2) (0) | 2024.08.16 |
| Java7의 날짜계산 : Date, Calendar, SimpleDateFormat(1) (0) | 2024.08.16 |
| Java의 상수, 매직넘버란 ? (0) | 2024.04.28 |