스레드를 사용하면 발생할 수 있는 성능 문제는 무엇이 있는가
- 메모리 할당
- 각 스레드는 자신만의 호출 스택(call stack)을 가지고 있어야 한다.
- 이 호출 스택은 스레드가 실행되 는 동안 사용하는 메모리 공간이다.
- 따라서 스레드를 생성할 때는 이 호출 스택을 위한 메모리를 할당해야 한다.
- 운영체제 자원 사용
- 스레드를 생성하는 작업은 운영체제 커널 수준에서 이루어지며, 시스템 콜(system call)을 통해 처리된다.
- 이는 CPU와 메모리 리소스를 소모하는 작업이다.
- 운영체제 스케줄러 설정
- 새로운 스레드가 생성되면 운영체제의 스케줄러는 이 스레드를 관리하고 실행 순서를 조 정해야 한다.
- 이는 운영체제의 스케줄링 알고리즘에 따라 추가적인 오버헤드가 발생할 수 있다
- 참고로 스레드 하나는 보통 1MB 이상의 메모리를 사용한다.
스레드의 관리 문제는 무엇이 있는가
- 트래픽이 몰려 스레드가 갑자기 10000개가 필요하다던지, 이러한 경우에 하드웨어가 버티지 못해 죽는 경우가 발생할 수 있어 최대 스레드 수를 관리할 필요가 있음.
- 스레드 종료시 이미 진행중인 스레드를 종료해야할 수도 있는 등 이러한 스레드들을 어떠한 곳에서 관리를 해 줄 필요하 있음.
Runnable 인터페이스는 어떠한 불편함이 있는가
- 반환 값이 없음
- run() 메소드는 return 값이 없기 때문에 실행 결과를 받기 위해 멤버 변수에 값을 넣어 놓고 join()함수를 통해 스레드 종료 후 멤버 변수의 값을 가져와야 하는 불편함이 있음.
- 예외 처리
- run() 메서드는 체크 예외를 던질 수 없어 메서드 내부에서 처리를 해줘야 함.
스레드 풀에 대해 설명하시오
- 스레드 풀은 스레드를 미리 필요한 만큼 만들어 둘 수 있음.
- 스레드는 스레드 풀 안에서 대기하며 쉬다가 작업 요청이 오면 그 때 바로 작업을 실행하게 됨.
- 작업을 완료한 스레드는 제거되는게 아니라 작업 완료 후 다시 스레드 풀에 들어가 재사용될 수 있음.
- 이러한 스레드 풀을 우리가 직접 구현하면 복잡할 수 있으나 Executor 프레임 워크는 이러한 스레드 풀, 스레드 관리, Runnable 문제점 해결 및 생산자 소비자 문제도 해결해줌.
ThreadPoolExecutor 구현체에 대해 설명하시오
- Executor 프레임워크의 구성 요소로는 최상단에 Executor 인터페이스가 있고, 이는 단순히 execute 메소드만 있음.
- Executor 인터페이스를 상속하는 ExecutorService 인터페이스는 close, submit 등의 함수가 있음.
- ThreadPoolExecutor는 ExecutorSerivce 인터페이스의 기본 구현체며 크게 BlockingQueue, 스레드 풀 2가지 구성 요소로 되어 있음.

