0. 들어가기 전
실무에서 스레드를 직접 생성해서 사용하면 다음과 같은 3가지 문제가 있습니다.
- 스레드 생성 시간으로 인한 성능 문제
- 스레드 관리 문제
- Runnable 인터페이스의 불편함
1. 스레드 생성 비용으로 인한 성능 문제
스레드를 사용하는 데 있어 가장 큰 문제 중 하나는 생성 비용이 매우 크다는 점입니다. 스레드를 생성하면 단순히 자바 객체를 하나 만드는 수준이 아니라, 각 스레드는 자신만의 호출 스택(call stack)을 가지며 이 메모리를 별도로 할당해야 합니다. 또한 운영체제 커널 수준에서 시스템 콜(system call)을 통해 스레드를 생성하기 때문에 CPU와 메모리 리소스를 많이 사용하게 됩니다.
생성된 스레드는 운영체제의 스케줄러에 등록되어 관리되며, 실행 순서를 조정받는 과정에서도 추가적인 오버헤드가 발생합니다. 일반적으로 스레드 하나는 약 1MB 이상의 메모리를 사용하며, 단순한 작업을 처리하는 데에도 스레드를 새로 생성하게 되면 스레드 생성 비용이 작업 처리 시간보다 더 오래 걸릴 수 있습니다.
이러한 이유로, 작업마다 새로운 스레드를 생성하게 되면 시스템 성능이 급격히 저하될 수 있습니다. 이러한 문제를 해결하기 위한 방법으로는 스레드 재사용이 있습니다. 스레드를 미리 생성해두고, 필요한 작업이 있을 때 기존 스레드를 재사용하면 매번 생성할 필요가 없어 성능을 크게 향상시킬 수 있습니다. 이러한 방식은 이후에 설명할 스레드 풀(Thread Pool) 개념의 기초가 됩니다. 스레드를 재사용하면 생성 비용을 줄일 수 있으며, 동시에 더 많은 작업을 효율적으로 처리할 수 있게 됩니다.
2. 스레드 관리 문제
서버는 CPU와 메모리 자원이 한정되어 있기 때문에, 스레드를 무한히 생성할 수는 없습니다. 예를 들어, 사용자의 주문을 처리하는 서비스가 있다고 가정하겠습니다. 이 서비스는 사용자의 주문 요청이 들어올 때마다 새로운 스레드를 만들어 처리하도록 구성되어 있습니다.
하지만 마케팅을 위해 선착순 할인 이벤트와 같은 프로모션을 진행하면, 사용자들이 한순간에 몰릴 수 있습니다. 평소에는 동시에 100개의 스레드로 충분했던 서비스도, 이벤트 상황에서는 수천 개 이상의 스레드가 동시에 생성될 수 있습니다. 이 경우 서버의 CPU와 메모리 자원이 급격하게 소모되며, 시스템 전체가 불안정해지거나 심한 경우 다운될 수 있습니다.
이러한 문제를 해결하려면 시스템이 감당할 수 있는 최대 스레드 수를 제한하고, 그 범위 안에서만 스레드를 생성하고 관리해야 합니다. 즉, 스레드 수를 제한하는 정책적 장치가 필요합니다. 또한 스레드를 관리해야 하는 이유는 단순히 자원 문제뿐만이 아닙니다. 예를 들어, 애플리케이션을 종료해야 하는 상황이 발생했을 때를 생각해보면, 남아 있는 스레드들이 어떤 작업을 수행 중인지 파악하고, 적절히 종료 요청을 보내거나, 필요한 경우 인터럽트를 통해 중단시킬 수 있어야 합니다.
이 모든 것들을 수동으로 관리하는 것은 매우 어렵고 복잡합니다. 따라서 이를 효과적으로 처리하기 위해 스레드를 체계적으로 생성, 재사용, 종료까지 관리해주는 기능이 필요합니다.
3. Runnable 인터페이스의 불편함
public interface Runnable {
void run();
}
Runnable 인터페이스는 다음과 같은 이유로 실무에서 사용하기에 다소 불편한 점이 있습니다.
첫째, 반환 값이 없습니다.
Runnable의 핵심 메서드인 run()은 void 반환 타입이기 때문에, 스레드가 수행한 작업의 결과를 직접적으로 받을 수 없습니다. 예를 들어, 어떤 계산 작업을 스레드에게 맡기고 그 결과를 받아오고 싶을 때, Runnable을 사용할 경우 결과를 멤버 변수 등에 저장해두고 join() 메서드로 스레드 종료를 기다린 다음, 그 변수에서 값을 꺼내야 하는 번거로운 작업이 필요합니다.
둘째, 체크 예외(Checked Exception)를 던질 수 없습니다.
run() 메서드는 선언적으로 throws를 사용할 수 없기 때문에, 체크 예외가 발생해도 메서드 내부에서 직접 처리해야만 합니다.
예외를 외부로 전달하거나 호출 측에서 유연하게 처리하기 어렵다는 단점이 있습니다.
이러한 제약을 극복하기 위해, 반환 값도 받고 예외도 좀 더 깔끔하게 처리할 수 있는 방법이 필요합니다.
스레드 풀이라는 개념을 사용하면, 매번 새로운 스레드를 생성하는 비용을 줄이고, 이미 생성된 스레드를 재사용함으로써 성능을 크게 향상시킬 수 있습니다. 스레드 풀에서는 일정 수의 스레드를 미리 만들어 두고, 작업이 들어올 때마다 이 스레드들을 재사용하게 됩니다. 이를 통해 스레드 생성 시간을 절약할 수 있으며, 시스템의 자원을 효율적으로 관리할 수 있습니다.
사실 스레드 풀 자체는 복잡한 개념은 아닙니다. 단순히 스레드를 모아두고 필요할 때 꺼내쓰고 다시 넣는 일종의 스레드 컬렉션이라 생각할 수 있습니다. 하지만 막상 직접 구현하려고 하면 쉽지 않습니다. 스레드가 대기 상태(WAITING)에 있다가 작업이 들어오면 즉시 RUNNABLE 상태로 전환되어야 하며, 이 모든 것을 효율적으로 처리하기 위해서는 생산자-소비자 패턴까지 고려해야 합니다.
이러한 복잡한 문제를 깔끔하게 해결해주는 것이 바로 자바의 Executor 프레임워크 입니다.
Executor는 다음과 같은 기능들을 제공합니다.
- 스레드 풀 관리 (스레드 재사용 및 개수 제한)
- Runnable과 Callable 기반의 작업 실행
- 스레드의 생명주기 관리
- 생산자-소비자 패턴 지원
- 작업 결과 반환 (Future 객체 사용)
- 안전한 종료 처리 (shutdown, awaitTermination)
이처럼 Executor 프레임워크는 멀티스레드 기술의 핵심 요소들을 집약해놓은 도구입니다.
그래서 실무에서는 직접 스레드를 생성해서 사용하는 일보다는 ExecutorService, Executors, ThreadPoolExecutor 등을 이용하여 멀티스레드 환경을 구성하는 것이 일반적입니다. 정리하자면, 직접 스레드를 만드는 방식은 관리도 어렵고 성능에도 좋지 않으며, 복잡한 예외 처리까지 고려해야 하기 때문에, 실무에서는 대부분 Executor 프레임워크를 사용하여 안정적이고 효율적인 멀티스레드 프로그래밍을 구현합니다.
1. Executor 프레임워크 소개
자바의 Executor 프레임워크는 멀티스레딩과 병렬 처리를 보다 쉽고 효율적으로 사용할 수 있도록 도와주는 기능 모음입니다.
이 프레임워크는 작업 실행의 흐름과 스레드 풀을 체계적으로 관리해주기 때문에, 개발자가 직접 스레드를 생성하고 제어하는 번거로움을 줄여줍니다. 복잡한 스레드 생성, 실행, 종료 과정을 간단한 API로 감싸줌으로써, 안정적이면서도 성능이 뛰어난 멀티스레드 환경을 구현할 수 있도록 돕는 것이 핵심입니다.
Executor 인터페이스
ExecutorService 인터페이스 - 주요 메서드
public interface ExecutorService extends Executor, AutoCloseable {
<T> Future<T> submit(Callable<T> task);
@Override
default void close(){...}
...
}
- ExecutorService는 Executor 인터페이스를 확장한 것으로, 작업의 제출과 실행 제어 기능을 추가로 제공합니다.
- 단순히 작업을 실행하는 것뿐 아니라, 작업 결과를 받아오거나, 스레드 풀을 종료하는 등 더 정교한 제어가 가능합니다.
- ExecutorService 인터페이스의 기본 구현체는 ThreadPoolExecutor 이다.
먼저 Executor 프레임워크의 상태를 확인하기 위한 로그 출력 유틸리티를 만들어둡시다.
public abstract class ExecutorUtils {
public static void printState(ExecutorService executorService) {
if (executorService instanceof ThreadPoolExecutor poolExecutor) {
int pool = poolExecutor.getPoolSize(); // 스레드 풀에서 관리되는 스레드의 숫자
int active = poolExecutor.getActiveCount(); // 작업을 수행하는 스레드의 숫자
int queued = poolExecutor.getQueue().size(); // 큐에 대기중인 작업의 숫자
long completedTask = poolExecutor.getCompletedTaskCount(); // 완료된 작업의 숫자
log("[pool = " + pool + ", active = " + active + ", queuedTasks = "
+ queued + ", completedTask = " + completedTask + "]");
}
}
}
다음은 ExecutorService 코드를 사용해보기 위해 먼저 1초간 대기하는 아주 간단한 작업을 하나 만들겠습니다.
public class RunnableTask implements Runnable {
private final String name;
private int sleepMs = 1000;
public RunnableTask(String name) {
this.name = name;
}
public RunnableTask(String name, int sleepMs) {
this.name = name;
this.sleepMs = sleepMs;
}
@Override
public void run() {
log(name + " 시작");
sleep(sleepMs);
log(name + "완료");
}
public class ExecutorBasicMain {
public static void main(String[] args) {
ExecutorService es = new ThreadPoolExecutor(2, 2, 0,
TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
log("== 초기 상태 ==");
ExecutorUtils.printState(es);
es.execute(new RunnableTask("taskA"));
es.execute(new RunnableTask("taskB"));
es.execute(new RunnableTask("taskC"));
es.execute(new RunnableTask("taskD"));
log("== 작업 수행 중 ==");
ExecutorUtils.printState(es);
sleep(3000);
log("== 작업 수행 완료 ==");
ExecutorUtils.printState(es);
es.close();
log("== shutdown 완료 ==");
ExecutorUtils.printState(es);
}
}
ThreadPoolExecutor는 ExecutorService의 대표적인 구현체로, 멀티스레드 환경에서 작업을 효율적으로 처리하기 위해 스레드 풀 과 작업 큐(BlockingQueue) 두 가지 핵심 구성 요소로 이루어져 있습니다.
- 스레드 풀 (Thread Pool)
- 작업을 처리하는 실제 스레드들의 집합입니다.
- 스레드 생성, 재사용, 제거 등을 자동으로 관리합니다.
- BlockingQueue
- 생산자가 제출한 작업을 저장하는 큐입니다.
- 일반 큐와 달리, 큐가 비어있으면 소비자는 대기하고, 가득 차면 생산자는 대기하게 되어 생산자-소비자 문제를 자연스럽게 해결할 수 있습니다.
작동 방식
- main 스레드 → 작업 제출 (execute() 또는 submit())
- 제출된 작업 → BlockingQueue 에 저장
- 스레드 풀 내부 스레드 → 큐에서 작업 꺼내 실행 (소비자 역할)
ThreadPoolExecutor 생성자
new ThreadPoolExecutor(2, 2, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
- corePoolSize : 스레드 풀에서 관리되는 기본 스레드의 수
- maximumPoolSize : 스레드 풀에서 관리되는 최대 스레드 수
- keepAliveTime , TimeUnit unit : 기본 스레드 수를 초과해서 만들어진 스레드가 생존할 수 있는 대기 시간이다. 이 시간 동안 처리할 작업이 없다면 초과 스레드는 제거된다.
- BlockingQueue workQueue : 작업을 보관할 블로킹 큐

실행 순서

- ThreadPoolExecutor 를 생성한 시점에 스레드 풀에 스레드를 미리 만들어두지는 않습니다.

- main 스레드는 es.execute("taskA ~ taskD")와 같이 작업을 제출합니다.
- 이때, 작업을 큐에 넣은 후에는 기다리지 않고 곧바로 다음 코드를 실행합니다.
- taskA부터 taskD까지의 작업 요청은 내부의 블로킹 큐(BlockingQueue)에 순서대로 저장됩니다.
- 작업이 처음 제출되면, 이를 처리하기 위한 스레드가 생성됩니다.
- 스레드 풀은 처음부터 스레드를 미리 만들어두지 않으며, 작업이 들어올 때마다 스레드를 생성합니다.
- 단, 생성되는 스레드의 수는 corePoolSize에 지정된 수를 넘지 않습니다.

예를 들어 corePoolSize가 2로 설정되어 있다면,
- taskA가 들어올 때 스레드1을 생성해 처리하고,
- taskB가 들어올 때 스레드2를 생성해 처리합니다.
- 이후 taskC, taskD 같은 작업은 이미 만들어진 스레드1, 스레드2가 재사용되어 처리하게 됩니다.
즉, corePoolSize만큼의 스레드를 필요할 때 생성한 후, 그 이후부터는 재사용하는 방식으로 동작합니다.

- 작업이 완료되면 해당 스레드는 스레드 풀에 반납됩니다.
- 반납된 스레드는 즉시 종료되지 않고, 스레드 풀 내부에서 WAITING 상태로 대기하게 됩니다.
- 이렇게 대기 중인 스레드는 다음 작업이 들어오면 재사용되며, 다시 RUNNABLE 상태로 전환되어 작업을 수행합니다.

- taskC , taskD 의 작업을 처리하기 위해 스레드 풀에서 스레드를 꺼내 재사용합니다.

- 작업이 완료되면 스레드는 다시 스레드 풀에서 대기합니다.

- close() 를 호출하면 ThreadPoolExecutor 가 종료됩니다.
- 이때 스레드 풀에 대기하는 스레드도 함께 제거됩니다.
참고: close() 메서드는 Java 19부터 지원되는 기능입니다.
만약 Java 19 미만의 버전을 사용하고 있다면 close() 대신 shutdown() 메서드를 호출해야 합니다.
2. Executor 프레임워크 소개 - Runnable의 불편함
앞서 살펴본 것처럼 Runnable 인터페이스는 반환 값이 없고, 체크 예외를 던질 수 없다는 점에서 불편함이 있습니다.
이러한 단점을 해결하기 위해 Executor 프레임워크는 어떤 방식으로 이런 불편함을 해결하는지 알아봅시다.
이해를 돕기 위해 먼저 Runnable을 사용하여 별도의 스레드에서 무작위 값을 하나 구하는 예제를 작성해보겠습니다.
이 예제에서는 Runnable이 결과를 반환하지 못하기 때문에 결과값을 공유 객체에 저장해야 하는 번거로움이 있습니다.
public class RunnableMain {
public static void main(String[] args) throws InterruptedException {
MyRunnable task = new MyRunnable();
Thread thread = new Thread(task, "Thread-1");
thread.start();
thread.join();
int result = task.value;
log("result value = " + result);
}
static class MyRunnable implements Runnable {
int value;
@Override
public void run() {
log("Runnable 시작");
sleep(2000);
value = new Random().nextInt(10);
log("create value = " + value);
log("Runnable 완료");
}
}
}
- 별도의 스레드에서 값을 생성하고, 그 값을 메인 스레드가 받아오기 위해 Runnable과 join()을 사용하는 방식은 꽤 번거롭습니다.
- 값을 담을 필드도 따로 만들어야 하고, 작업이 끝날 때까지 join()으로 기다려야 합니다.
만약 작업 스레드가 단순히 return으로 값을 반환하고, 요청 스레드가 그 값을 바로 받아올 수 있다면 얼마나 간결하고 직관적일까요?
이런 복잡함을 해결하기 위해 자바의 Executor 프레임워크는 Callable과 Future라는 인터페이스를 도입했습니다.
3. Callable 과 Future
Runnable
package java.lang;
public interface Runnable {
void run();
}
- Runnable의 run() 메서드는 void를 반환하기 때문에, 스레드 작업의 실행 결과를 외부로 전달할 수 없습니다.
- run() 메서드는 throws 절이 없습니다. 즉, IOException, InterruptedException 같은 체크 예외를 던질 수 없고, 무조건 try-catch로 감싸서 내부에서 처리해야 합니다.
Callable
package java.util.concurrent;
public interface Callable<V> {
V call() throws Exception;
}
- call() 메서드는 제네릭 타입 V를 반환합니다. 즉, 작업 결과를 return으로 직접 전달할 수 있습니다.
- call() 메서드는 throws Exception이 선언되어 있어, 해당 인터페이스를 구현하는 모든 메서드는 체크 예외인 Exception 과 그 하위 예외를 모두 던질 수 있습니다.
그렇다면 Callable을 실제로 어떻게 사용하는지 알아봅시다.
public class CallableMainV1 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// Executors가 제공하는 newFixedThreadPool(size)을 사용하면 편리하게 ExecutorService를 생성할 수 있다.
// new ThreadPoolExecutor(1,1,0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
ExecutorService es = Executors.newFixedThreadPool(1);
MyCallable task = new MyCallable();
// submit()을 통해 Callable을 작업으로 전달할 수 있다.
Future<Integer> future = es.submit(task);
// MyCallable의 call()이 반환한 결과를 받을 수 있다.
Integer result = future.get();
log("result value = " + result);
es.close();
}
static class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
log("Callable 시작");
sleep(2000);
int value = new Random().nextInt(10);
log("create value = " + value);
log("Callable 완료");
return value;
}
}
}
요청 스레드가 결과를 받아야 하는 상황이라면, Callable을 사용하는 방식은 Runnable보다 훨씬 더 편리합니다.
코드를 보면 복잡한 멀티스레드 환경이라기보다는, 마치 단일 스레드 방식으로 개발하는 것처럼 느껴질 수 있습니다.
이 과정에서 직접 스레드를 생성하거나 join()으로 스레드를 제어하는 코드는 전혀 등장하지 않습니다.
심지어 Thread 라는 키워드조차 사용하지 않습니다.
단지 ExecutorService에 작업을 제출하고, 그 결과를 받아서 사용하는 것만으로 충분합니다.
이처럼 멀티스레드를 매우 간결하고 직관적으로 사용할 수 있게 해주는 것이 Executor 프레임워크의 큰 장점입니다.
하지만 이렇게 편리하다고 해서, 동작 원리를 정확히 이해하지 못한 채 사용하는 것은 위험할 수 있습니다.
예를 들어 future.get()을 호출하는 시점을 생각해 보면 두 가지 상황이 존재합니다.
- 첫째, 스레드 풀에서 MyCallable 작업이 이미 완료된 경우에는 바로 결과를 받을 수 있습니다.
- 둘째, 작업이 아직 완료되지 않았다면 요청 스레드는 해당 결과가 나올 때까지 대기해야 합니다.
여기서 자연스럽게 이런 의문이 생길 수 있습니다. future.get()을 호출했을 때 스레드 풀의 스레드가 작업을 완료했다면 반환 받을 결과가 있을 것입니다. "그런데 아직 작업을 처리중이라면 어떻게 될까요?" 또는 "왜 결과 값을 바로 반환하지 않고 굳이 Future라는 객체를 통해 우회적으로 전달할까요?"
4. Future 분석
Future<Integer> future = es.submit(new MyCallable());
- submit() 메서드를 호출하면서 MyCallable 인스턴스를 전달하면, 이 작업은 즉시 실행되지 않고, 스레드 풀에 등록됩니다.
- 이때 submit()은 MyCallable.call()의 반환값을 직접 주는 대신, 작업 결과를 나중에 받아올 수 있는 Future 객체를 반환합니다.
🤔 왜 이렇게 동작할까요?
그 이유는 간단합니다. MyCallable의 call() 메서드는 현재 호출하는 스레드에서 실행되는 것이 아니라, 스레드 풀의 다른 스레드가 미래의 어느 시점에 대신 실행할 코드이기 때문입니다. 따라서 submit()을 호출한 즉시 결과를 받을 수는 없습니다. 결국 Future는 말 그대로 미래(Future)의 결과를 담고 있는 그릇입니다. 우리는 이 객체를 통해 작업이 완료된 후 결과를 가져오거나, 완료 여부를 확인하거나, 작업을 취소하는 등의 제어를 할 수 있습니다.
이제 본격적으로 Future가 어떻게 작동하는지 알아봅시다.
public class CallableMainV2 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService es = Executors.newFixedThreadPool(1);
log("submit() 호출");
MyCallable task = new MyCallable();
Future<Integer> future = es.submit(task);
log("future 즉시 반환, future = " + future);
log("future.get() [블로킹] 메서드 호출 시작 -> main 스레드 WAITING");
Integer result = future.get();
log("future.get() [블로킹] 메서드 호출 완료 -> main 스레드 RUNNABLE");
log("result value = " + result);
log("future 완료, future = " + future);
es.close();
}
static class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
log("Callable 시작");
sleep(2000);
int value = new Random().nextInt(10);
log("create value = " + value);
log("Callable 완료");
return value;
}
}
}
실행 결과 분석
- MyCallable 인스턴스를 편의상 taskA 라고 하겠습니다.
- 실제로는 call() 메서드가 스레드에 의해 실행되어야 작업이 시작되지만, 설명의 편의를 위해 스레드 풀에 스레드가 이미 하나 존재한다고 가정하겠습니다.
요청 스레드가 es.submit(taskA) 를 호출하면, ExecutorService 는 먼저 taskA 작업을 감싸서 미래 결과를 추적할 수 있는 Future 객체를 생성합니다. 이 Future 객체는 단순한 껍데기가 아니라, 내부에 전달받은 taskA 인스턴스를 보관하고 있으며, 작업의 완료 여부, 예외 발생 여부, 실제 결과값 등을 담을 수 있는 기능을 가지고 있습니다.
참고로 Future는 인터페이스이고, 실제로는 FutureTask라는 구현체가 사용됩니다.
FutureTask는 Runnable과 Future를 모두 구현하고 있어서, 스레드 풀에 실행을 맡기면서 동시에 결과도 추적할 수 있게 해줍니다.
Future<Integer> future = es.submit(task);
- submit()을 호출하면, 단순히 taskA 작업이 블로킹 큐에 바로 들어가는 것이 아닙니다.
- Executor는 먼저 taskA를 FutureTask로 감싸고, 이 FutureTask 객체가 블로킹 큐에 담깁니다.
- 즉, 큐에 들어가는 것은 단순한 작업이 아니라, 작업의 실행과 결과 추적을 함께 관리할 수 있는 객체입니다.
- 이렇게 하면 나중에 스레드가 해당 작업을 실행한 뒤, Future를 통해 결과를 받아볼 수 있게 됩니다.
- Future는 내부에 작업의 완료 여부와 결과 값을 가지고 있습니다.
- 작업이 아직 완료되지 않았기 때문에 현재는 결과 값이 없는 상태입니다.
- 로그를 살펴보면, Future의 구현체는 FutureTask임을 확인할 수 있습니다.
이 Future의 상태는 "Not completed"이며, 연관된 작업으로는 taskA라는 MyCallable 인스턴스가 들어 있습니다.
여기서 중요한 점은, 작업을 제출할 때 생성된 Future 객체가 즉시 반환된다는 사실입니다.
즉, 작업이 끝나지 않았더라도 Future는 즉시 호출자에게 전달되어, 나중에 작업 결과를 받을 수 있는 창구 역할을 하게 됩니다.
이 Future는 작업의 결과를 바로 담고 있는 것은 아니고, 아직 완료되지 않은 작업의 미래 결과를 추적할 수 있는 객체입니다. 즉, 작업은 스레드 풀의 다른 스레드에 의해 나중에 실행되지만, submit()을 호출한 스레드는 대기하지 않고, 곧바로 Future를 받아서 자유롭게 본인의 다음 코드를 호출할 수 있습니다.
- 스레드 풀의 스레드1이 큐에서 Future[taskA]를 꺼내 실행을 시작합니다.
- 여기서 핵심은 Future의 구현체인 FutureTask가 Runnable도 구현하고 있다는 점입니다.
- 즉, 스레드가 실행할 수 있는 작업 형태(Runnable)이기도 하면서, 결과를 담을 수 있는 컨테이너(Future) 역할도 동시에 수행합니다.
- 스레드1은 FutureTask.run()을 호출하고, 이 내부에서 우리가 제출한 taskA의 call() 메서드를 실행합니다.
- 그리고 그 결과 값을 FutureTask 내부에 저장해 둡니다.
요청 스레드는 Future 객체를 통해 taskA의 결과를 받을 수 있습니다.
하지만 이 결과는 taskA 작업이 완료된 이후에만 얻을 수 있습니다.
현재 상황은
- 스레드1은 taskA 작업을 수행 중이며, 아직 완료되지 않았습니다.
- 요청 스레드(main)는 submit()을 통해 반환받은 Future 인스턴스를 가지고 있으며, 작업 결과가 필요해 future.get()을 호출합니다.
Integer result = future.get();
get()을 호출하면 다음과 같은 두 가지 경우가 발생할 수 있습니다.
- 작업이 이미 완료된 경우: Future는 완료 상태이고, 결과가 내부에 저장되어 있으므로 요청 스레드는 즉시 반환값을 얻습니다.
- 작업이 아직 완료되지 않은 경우: 요청 스레드는 Future가 완료 상태가 될 때까지 RUNNABLE → WAITING 상태 로 들어갑니다. 이대기 동안 요청 스레드는 다른 작업을 수행할 수 없으며, 일시적으로 블로킹 상태가 됩니다.
요청 스레드는 future.get()을 호출한 뒤 WAITING 상태로 진입합니다.
이는 Future 내부의 작업 결과가 아직 준비되지 않았기 때문에, 결과가 반환될 때까지 대기하는 상태입니다.
스레드1은 taskA 작업을 처리하는 실행 스레드입니다.
🌟 블로킹 메서드
Thread.join() , Future.get() 과 같은 메서드는 스레드가 작업을 바로 수행하지 않고, 다른 작업이 완료될 때까지 기다리게 하는 메서드이다. 이러한 메서드를 호출하면 호출한 스레드는 지정된 작업이 완료될 때까지 블록(대기) 되어 다른 작업을 수행할 수 없습니다.
작업이 완료되면 다음 순서대로 동작합니다
- taskA의 작업을 정상적으로 완료합니다.
- Future 객체에 taskA의 반환값(result) 을 보관합니다.
- Future의 상태를 완료(completed) 로 변경합니다.
- future.get()으로 대기 중이던 요청 스레드를 깨웁니다. 이때 요청 스레드는 WAITING 상태에서 RUNNABLE 상태로 전환 됩니다.
- WAITING 상태였던 요청 스레드는 taskA 작업이 완료되면서 RUNNABLE 상태로 전환됩니다.
- 그리고 완료된 Future 객체에서 작업 결과를 꺼내어 반환받습니다.
- 이때 taskA의 결과는 Future 내부에 미리 저장되어 있으므로, 바로 값을 얻을 수 있습니다.
- taskA의 작업을 마친 스레드1은 더 이상 처리할 작업이 없으므로, 스레드 풀에 반환되어 WAITING 상태로 들어갑니다.
Future 의 실제 구현체인 FutureTask 를 확인해보면, 작업이 정상적으로 완료되었을 경우 "Completed normally" 같은 상태 메시지를 확인할 수 있습니다.
Future를 반환 하는 코드
Future<Integer> future = es.submit(new MyCallable()); // 여기는 블로킹 아님
future.get(); // 여기서 블로킹
이렇게 설계한다면 submit()을 호출하는 시점에 작업이 끝날 때까지 요청 스레드가 무조건 대기해야 합니다.
하지만 Future를 사용하는 현재 구조도 결국 future.get()을 호출할 때 작업 완료를 기다리므로, 결과를 기다리는 측면에서는 차이가 없습니다.
Integer result = es.submit(new MyCallable()); // 여기서 블로킹
그렇다면 ExecutorService 설계할 때 지금처럼 복잡하게 Future 를 반환하는게 아니라 위와 같이 결과를 직접 받도록 설계하는게 더 단순하고 좋지 않았을까요?
Future는 작업 제출 시점에 즉시 반환되기 때문에, 요청 스레드는 작업 완료를 기다리지 않고 다른 일을 할 수 있습니다.
즉, Future라는 개념은 작업의 결과를 기다릴 필요 없이 즉시 반환을 받고, 필요할 때만 결과를 기다릴 수 있게 유연성을 제공합니다.
예를 들어, 작업 스레드가 작업을 수행하는 데 2초가 걸린다고 가정하면, 메인 스레드는 submit() 호출 후 즉시 Future를 받고, 이 2초 동안 다른 작업을 수행할 수 있습니다. 필요한 시점에 future.get()을 호출해서 결과가 준비되었는지 확인하고, 아직 작업이 끝나지 않았다면 그때 잠시 대기하면서 결과를 받을 수 있습니다. 이런 구조 덕분에 프로그램은 작업의 완료 시점에 구애받지 않고 효율적으로 자원을 활용할 수 있으며, 동시성 프로그래밍의 유연성과 효율성을 크게 높일 수 있습니다.
정리
Future라는 개념이 없다면 결과를 받을 때까지 요청 스레드는 아무 일도 하지 못하고 무조건 대기해야 합니다.따라서 다른 작업을 동시에 수행할 수도 없습니다. 하지만 Future 덕분에 요청 스레드는 대기하지 않고 다른 작업을 수행할 수 있습니다. 예를 들어, 다른 작업들을 추가로 요청할 수 있습니다. 그리고 모든 작업 요청이 끝난 후, 실제 결과가 필요할 때 Future.get()을 호출해 최종 결과를 받을 수 있습니다. Future를 사용하면 task1, task2 같은 여러 작업을 동시에 요청할 수 있으며, 두 작업이 병렬로 제대로 수행될 수 있습니다. Future는 요청 스레드를 블로킹(대기) 상태로 만들지 않고 필요한 작업을 모두 제출할 수 있게 해줍니다. 그 후에 필요한 시점에 Future.get()을 호출해 블로킹 상태로 대기하면서 결과를 받을 수 있습니다. 이런 이유로 ExecutorService는 작업 결과를 직접 반환하지 않고, 미래의 결과를 담고 있는 Future를 반환하는 구조로 설계되어 있습니다.
5. Future 정리
Future는 작업의 미래 계산 결과를 나타내며, 작업이 완료되었는지 확인할 수 있고, 완료될 때까지 기다릴 수 있는 기능을 제공합니다.
Future 인터페이스
package java.util.concurrent;
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
enum State {
RUNNING,
SUCCESS,
FAILED,
CANCELLED
}
default State state() {}
}
주요 메서드
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() // (Java 19부터 지원)
- 기능: Future의 상태를 반환한다.
- 반환값: RUNNING(작업 실행 중), SUCCESS(성공 완료), FAILED(실패 완료), CANCELLED(취소 완료)
- 설명: 작업 진행 상황을 구체적으로 확인할 수 있다.
V get()
- 기능: 작업이 완료될 때까지 대기하고, 완료되면 결과를 반환한다.
- 반환값: 작업의 결과
- 예외:
- InterruptedException: 대기 중에 현재 스레드가 인터럽트된 경우 발생
- ExecutionException: 작업 계산 중에 예외가 발생한 경우 발생
- 설명: 작업이 완료될 때까지 get()을 호출한 현재 스레드를 대기(블로킹)한다. 작업이 완료되면 결과를 반환한다.
V get(long timeout, TimeUnit unit)
- 기능: get()과 같은데, 시간 초과되면 예외를 발생시킨다.
- 매개변수:
- timeout: 대기할 최대 시간
- unit: timeout 매개변수의 시간 단위 지정
- 반환값: 작업의 결과
- 예외:
- InterruptedException: 대기 중에 현재 스레드가 인터럽트된 경우 발생
- ExecutionException: 계산 중에 예외가 발생한 경우 발생
- TimeoutException: 주어진 시간 내에 작업이 완료되지 않은 경우 발생
- 설명: 지정된 시간 동안 결과를 기다린다. 시간이 초과되면 TimeoutException을 발생시킨다.
'Java' 카테고리의 다른 글
[JAVA] ExecutorService - graceful shutdown (0) | 2025.07.03 |
---|---|
[JAVA] 동시성 컬렉션 (1) | 2025.06.30 |
[JAVA] 원자적 연산 (0) | 2025.06.26 |
[JAVA] ReentrantLock (1) | 2025.06.24 |
[JAVA] synchronized 동기화 (0) | 2025.06.21 |