실전 예제로 보는 Future
Runnable 방식의 한계
전통적인 Runnable 방식으로 1부터 100까지의 합을 병렬 처리해본다
public class SumTaskMainV1 {
public static void main(String[] args) throws InterruptedException {
SumTask task1 = new SumTask(1, 50);
SumTask task2 = new SumTask(51, 100);
Thread thread1 = new Thread(task1, "thread-1");
Thread thread2 = new Thread(task2, "thread-2");
thread1.start();
thread2.start();
// 스레드가 종료될 때까지 대기
log("join() - main 스레드가 thread1, thread2 종료까지 대기");
thread1.join();
thread2.join();
log("main 스레드 대기 완료");
// 결과 수집
log("task1.result=" + task1.result);
log("task2.result=" + task2.result);
int sumAll = task1.result + task2.result;
log("task1 + task2 = " + sumAll);
log("End");
}
static class SumTask implements Runnable {
int startValue;
int endValue;
int result = 0; // 결과를 필드에 저장
public SumTask(int startValue, int endValue) {
this.startValue = startValue;
this.endValue = endValue;
}
@Override
public void run() {
log("작업 시작");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
int sum = 0;
for (int i = startValue; i <= endValue; i++) {
sum += i;
}
result = sum;
log("작업 완료 result=" + result);
}
}
}
실행 결과
[thread-2] 작업 시작
[thread-1] 작업 시작
[main] join() - main 스레드가 thread1, thread2 종료까지 대기
[thread-2] 작업 완료 result=3775
[thread-1] 작업 완료 result=1275
[main] main 스레드 대기 완료
[main] task1.result=1275
[main] task2.result=3775
[main] task1 + task2 = 5050
Runnable 방식의 문제점
- 결과를 필드에 저장해야 한다
join()으로 명시적으로 대기해야 한다- 스레드 생성 및 관리 코드가 복잡해진다
- 체크 예외를 던질 수 없다
Callable과 Future로 개선
같은 작업을 Callable과 Future로 구현
public class SumTaskMainV2 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
SumTask task1 = new SumTask(1, 50);
SumTask task2 = new SumTask(51, 100);
ExecutorService es = Executors.newFixedThreadPool(2);
// 작업 제출 및 Future 획득
Future<Integer> future1 = es.submit(task1);
Future<Integer> future2 = es.submit(task2);
// 결과 획득
Integer sum1 = future1.get();
Integer sum2 = future2.get();
log("task1.result=" + sum1);
log("task2.result=" + sum2);
int sumAll = sum1 + sum2;
log("task1 + task2 = " + sumAll);
log("End");
es.close();
}
static class SumTask implements Callable<Integer> {
int startValue;
int endValue;
public SumTask(int startValue, int endValue) {
this.startValue = startValue;
this.endValue = endValue;
}
@Override
public Integer call() throws InterruptedException {
log("작업 시작");
Thread.sleep(2000);
int sum = 0;
for (int i = startValue; i <= endValue; i++) {
sum += i;
}
log("작업 완료 result=" + sum);
return sum; // 결과 직접 반환!
}
}
}
실행 결과
[pool-1-thread-1] 작업 시작
[pool-1-thread-2] 작업 시작
[pool-1-thread-1] 작업 완료 result=1275
[pool-1-thread-2] 작업 완료 result=3775
[main] task1.result=1275
[main] task2.result=3775
[main] task1 + task2 = 5050
개선된 점
- 결과를
return으로 직접 반환 join()불필요 –future.get()으로 자동 대기- 스레드 생성/관리 코드 제거
- 체크 예외 던지기 가능 (
throws InterruptedException)
💡 핵심: 마치 단일 스레드에서 일반 메서드를 호출하는 것처럼 느껴진다
Future가 필요한 진짜 이유
잘못된 가정: Future 없이 결과 직접 반환
만약 Future 없이 결과를 바로 반환한다면?
// 가정 코드 (실제로는 불가능) Integer sum1 = es.submit(task1); // 여기서 블로킹 Integer sum2 = es.submit(task2); // 여기서 블로킹 // 총 4초 소요
문제점: 순차 실행과 동일
- task1 제출 → 2초 대기 (블로킹)
- 결과 수신 후 task2 제출 → 2초 대기 (블로킹)
- 총 4초 소요 (병렬 처리의 이점이 없음)
Future를 사용하는 올바른 방법
// 올바른 방법 Future<Integer> future1 = es.submit(task1); // 논블로킹 Future<Integer> future2 = es.submit(task2); // 논블로킹 // 여기서 다른 작업 수행 가능! Integer sum1 = future1.get(); // 블로킹 (약 2초) Integer sum2 = future2.get(); // 즉시 반환 (이미 완료됨) // 총 2초 소요
동작 방식
작업 제출 단계 (논블로킹)
- task1 제출 →
Future즉시 반환 → 작업 스레드1이 실행 - task2 제출 →
Future즉시 반환 → 작업 스레드2이 실행 - 두 작업이 동시에 실행 된다
결과 수집 단계
future1.get()→ 약 2초 대기 (작업 완료 때까지)future2.get()→ 즉시 반환 (이미 2초 동안 실행 완료됨)
핵심 원리
- Future 없을 경우: submit(블로킹) → submit(블로킹) → 순차 실행 (4초)
- Future 사용 경우: submit(즉시) → submit(즉시) → get(대기) → 병렬 실행 (2초)
Future를 잘못 사용하는 안티패턴
안티패턴 1 – submit 직후 get() 호출
Future<Integer> future1 = es.submit(task1); Integer sum1 = future1.get(); // 즉시 블로킹 Future<Integer> future2 = es.submit(task2); Integer sum2 = future2.get(); // 즉시 블로킹 // 총 4초 - 병렬 처리 이점 없음!
안티패턴 2 – 메서드 체이닝으로 get() 호출
Integer sum1 = es.submit(task1).get(); // 블로킹 Integer sum2 = es.submit(task2).get(); // 블로킹 // 총 4초 - 위와 동일한 문제
실제 검증
public class SumTaskMainV2_Bad {
public static void main(String[] args) throws ExecutionException, InterruptedException {
SumTask task1 = new SumTask(1, 50);
SumTask task2 = new SumTask(51, 100);
ExecutorService es = Executors.newFixedThreadPool(2);
Future<Integer> future1 = es.submit(task1);
Integer sum1 = future1.get(); // 2초 대기
Future<Integer> future2 = es.submit(task2);
Integer sum2 = future2.get(); // 2초 대기
// 결과 처리...
es.close();
}
}
실행 결과
[pool-1-thread-1] 작업 시작
[pool-1-thread-1] 작업 완료 result=1275
[pool-1-thread-2] 작업 시작 ← task2가 이제 시작!
[pool-1-thread-2] 작업 완료 result=3775
총 4초 소요 – 멀티스레드를 사용했지만 싱글스레드와 동일한 성능
정리 – Future의 존재 이유
Future가 없다면
- 요청 스레드가 결과를 받을 때까지 블로킹된다
- 다른 작업을 동시에 제출할 수 없다
- 병렬 처리의 이점을 활용할 수 없다
Future가 있기 때문에
- 요청 스레드가 블로킹되지 않고 계속 작업이 가능하다
- 모든 작업을 먼저 제출한 후 결과 수집이 가능하다
- 진정한 병렬 처리 구현이 가능하다
핵심
- Future는 “결과를 나중에 받을 수 있는 약속”이다. 이를 통해 요청 스레드는 자유롭게 여러 작업을 제출하고, 필요한 시점에
get()으로 결과를 수집할 수 있다
Future 인터페이스
주요 메서드
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;
// Java 19+
enum State { RUNNING, SUCCESS, FAILED, CANCELLED }
default State state() { }
}
cancel(boolean) – 작업을 취소하는 메서드
매개변수
- cancel(true): Future를 취소 상태로 변경 + 실행 중인 작업에 인터럽트 발생
- cancel(false): Future를 취소 상태로 변경 (실행 중인 작업은 그대로 진행)
반환값
- true: 작업이 성공적으로 취소된다
- false: 이미 완료되었거나 취소할 수 없다
중요한 부분
- 취소된 Future에
get()호출 시CancellationException(런타임 예외)발생
isCancelled() / isDone()
boolean isCancelled() // 취소 여부 확인 boolean isDone() // 완료 여부 확인
isDone()의 의미
- 정상 완료, 취소, 예외 발생 등 모든 종료 상태에서 true 반환
- “성공”이 아닌 “종료”여부를 나타냄
State state() (Java 19+)
enum State {
RUNNING, // 작업 실행 중
SUCCESS, // 성공 완료
FAILED, // 실패 완료
CANCELLED // 취소 완료
}
get() 메서드
기본 get()
V get() throws InterruptedException, ExecutionException
- 작업이 완료될 때까지 무한정 대기
- 완료 시 결과 반환
타임아웃 버전
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException
- 지정된 시간만큼만 대기
- 시간 초과 시
TimeoutException발생
예외 종류
- InterruptedException: 대기 중 인터럽트 발생
- ExecutionException: 작업 수행 중 예외 발생 (원본 예외를 포함)
- TimeoutException: 타임아웃 발생
Future 취소 (cancel) 실전
cancel(true) vs cancel(false)
public class FutureCancelMain {
private static boolean mayInterruptIfRunning = true; // 변경해가며 테스트
// private static boolean mayInterruptIfRunning = false; // 변경해가며 테스트
public static void main(String[] args) {
ExecutorService es = Executors.newFixedThreadPool(1);
Future<String> future = es.submit(new MyTask());
log("Future.state: " + future.state());
// 3초 후 취소 시도
sleep(3000);
log("future.cancel(" + mayInterruptIfRunning + ") 호출");
boolean cancelResult = future.cancel(mayInterruptIfRunning);
log("Future.state: " + future.state());
log("cancel result: " + cancelResult);
// 결과 확인
try {
log("Future result: " + future.get());
} catch (CancellationException e) {
log("Future는 이미 취소되었습니다.");
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
es.close();
}
static class MyTask implements Callable<String> {
@Override
public String call() {
try {
for (int i = 0; i < 10; i++) {
log("작업 중: " + i);
Thread.sleep(1000);
}
} catch (InterruptedException e) {
log("인터럽트 발생");
return "Interrupted";
}
return "Completed";
}
}
}
cancel(true) 실행 결과
[main] Future.state: RUNNING [pool-1-thread-1] 작업 중: 0 [pool-1-thread-1] 작업 중: 1 [pool-1-thread-1] 작업 중: 2 [main] future.cancel(true) 호출 [pool-1-thread-1] 인터럽트 발생 ← 작업 중단! [main] Future.state: CANCELLED [main] cancel result: true [main] Future는 이미 취소되었습니다.
동작
- Future 상태가 CANCELLED로 변경
- 실행 중인 작업에
Thread.interrupt()호출 - 작업이 중단됨
cancel(false) 실행 결과
[main] Future.state: RUNNING [pool-1-thread-1] 작업 중: 0 [pool-1-thread-1] 작업 중: 1 [pool-1-thread-1] 작업 중: 2 [main] future.cancel(false) 호출 [main] Future.state: CANCELLED [main] cancel result: true [main] Future는 이미 취소되었습니다. [pool-1-thread-1] 작업 중: 3 ← 작업 계속 진행! [pool-1-thread-1] 작업 중: 4 ... [pool-1-thread-1] 작업 중: 9
동작
- Future 상태가 CANCELLED로 변경
- 실행 중인 작업은 그대로 진행
- 클라이언트는 결과를 받을 수 없듬(
get()호출 시 예외 발생)
| 구분 | cancel(true) | cancel(false) |
| Future 상태 | CANCELLED | CANCELLED |
| 실행 중인 작업 | 인터럽트로 중단 시도 | 계속 실행 |
| get() 호출 시 | CancellationException | CancellationException |
| 사용 시나리오 | 빠른 취소 필요 | 실행 중인 작업 보호 |
Future와 예외 처리
작업 중 예외 발생 시 처리
Future는 결과뿐만 아니라 예외도 전달할 수 있다
public class FutureExceptionMain {
public static void main(String[] args) {
ExecutorService es = Executors.newFixedThreadPool(1);
log("작업 전달");
Future<Integer> future = es.submit(new ExCallable());
sleep(1000);
try {
log("future.get() 호출 시도, future.state(): " + future.state());
Integer result = future.get();
log("result value = " + result);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
log("e = " + e);
Throwable cause = e.getCause(); // 원본 예외
log("cause = " + cause);
}
es.close();
}
static class ExCallable implements Callable<Integer> {
@Override
public Integer call() {
log("Callable 실행, 예외 발생");
throw new IllegalStateException("ex!");
}
}
}
실행 결과
[main] 작업 전달
[pool-1-thread-1] Callable 실행, 예외 발생
[main] future.get() 호출 시도, future.state(): FAILED
[main] e = java.util.concurrent.ExecutionException: java.lang.IllegalStateException: ex!
[main] cause = java.lang.IllegalStateException: ex!
예외 처리 메커니즘
작업 스레드에서 예외 발생
throw new IllegalStateException("ex!");
Future에 예외 저장
- 예외는 객체이므로 Future 내부 필드에 보관 가능
- Future 상태가 FAILED로 변경
get() 호출 시 ExecutionException 발생
catch (ExecutionException e) {
Throwable cause = e.getCause(); // 원본 예외 추출
}
예외 체인 구조
ExecutionException
└─ cause: IllegalStateException (원본 예외)
예외 처리의 핵심
try {
result = future.get();
} catch (ExecutionException e) {
// ExecutionException은 래퍼 예외
Throwable originalException = e.getCause();
// 원본 예외 타입 확인 및 처리
if (originalException instanceof IllegalStateException) {
// 구체적인 예외 처리
}
}
장점
- 마치 일반 메서드 호출처럼 예외를 받을 수 있다
- 멀티스레드 환경에서도 자연스러운 예외 처리 가능
Executor 프레임워크의 뛰어난 설계를 보여주는 예
💡 설계 철학: "스레드를 사용하지만, 스레드를 사용하지 않는 것처럼 개발할 수 있게 하자"
작업 컬렉션 처리
CallableTask 준비
public class CallableTask implements Callable<Integer> {
private final String name;
private int sleepMs = 1000;
public CallableTask(String name) {
this.name = name;
}
public CallableTask(String name, int sleepMs) {
this.name = name;
this.sleepMs = sleepMs;
}
@Override
public Integer call() throws Exception {
log(name + " 실행");
sleep(sleepMs);
log(name + " 완료");
return sleepMs;
}
}
invokeAll() – 모든 작업 완료 대기
여러 작업을 제출하고 모든 작업이 완료될 때까지 대기한다
public class InvokeAllMain {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService es = Executors.newFixedThreadPool(10);
CallableTask task1 = new CallableTask("task1", 1000);
CallableTask task2 = new CallableTask("task2", 2000);
CallableTask task3 = new CallableTask("task3", 3000);
List<CallableTask> tasks = List.of(task1, task2, task3);
// 모든 작업 제출 및 완료 대기
List<Future<Integer>> futures = es.invokeAll(tasks);
// 결과 수집
for (Future<Integer> future : futures) {
Integer value = future.get();
log("value = " + value);
}
es.close();
}
}
실행 결과
[pool-1-thread-1] task1 실행
[pool-1-thread-2] task2 실행
[pool-1-thread-3] task3 실행
[pool-1-thread-1] task1 완료
[pool-1-thread-2] task2 완료
[pool-1-thread-3] task3 완료 ← 여기서 invokeAll() 반환
[main] value = 1000
[main] value = 2000
[main] value = 3000
특징
- 모든 작업이 병렬로 실행된다
- 가장 오래 걸리는 작업 (task3 – 3초)이 완료될 때까지 대기한다
- 총 실행 시간 – 3초
invokeAny() – 가장 빠른 작업만 사용
여러 작업 중 가장 먼저 완료된 작업의 결과만 반환하고 나머지는 취소한다
public class InvokeAnyMain {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService es = Executors.newFixedThreadPool(10);
CallableTask task1 = new CallableTask("task1", 1000);
CallableTask task2 = new CallableTask("task2", 2000);
CallableTask task3 = new CallableTask("task3", 3000);
List<CallableTask> tasks = List.of(task1, task2, task3);
// 가장 빠른 작업의 결과만 반환
Integer value = es.invokeAny(tasks);
log("value = " + value);
es.close();
}
}
실행 결과
[pool-1-thread-1] task1 실행
[pool-1-thread-2] task2 실행
[pool-1-thread-3] task3 실행
[pool-1-thread-1] task1 완료 ← 가장 먼저 완료
[main] value = 1000 ← 즉시 반환
[pool-1-thread-2] 인터럽트 발생, sleep interrupted
[pool-1-thread-3] 인터럽트 발생, sleep interrupted
특징
- task1이 먼저 완료되어 즉시 반환한다
- task2, task3은 인터럽트로 취소된다
- 총 실행 시간 – 1초
invokeAll() 사용 예시
- 여러 데이터 소스에서 데이터 수집
- 배치 처리에서 모든 작업 완료 필요
- 데이터 집계 및 분석
invokeAny 사용 에시
- 여러 서버에 동일 요청, 가장 빠른 응답 사용
- 중복 계산으로 빠른 결과 획득
- 장애 대응 (여러 백업 서버 중 하나라도 성공하면 됨)
Callable과 Future를 활용하면 복잡한 멀티스레드 프로그래밍을 마치 단일 스레드처럼 간단하게 작성할 수 있다. 특히 여러 작업을 병렬로 처리하고 결과를 수집해야 하는 실무 상황에서 매우 유용하다