- 생산자 소비자 문제를 해결하기 위해 단순 큐가 아닌 BlockingQueue를 사용함.
- 생산자: es.execute(작업)을 호출하면 BlockingQueue에 작업을 보관함(main 스레드가 생산자)
- 소비자: 스레드 풀에 있는 스레드가 소비자며, 소비자 중 하나가 BlockingQueue에 들어있는 작업을 받아 처리
- ThreadPoolExecutor 생성자
- corePoolSize : 스레드 풀에서 관리되는 기본 스레드의 수
- maximumPoolSize : 스레드 풀에서 관리되는 최대 스레드 수
- keepAliveTime , TimeUnit unit : 기본 스레드 수를 초과해서 만들어진 스레드가 생존할 수 있는 대기 시 간이다. 이 시간 동안 처리할 작업이 없다면 초과 스레드는 제거된다.
- BlockingQueue workQueue : 작업을 보관할 블로킹 큐
//new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit, BlockingQueue);
public class ExecutorBasicMain {
public static void main(String[] args) throws InterruptedException {
ExecutorService es = new ThreadPoolExecutor(2, 2, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
log("== 초기 상태 ==");
printState(es);
es.execute(new RunnableTask("taskA"));
es.execute(new RunnableTask("taskB"));
es.execute(new RunnableTask("taskC"));
es.execute(new RunnableTask("taskD"));
log("== 작업 수행 중 ==");
printState(es);
sleep(3000);
log("== 작업 수행 완료 ==");
printState(es);
es.close();
log("== shutdown 완료 ==");
printState(es);
}
}
Runnable 대신에 Callable을 사용하면 무엇이 좋은가?
- Callable의 메소드인 call()은 throws Exception 예외가 선언되어 있으므로 해당 인터페이스를 구현하는 모든 메서드는 체크 예외인 Exception과 그 하위 예외를 모두 던질 수 있음.
- Call()은 반환타입이 제네릭 V이므로 값 반환이 가능함.
다음 코드의 main 부분을 설명하시오
public class CallableMainV1 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService es = Executors.newFixedThreadPool(1);
Future<Integer> future = es.submit(new MyCallable());
Integer result = future.get();
log("result value = " + result);
es.close();
}
static class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
log("Callable 시작");
sleep(2000);
int value = new Random().nextInt(10);
log("create value = " + value);
log("Callable 완료");
return value;
}
}
}
- ExecutorService es = Executors.newFixedThreadPool(1);
- newFixedThreadPool을 사용하면 간편하게(예제느낌으로) ExecutorService를 사용할 수 있으며 위는 new ThreadPoolExecutor(1,1,0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); 와 같음.
- Future<Integer> future = es.submit(new MyCallable());
- submit으로 Callable을 넘겨주면 스레드에게 작업으로 callable을 넘겨주게 됨.
- callable의 결과 값으로 Future를 받게 됨.
- Future<Integer> 이기 때문에 결과 값은 Integer 타입
- Future 객체는 받게 되지만, 스레드 실행이 완료되지는 않았을 수도 있어서 결과값은 담겨 있는지 아직 모름.
- Integer result = future.get();
- get()함수를 부르면 스레드가 아직 작업이 완료되지 않았으면 기다리게 되며 현재 스레드는 Waiting 상태에 들어감.
- 스레드 작업이 완료되면 현재 스레드는 다시 Runnable 상태로 돌아가며 Callable의 결과 Integer를 result에 담게 됨.
Future에 대해 설명하시오

