Search

멀티 스레드와 동시성 (6) 스레드풀과 Executor 프레임워크

Tags
Study
Java
Last edited time
2025/10/24 07:49
2 more properties
Search
멀티 스레드와 동시성 (6) 스레드풀과 Executor 프레임워크
Study
Java
멀티 스레드와 동시성 (6) 스레드풀과 Executor 프레임워크
Study
Java

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 프레임워크는 CallableFuture를 통해 마치 단일 스레드 환경에서 일반적인 메서드를 호출하고 결과를 받는 것처럼 직관적이고 깔끔하게 멀티스레드 작업을 처리할 수 있게 함

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.
시간 초과 시 강제 종료
만약 awaitTerminationfalse를 반환(시간 초과로 인해 정상 종료 실패)하면, 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+ 업데이트 내용

ExecutorServiceAutoCloseable을 구현
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
복사