List
Search
1. 들어가기 앞서
•
자바의 멀티스레딩은 단순한 Thread 직접 사용에서 시작해 Executor 프레임워크로 발전했고, 오늘날 사실상 표준임
•
스프링 등 엔터프라이즈 프레임워크 전반에서 핵심적으로 활용됨
참고: 자바 19+/21+ 환경에서는 가상 스레드와의 조합을 함께 고려해야 함
2. 스레드 직접 사용의 문제점
2.1. 스레드 생성 비용으로 인한 성능 문제
•
메모리 할당
◦
각 스레드는 독립적인 호출 스택(Call Stack) 공간 필요
◦
일반적으로 스레드 하나당 1MB 이상의 메모리 사용
◦
대량의 스레드 생성 시 메모리 고갈 위험
•
운영체제 수준의 오버헤드
◦
시스템 콜(System Call) 을 통한 커널 수준 작업
◦
CPU와 메모리 리소스 소모
◦
운영체제 스케줄러에 의한 관리 오버헤드
2.2. 스레드 관리 문제
•
자원 한계 문제
◦
서버의 CPU, 메모리는 유한한 자원임.
◦
갑작스러운 요청 폭 증시, 시스템이 감당하기 어려운 수의 스레드가 생성되면 시스템 자원이 고갈되어 다운될 수 있음
•
생명주기 관리 문제
◦
애플리케이션 종료 시: 실행 중인 모든 스레드의 안전한 종료 필요
◦
긴급 종료 시: 인터럽트 신호를 통한 강제 종료 메커니즘 필요
◦
스레드 추적: 현재 실행 중인 스레드들의 상태 관리
2.3. Runnable 인터페이스의 불편함
•
반환값 부재 문제
•
Runnable.run()은 반환 값이 void이라, 실행 결과를 직접 반환할 수 없음
•
결과를 받기 위한 복잡한 별도 메커니즘(join(), 멤버 변수)이 필요
// 복잡한 결과 수집 방식
class SumTask implements Runnable {
private int result; // 결과 저장용 필드
private final int start, end;
@Override
public void run() {
result = calculateSum(start, end);
}
public int getResult() {
return result; // 별도 메서드로 결과 조회
}
}
// 사용 시
SumTask task = new SumTask(1, 100);
Thread thread = new Thread(task);
thread.start();
thread.join(); // 완료 대기
int result = task.getResult(); // 결과 수집
Java
복사
•
예외 처리 제약
◦
체크 예외를 던질 수없음. 강제 로 내부 처리 필요
public void run() {
try {
Thread.sleep(1000); // 체크 예외를 내부에서 처리해야 함
} catch (InterruptedException e) {
// 강제로 내부 처리
}
// throws InterruptedException 불가능
}
Java
복사
3. Executor 프레임워크
3.1. 주요 구성 요소 및 인터페이스
•
주요 인터페이스 계층 구조
◦
Executor (최상위)
◦
ExecutorService (실무에서 주로 사용)
◦
ThreadPoolExecutor (핵심 구현체)
•
Executor 인터페이스
public interface Executor {
void execute(Runnable command);
}
Java
복사
•
ExecutorService 인터페이스
public interface ExecutorService extends Executor {
// 작업 제출 메서드들
Future<?> submit(Runnable task);
<T> Future<T> submit(Callable<T> task);
// 생명주기 관리
void shutdown();
List<Runnable> shutdownNow();
boolean awaitTermination(long timeout, TimeUnit unit);
// 컬렉션 처리
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks);
<T> T invokeAny(Collection<? extends Callable<T>> tasks);
}
Java
복사
•
ThreadPoolExecutor
◦
스레드 풀과 작업 보관을 위한 BlockingQueue로 구성되어 있음
◦
이를 통해 생산자-소비자 문제까지 해결
3.2. Runnable의 한계점 개선 (1) Callable
•
Callable
◦
결과를 보관할 별도의 필드를 만들지 않고도 결과를 return할 수 있어 코드가 간결해짐
public interface Callable<V> {
V call() throws Exception; // 반환값과 예외 선언 가능
}
Java
복사
•
Runnable vs Callable 비교
구분 | Runnable | Callable<V> |
메서드 시그니처 | void run() | V call() throws Exception |
반환값 | 없음 (void) | 제네릭 타입 V 반환 |
예외 처리 | 체크 예외 던질 수 없음 | throws Exception 선언
(체크 예외 던지기 가능) |
결과 수집 | 별도 메커니즘 필요 | 직접 return 가능 |
•
Callable 사용 예시
// 간결한 Callable 구현
Callable<Integer> sumTask = () -> {
Thread.sleep(1000); // 체크 예외 자동 처리
return IntStream.rangeClosed(1, 100).sum(); // 직접 반환
};
// ExecutorService와 함께 사용
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(sumTask);
Integer result = future.get(); // 결과 직접 수집
Java
복사
3.3. Runnable의 한계점 개선 (2) Future
Future은 비동기 작업의 미래 결과
"아직 완료되지 않은 작업의 미래 결과" 를 나타내는 핵심 개념
•
비동기적 작업 제출 메커니즘
◦
submit(Callable)을 호출하면 Callable의 결과 대신 Future 객체가 즉시 반환됨
◦
Future은 작업의 미래 결과를 담고 있음
◦
요청 스레드는 Future를 즉시 반환받아 블로킹 없이 다른 작업 수행 가능
◦
결과가 필요할 때 Future.get() 를 호출하며, 이 메서드는 작업이 완료될 때까지 요청 스레드를 블로킹 상태로 만듬
•
병렬 처리 극대화
◦
논블로킹 특성으로 인해 여러 작업을 동시에 요청 가능하며, 스레드풀의 스레드에 의해 동시 수행 가능
•
예시 코드 1)
ExecutorService executor = Executors.newSingleThreadExecutor();
// 1. 작업 제출 (논블로킹)
Future<String> future = executor.submit(() -> {
Thread.sleep(3000); // 3초 소요 작업
return "작업 완료!";
});
// 2. 즉시 반환되어 다른 작업 수행 가능
System.out.println("다른 작업 수행 중...");
doOtherWork();
// 3. 필요할 때 결과 조회 (블로킹)
String result = future.get(); // 3초 후 결과 반환
Java
복사
•
예시 코드 2) 병렬성 극대화
// 여러 작업을 동시에 제출
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 10; i++) {
final int taskId = i;
Future<Integer> future = executor.submit(() -> heavyCalculation(taskId));
futures.add(future); // 즉시 반환되어 다음 작업 제출 가능
}
// 모든 결과 수집
List<Integer> results = new ArrayList<>();
for (Future<Integer> future : futures) {
results.add(future.get()); // 각 작업 완료 시점에 결과 수집
}
Java
복사
•
Future 인터페이스 주요 메서드
메서드 | 기능 | 특징 |
V get() | 결과 대기 및 반환 | 블로킹, 무한 대기 |
V get(timeout, unit) | 시간 제한 대기 | 블로킹, TimeoutException 가능 |
boolean isDone() | 완료 상태 확인 | 논블로킹, 즉시 반환 |
boolean cancel(boolean) | 작업 취소 | 취소 가능 여부에 따라 결정 |
boolean isCancelled() | 취소 여부 확인 | 논블로킹 |
•
예외 처리 메커니즘
◦
작업 스레드에서 예외가 발생하면 Future은 그 예외를 담고 있음
◦
Future.get() 호출 시, ExecutionException 를 던짐
◦
e.getCause() 로 원본 예외 확인 가능
Future<String> future = executor.submit(() -> {
if (someCondition) {
throw new IllegalStateException("작업 실패");
}
return "성공";
});
try {
String result = future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // 원본 예외 추출
if (cause instanceof IllegalStateException) {
// 구체적인 예외 처리
}
}
Java
복사
•
시간 제한 대기
◦
get(long timeout, TimeUnit unit) 메서드를 사용하여 지정된 시간 동안만 결과를 기다릴 수 있음
◦
시간이 초과되면 TimeoutException을 발생시켜 무한 대기하는 상황을 방지 가능
try {
// 최대 5초 대기
String result = future.get(5, TimeUnit.SECONDS);
System.out.println("결과: " + result);
} catch (TimeoutException e) {
System.out.println("시간 초과로 작업을 취소합니다.");
future.cancel(true); // 실행 중인 스레드에 인터럽트
} catch (ExecutionException e) {
System.out.println("실행 오류: " + e.getCause().getMessage());
}
Java
복사
•
작업 취소 기능
◦
cancel(boolean mayInterruptIfRunning) 메서드를 통해 아직 완료되지 않은 작업을 취소할 수 있음
◦
필요에 따라 실행 중인 스레드에 인터럽트를 걸어 중단할 수도 있음
Future<String> future = executor.submit(() -> {
for (int i = 0; i < 1000; i++) {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException("작업이 취소되었습니다.");
}
// 무거운 작업 수행
heavyWork(i);
}
return "완료";
});
// 3초 후 취소
Thread.sleep(3000);
boolean cancelled = future.cancel(true); // 인터럽트로 강제 취소
Java
복사
Executor 프레임워크는 Callable과 Future를 통해 마치 단일 스레드 환경에서 일반적인 메서드를 호출하고 결과를 받는 것처럼 직관적이고 깔끔하게 멀티스레드 작업을 처리할 수 있게 함
3.4. 스레드 풀
•
복습 내용
◦
자바는 Executor 인터페이스를 확장한 ExecutorService 를 통해 스레드 풀의 기능을 제공
◦
ExecutorService의 기본 구현체는 ThreadPoolExecutor입니다
•
ThreadPoolExecutor 구조
◦
스레드 풀은 생산자-소비자 패턴을 기반으로 설계되어 있음
[요청 스레드] → [작업 큐] → [스레드 풀] → [작업 처리]
(생산자). (BlokingQueue) (소비자)
Plain Text
복사
•
핵심 구성 요소
◦
스레드 풀 (Pool)
▪
미리 생성된 스레드들이 대기 상태 (WAITING)로 관리
▪
작업이 요청되면 풀에서 스레드를 꺼내 작업을 처리
▪
작업이 완료되면 스레드를 종료하지 않고 다시 풀에 반납하여 재사용
◦
작업 큐 (BlockingQueue)
▪
작업을 임시로 보관하는 공간 (처리 대기 중인 작업들 보관)
▪
일반 큐가 아닌 BlockingQueue를 사용하여 생산자-소비자 문제를 해결
◦
스레드 관리자
▪
스레드 생성/제거 정책 관리
•
ThreadPoolExecutor 핵심 매개변수
public ThreadPoolExecutor(
int corePoolSize, // 풀에서 관리되는 기본 스레드 수
int maximumPoolSize, // 풀에서 관리될 수 있는 최대 스레드 수
long keepAliveTime, // corePoolSize를 초과하여 생성된 스레드(초과 스레드) 생존 시간
TimeUnit unit, // 시간 단위
BlockingQueue<Runnable> workQueue, // 작업 큐
RejectedExecutionHandler handler // 거절 정책
)
Java
복사
•
동작 알고리즘
1.
현재 스레드 수 < corePoolSize: 새 스레드 생성하여 작업 처리
2.
corePoolSize 도달: 작업을 큐에 저장
3.
큐 가득참 & 현재 스레드 수 < maximumPoolSize: 초과 스레드 생성
4.
maximumPoolSize 도달: 거절 정책(RejectedExecutionHandler) 실행
4. ExecutorService의 우아한 종료 (Graceful Shutdown)
•
ExecutorService는 자바의 Executor 프레임워크의 핵심 인터페이스로, 작업 제출 및 스레드 풀 제어 기능을 제공
•
서비스의 안정적인 운영을 위해서는 고객의 주문 처리와 같은 진행 중인 작업을 안전하게 마무리하고 종료하는 우아한 종료(graceful shutdown) 방식이 매우 중요
4.1. 주요 메서드
•
주요 종료 메서드
메서드 | 동작 방식 | 특징 |
shutdown() | 새로운 작업 거절, 기존 작업 완료 후 종료 | 논 블로킹 |
shutdownNow() | 실행 중인 작업 중단, 대기 작업 반환 | 논 블로킹 |
awaitTermination() | 지정 시간 동안 종료 대기 | 블로킹 |
close() | Java 19+, shutdown() 후 장시간 대기 (최대 1일) | 논블로킹 (shutdown)
+ 블로킹 (장시간 대기) |
•
상태 확인 메서드
◦
isShutdown(): 종료 명령이 내려졌는지 확인
◦
isTerminated(): shutdown() 또는 shutdownNow() 호출 후, 모든 작업이 완료되었는지 확인
4.2. 권장 종료 패턴
•
2단계 접근법을 통해 종료 처리
1.
shutdown()우아한 종료를 시도
2.
정해진 시간 내에 완료되지 않으면 shutdownNow()를 통해 강제 종료
•
전략 구현 순서
1.
es.shutdown() 호출
•
새로운 작업은 받지 않도록 함 (논 블로킹)
2.
es.awaitTermination(timeout, unit) 호출
•
main 스레드(호출 스레드)가 지정된 시간 동안 모든 작업이 완료되기를 블로킹 상태로 기다림
3.
시간 초과 시 강제 종료
•
만약 awaitTermination이 false를 반환(시간 초과로 인해 정상 종료 실패)하면, es.shutdownNow()를 호출하여 강제 종료를 시도
•
이때 작업 중인 스레드에 인터럽트가 발생
4.
강제 종료 대기
a.
강제 종료 후에도 정리 작업이 필요할 수 있음
b.
다시 한번 awaitTermination을 짧게 호출하여 서비스가 실제로 종료되었는지 확인
c.
그래도 종료되지 않으면 로그를 남겨 개발자에게 문제를 인지
public void gracefulShutdown(ExecutorService executor) {
try {
// 1단계: 우아한 종료 시도
executor.shutdown();
// 2단계: 지정 시간 동안 완료 대기
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
// 3단계: 강제 종료
List<Runnable> pendingTasks = executor.shutdownNow();
System.out.println("강제 종료됨. 미완료 작업 수: " + pendingTasks.size());
// 4단계: 강제 종료 후 재확인
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
System.err.println("스레드풀이 완전히 종료되지 않았습니다.");
}
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
Java
복사
4.3. 종료 시나리오별 동작
•
shutdown() - 처리 중인 작업이 있는 경우
1.
새로운 요청 거절 (RejectedExecutionException 발생)
2.
실행 중인 작업 완료 대기
3.
큐에 대기 중인 작업도 모두 처리
4.
모든 작업 완료 후 자원 정리
•
shutdownNow() - 즉시 강제 종료
1.
새로운 요청 거절
2.
큐 비우고 대기 작업을 List로 반환
3.
실행 중인 스레드에 인터럽트 발생
4.
자원 정리
4.4. Java19+ 업데이트 내용
•
ExecutorService가 AutoCloseable을 구현
•
try-with-resources에서 close() 호출 시 내부적으로 종료 대기(최대 1일)
•
긴 수명(애플리케이션 전체) 풀이면 close()의 대기 특성 때문에 try-with-resources 남용은 지양.
try (ExecutorService es = Executors.newFixedThreadPool(8)) {
// 작업 제출
} // 여기서 close()가 호출되며 내부적으로 종료를 기다림
Java
복사
5. 스레드풀 관리 전략
5.1 FixedThreadPool (고정 풀 전략)
•
스레드 수 고정: 일정한 스레드 수 유지
•
무제한 큐: 큐 크기 제한 없음
•
예측 가능한 자원 사용: 안정적인 메모리, CPU 사용량
ExecutorService executor = Executors.newFixedThreadPool(10);
// corePoolSize = maximumPoolSize = 10
// workQueue = LinkedBlockingQueue (무제한)
Java
복사
•
장점
◦
안정적, 자원 예측 용이
•
주의
◦
기본 큐가 사실상 무제한이라 레이턴시/메모리 증가 위험(큐 길이가 계속 늘어남)
•
적용 시나리오
◦
안정성이 중요한 시스템
◦
요청량이 예측 가능한 서비스
◦
초기 운영 단계의 서비스
5.2 CachedThreadPool (캐시 풀 전략)
•
기본 스레드 없음: 평상시 스레드 유지하지 않음
•
즉시 스레드 생성: 요청 시 바로 새 스레드 생성
•
빠른 응답: 큐를 거치지 않고 즉시 처리
ExecutorService executor = Executors.newCachedThreadPool();
// corePoolSize = 0
// maximumPoolSize = Integer.MAX_VALUE (무제한)
// workQueue = SynchronousQueue (저장 공간 없음)
Java
복사
•
장단점
장점 | 단점 |
매우 빠른 처리 | 시스템 다운 위험 |
유연한 확장 | 자원 사용량 예측 불가 |
자원 최대 활용 | 메모리 고갈 가능 |
•
적용 시나리오
◦
빠른 응답이 중요한 시스템
◦
요청량 변동이 큰 서비스
◦
충분한 서버 자원 확보된 환경
5.3 사용자 정의 전략
•
corePoolSize, maximumPoolSize, BlockingQueue를 직접 조합
•
세분화된 전략 활용 가능
◦
일반적인 상황에서는 고정 크기로 안정적으로 운영
◦
요청이 갑자기 증가하면 긴급하게 스레드를 추가(최대 스레드) 투입하여 대응
◦
시스템이 감당할 수 없을 때는 요청을 거절
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // corePoolSize: 평상시 5개 스레드
20, // maximumPoolSize: 최대 20개 스레드
60L, // keepAliveTime: 초과 스레드 60초 생존
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100), // 큐 크기 100 제한
new ThreadPoolExecutor.CallerRunsPolicy() // 거절 시 호출자가 직접 실행
);
Java
복사
5.4 전략 비교표
전략 | corePoolSize/
maximumPoolSize | 큐 (Queue) | 주요 특징 |
고정 풀 | 고정된 N | 무한대 (LinkedBlockingQueue) | 안정적이지만 응답 지연 가능 |
캐시 풀 | 0 / 무제한 | 저장 공간 없음 (SynchronousQueue) | 빠르고 유연하지만 시스템 다운 위험 |
사용자 정의 | Core + Max 제한 | 유한대 (ArrayBlockingQueue) | 단계별 대응으로 성능과 안정성 균형 |
6. 작업 거절 정책 (RejectedExecutionHandler)
•
스레드풀이 maximumPoolSize에 도달하고 큐까지 가득 찬 상황에서 새로운 작업 요청을 어떻게 처리할지 결정하는 정책
6.1. 표준 거절 정책
정책 | 동작 | 적용 상황 |
AbortPolicy (기본) | RejectedExecutionException 발생 | 예외 처리로 요청 거절 명시 |
DiscardPolicy | 요청 조용히 무시 | 로그 없이 요청 폐기 |
DiscardOldestPolicy | 가장 오래된 요청 제거 후 새 요청 추가 | 최신 요청 우선 처리 |
CallerRunsPolicy | 요청 스레드가 직접 실행 | 자연스러운 백프레셔(backpressure) |
6.2 각 정책 상세 설명
•
AbortPolicy (기본 정책)
◦
거절 시 RejectedExecutionException 런타임 예외 발생
◦
개발자가 직접 예외 처리 로직 구현 필요
◦
명확한 실패 처리 가능
// 기본 정책으로 별도 설정 없이 사용
ThreadPoolExecutor executor = new ThreadPoolExecutor(...);
// RejectedExecutionException 발생
Java
복사
•
DiscardPolicy
◦
거절된 작업을 조용히 버림
◦
예외 발생하지 않음
◦
로그 없이 작업 손실 발생 가능
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
Java
복사
•
CallerRunsPolicy
◦
작업을 제출한 스레드가 직접 실행
◦
생산 속도 자동 조절 (백프레셔 효과)
◦
작업 손실 방지
◦
호출자 스레드 차단으로 자연스러운 속도 제어
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
Java
복사
•
DiscardOldestPolicy
◦
큐에서 가장 오래된 작업 제거
◦
새로운 작업을 큐에 추가
◦
최신 작업 우선 처리
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy());
Java
복사
•
사용자 정의 거절 정책
executor.setRejectedExecutionHandler(new RejectedExecutionHandler() {
private final AtomicLong rejectedCount = new AtomicLong(0);
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
long count = rejectedCount.incrementAndGet();
// 로깅
logger.warn("작업 거절됨. 총 거절 횟수: {}", count);
// 메트릭 수집
meterRegistry.counter("thread.pool.rejected").increment();
// 알림 또는 대체 처리 로직
if (count % 100 == 0) {
alertService.sendAlert("스레드풀 거절 횟수가 " + count + "회에 도달했습니다.");
}
}
})
Java
복사