- Future future = es.submit(new MyCallable()); 과 같은 함수가 호출되면, taskA가 바로 블로킹 큐에 담기는 것이 아닌 그림처럼 taskA를 감싼 Future가 대신 블로킹 큐에 담김.
- future는 위 함수 실행시 값이 바로 반환되며, 이때 스레드 작업이 완료되지 않았을 수도 있으며 아래는 그러한 Future의 예시임.
FutureTask@46d56d67[Not completed, task = thread.executor.future.CallableMainV2$MyCallable@14acaea5] - Future는 완료가 되었는지 되지 않았는지에 대한 값, taskA에 대한 결과 값을 담고 있음.
- Future는 말 그대로 미래이며, Future를 요청한 스레드는 Future 를 받더라도 그 결과는 아직 받지 않았을 수도 있지만 Future의 주소값을 갖고 있기 때문에 Future가 완료된 순간 Future의 주소값을 참조해 내부 결과 값을 추후 조회할 수 있음.
- 요청 스레드가 future.get()을 호출하면 Future가 완료 상태가 될 때까지 대기하며, 요청 스레드의 상태는 Runnable -> Waiting 이 됨.
- 만약 작업이 완료될 경우 Future에 결과를 담고, Future의 상태를 완료로 변경하며, 요청 스레드를 깨우며 요청 스레드의 상태를 Waiting -> Runnable로 변경함.
- Futuer<Integer> result = es.submit(new MyCallable()); // 여기서 블로킹
위와 같은 코드를 사용하면 Runnable과 join 함수를 사용하는 것 보다 결과 값을 받는다는 코드가 훨씬 직관적임. - Future를 사용하면 멀티스레드를 싱글스레드를 쓰는 것 처럼 쉽게 사용이 가능함.
- Future를 사용할 때 주의 점은, 여러 스레드를 동시에 시작하고, 동시에 기다려야 멀티스레딩의 이점을 활용하게 되는 것이지, 하나 실행 후 하나의 결과 대기, 또 하나 실행 후 하나의 결과 대기 이런식으로 사용하면 싱글 스레드 방식과 다를게 없어짐.
Future의 주요 메서드를 설명하시오
- boolean cancel(boolean mayInterruptIfRunning)
- 기능: 아직 완료되지 않은 작업을 취소한다.
- 매개변수: mayInterruptIfRunning
- cancel(true): Future를 취소 상태로 변경한다. 이때 작업이 실행 중이라면 Thread.interrupt()를 호출해서 작업을 중단한다.
- cancel(false): Future를 취소 상태로 변경한다. 단, 이미 실행 중인 작업을 중단하지는 않는다.
- 반환값: 작업이 성공적으로 취소된 경우 true, 이미 완료되었거나 취소할 수 없는 경우 false
- 설명: 작업이 실행 중이 아니거나 아직 시작되지 않았으면 취소하고, 실행 중인 작업의 경우 mayInterruptIfRunning이 true이면 중단을 시도한다.
- 참고: 취소 상태의 Future에 Future.get()을 호출하면 CancellationException 런타임 예외가 발생한다.
- boolean isCancelled()
- 기능: 작업이 취소되었는지 여부를 확인한다.
- 반환값: 작업이 취소된 경우 true, 그렇지 않은 경우 false
- 설명: 이 메서드는 작업이 cancel() 메서드에 의해 취소된 경우에 true를 반환한다.
- boolean isDone()
- 기능: 작업이 완료되었는지 여부를 확인한다.
- 반환값: 작업이 완료된 경우 true, 그렇지 않은 경우 false
- 설명: 작업이 정상적으로 완료되었거나, 취소되었거나, 예외가 발생하여 종료된 경우에 true를 반환한다.
- State state()
- 기능: Future의 상태를 반환한다. 자바 19부터 지원한다.
- RUNNING: 작업 실행 중
- SUCCESS: 성공 완료
- FAILED: 실패 완료
- CANCELLED: 취소 완료
- 기능: Future의 상태를 반환한다. 자바 19부터 지원한다.
- V get()
- 기능: 작업이 완료될 때까지 대기하고, 완료되면 결과를 반환한다.
- 반환값: 작업의 결과
- 예외:
- InterruptedException: 대기 중에 현재 스레드가 인터럽트된 경우 발생
- ExecutionException: 작업 계산 중에 예외가 발생한 경우 발생
- 설명: 작업이 완료될 때까지 get()을 호출한 현재 스레드를 대기(블로킹)한다. 작업이 완료되면 결과를 반환한다.
- V get(long timeout, TimeUnit unit)
- 기능: get()과 같은데, 시간 초과되면 예외를 발생시킨다.
- 매개변수:
- timeout: 대기할 최대 시간
- unit: timeout 매개변수의 시간 단위 지정
- 반환값: 작업의 결과
- 예외:
- InterruptedException: 대기 중에 현재 스레드가 인터럽트된 경우 발생
- ExecutionException: 계산 중에 예외가 발생한 경우 발생
- TimeoutException: 주어진 시간 내에 작업이 완료되지 않은 경우 발생
- 설명: 지정된 시간 동안 결과를 기다린다. 시간이 초과되면 TimeoutException을 발생시킨다.
Future의 cancel()이 동작할 경우 실행중인 스레드는 어떻게 되는가
- 작업이 아직 실행 중이 아니라면 작업을 취소함
- 하지만, 만약 이미 작업이 실행중일 경우 취소하지 않음.
- cancel(true)를 실행할 경우에는 실행중인 작업도 취소함
- 파라미터는 mayInterruptIfRunning을 의미
ExeuctorService가 여러 작업을 한 번에 처리하는 방법은?
- invokeAll을 사용
- invokeAll은 모든 태스크를 실행시킴.
List<CallableTask> tasks = List.of(task1, task2, task3);
List<Future<Integer>> futures = es.invokeAll(tasks);
- invokeAny를 사용
- invokeAny는 한 번에 여러 작업을 제출하고 가장 먼저 완료된 작업의 결과를 반환하며, 나머지는 인터럽트로 취소함
List<CallableTask> tasks = List.of(task1, task2, task3);
Integer value = es.invokeAny(tasks);
ExecutorService의 shutdown(), shutdownNow(), close() 함수의 차이를 설명하시오
- shutdown()은 호출 되었을 때 더이상 새로운 작업을 받지 않고, 이미 제출된 작업을 모두 완료한 후 종료함.
- shutdownNow()는 실행 중인 작업을 즉시 중단하고, 대기중인 작업도 반환하며 즉시 종료함.
- close()는 자바 19부터 지원하며, 기본적으로 shutdown()과 같지만 만약 하루를 기다려도 작업이 완료되지 않으면 shutdownNow()를 호출한다.
아래와 같은 ExecutorService가 정의되어 있을 때 연속해서 7개의 오래걸리는 작업을 연속해서 실행하면 어떠한 상황이 발생할 지 설명하시오
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
ExecutorService es = new ThreadPoolExecutor(2, 4, 3000, TimeUnit.MILLISECONDS, workQueue);
- [pool=0, active=0, queuedTasks=0, completedTasks=0]
- [pool=1, active=1, queuedTasks=0, completedTasks=0]
- [pool=2, active=2, queuedTasks=0, completedTasks=0]
- [pool=2, active=2, queuedTasks=1, completedTasks=0]
- [pool=2, active=2, queuedTasks=2, completedTasks=0]
- [pool=3, active=3, queuedTasks=2, completedTasks=0]
- [pool=4, active=4, queuedTasks=2, completedTasks=0]
- RejectedExecutionException 발생
- 최대 스레드 풀 개수 4개, 최대 큐 사이즈 2개가 모두 차고 나면 RejectedExecutionException 발생함.
- corePoolSize, queueSize, maximumPoolSize 순으로 채워지고 그 이후로는 RejectedExecutionException 발생
ExecutorService의 maximumPoolSize가 무엇을 의미하는지 설명하시오
ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit, BlockingQueue)
- 최대 Pool Size는 평소의 스레드 풀의 최대 개수를 의미하는 것은 아님.
- 평소에는 corePoolSize가 최대 스레드 풀 개수인 것 처럼 동작함.
- corePoolSize가 모두 사용중이게 되면, BlockingQueue에 작업이 쌓이게 됨.
- BlockingQueue까지 모두 작업이 꽉 차면 그 때 스레드 풀의 개수는 최대 Pool Size 만큼 늘어남.
- 즉, 최악의 상황의 최대 Pool Size를 의미함.
- 만약, 최대 Pool Size까지 꽉 찼는데 작업이 들어오려고 할 경우 RejectedExecutionException 발생.
ExecutorService의 keepAliveTime이 무엇을 의미하는지 설명하시오
ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit, BlockingQueue)
- 만약 corePoolSize가 꽉 차고, BlockingQueue까지 스레드 풀 사이즈가 corePoolSize를 넘어 maximumPoolSize까지 커질 수 있음.
- 하지만, 만약 그 경우에 스레드가 작업이 끝나고 나서도 keepAliveTime 동안 작업이 없으면 스레드 개수는 다시 corePoolSize 만큼 줄어들게 됨.
이전 강의에서 BlockingQueue(ArrayBlockingQueue와 같은)는 요청이 왔을 때 만약 Queue가 꽉차있으면 Block을 걸고 요청을 대기한다고 했는데, 왜 ExecutorService에서는 RejectedExecutionException이 발생하는가?
- Queue가 꽉차있으면 Block을 걸고 요청을 대기할 때 사용하는 함수는 put() 함수임.
- 하지만 ExecutorService가 사용하는 함수는 offer()함수로 실제로 다른 함수를 사용함.
- offer()함수는 put()함수와 다르게 Queue가 꽉차있으면 Block을 걸고 기다리지 않고 그냥 false를 리턴함.
- 또 하나의 함수는 add()인데 add 함수는 Queue가 꽉차면 IllegalStateException을 발생시킴.
- 하지만 ExecutorService에서 발생하는 예외는 RejectedExecutionException이라 add 함수를 사용하는 경우가 아님.
- ExecutorService에서 offer()함수에서 false가 반환되면 이는 reject(command)를 호출해 RejectedExecutionHandler로 넘겨지며, RejectedExecutionHandler가 RejectedExecutionException을 발생시킴.
스레드는 처음에 0개였다가 이후에 최소 corePoolSize만큼 증가하는데, 처음부터 corePoolSize만큼 스레드를 생성해놓을 수 있는 방법이 있는가?
다음과 같이 prestartAllCoreThreads 함수를 사용하면 가능함.
ThreadPoolExecutor poolExecutor = (ThreadPoolExecutor) es;
poolExecutor.prestartAllCoreThreads();
고정 풀 전략(newFixedThreadPool)에 대해 설명하시오
- 스레드 풀에 nThreads 만큼의 기본 스레드를 생성한다. 초과 스레드는 생성하지 않는다.
- 큐 사이즈에 제한이 없다. ( LinkedBlockingQueue )
- 스레드 수가 고정되어 있기 때문에 CPU, 메모리 리소스가 어느정도 예측 가능한 안정적인 방식이다.
- new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue())
- 트래픽이 점점 늘어나게 되면 어느 순간부터 응답이 느려지는 문제가 발생할 수 있음.
- 만약 갑자기 트래픽이 급증하면 응답이 불가능한 상황이 발생할 수 있음.
캐시 풀 전략(newCachedThreadPool)에 대해 설명하시오
- 기본 스레드를 사용하지 않고, 60초 생존 주기를 가진 초과 스레드만 사용한다.
- 초과 스레드의 수는 제한이 없다.
- 큐에 작업을 저장하지 않는다. ( SynchronousQueue )
- 대신에 생산자의 요청을 스레드 풀의 소비자 스레드가 직접 받아서 바로 처리한다.
- 모든 요청이 대기하지 않고 스레드가 바로바로 처리한다. 따라서 빠른 처리가 가능하다.
- new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue());
- 트래픽이 점점 늘어나는 상황에 유연하게 대처가 가능함
- Queue가 없어 스레드가 무한정 늘어나게 될 경우 메모리 사용량이 매우 크게 늘 수 있음
- 갑자기 급증하는 경우 응답이 안될 수 있음.
사용자 정의 풀 전략에 대해 설명하시오
- 다음과 같은 세분화된 전략을 사용해서 대응하도록 함
- 일반: 일반적인 상황에는 CPU, 메모리 자원을 예측할 수 있도록 고정 크기의 스레드로 서비스를 안정적으로 운영 한다.
- 긴급: 사용자의 요청이 갑자기 증가하면 긴급하게 스레드를 추가로 투입해서 작업을 빠르게 처리한다.
- 거절: 사용자의 요청이 폭증해서 긴급 대응도 어렵다면 사용자의 요청을 거절한다.
- ex) ExecutorService es = new ThreadPoolExecutor(100, 200, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000));
- 100개의 스레드로 일반적인 상황에 안정적으로 운영함
- 트래픽이 급증하면 스레드를 200개까지 늘려서 운영함
- 만약 트래픽이 1200개 이상인 경우 트래픽을 거절함.
- 주의: ArrayBlockingQueue<>() 이런식으로 생성하면 Queue사이즈가 무한으로 늘어나 큐가 가득차는 상황이 발생하지 않아 스레드가 늘어날 수없는 상황이 발생할 수도 있음.
Executor 예외 정책인 AbortPolicy, DiscardPolicy, CallerRunsPolicy에 대해 설명하시오
- AbortPolicy: 새로운 작업을 제출할 때 RejectedExecutionException 을 발생시킨다. 기본 정책이다.
- DiscardPolicy: 새로운 작업을 조용히 버린다.
- CallerRunsPolicy: 새로운 작업을 제출한 스레드가 대신해서 직접 작업을 실행한다.
- 사용자 정의( RejectedExecutionHandler ): 개발자가 직접 정의한 거절 정책을 사용할 수 있다.
- 다음과 같이 선언 가능
ExecutorService executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new SynchronousQueue<>(), new ThreadPoolExecutor.DiscardPolicy());
'김영한의 실전 자바 - 고급 1편' 카테고리의 다른 글
| 김영한의 실전 자바 - 고급 2편(총 정리) (0) | 2026.01.26 |
|---|---|
| 김영한의 실전 자바 - 고급 1편(총 정리) (0) | 2026.01.26 |
| 김영한의 실전 자바 - 고급 1편(동시성 컬렉션) (0) | 2025.01.11 |
| 김영한의 실전 자바 - 고급 1편(CAS) (1) | 2025.01.11 |
| 김영한의 실전 자바 - 고급 1편(생산자 소비자 문제: Object - wait/notify, ReentarantLock - await/signal, BlockingQueue) (1) | 2025.01.08 |