Future 활용

실전 예제로 보는 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 상태CANCELLEDCANCELLED
실행 중인 작업인터럽트로 중단 시도계속 실행
get() 호출 시CancellationExceptionCancellationException
사용 시나리오빠른 취소 필요실행 중인 작업 보호

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를 활용하면 복잡한 멀티스레드 프로그래밍을 마치 단일 스레드처럼 간단하게 작성할 수 있다. 특히 여러 작업을 병렬로 처리하고 결과를 수집해야 하는 실무 상황에서 매우 유용하다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 1편, 멀티스레드와 동시성