Callable과 Future

Runnable의 한계를 극복하고 반환 값을 받을 수 있는 Callable과 Future에 대해 알아본다

Runnable의 한계

기존 Runnable 인터페이스를 살펴본다

package java.lang;

public interface Runnable {
    void run();
}

Runnable은 다음과 같은 명확한 한계가 있다

반환 값이 없음

  • run()메서드는 void 타입입니다
  • 작업 결과를 받으려면 별도의 메커니즘(멤버 변수 등)이 필요하다

예외 처리의 제약

  • 체크 예외를 던질 수 없다
  • 모든 예외를 메서드 내부에서 처리해야 한다

자식은 부모의 예외 범위를 넘어설 수 없기 때문이다

💡 참고: 런타임(비체크) 예외는 제외된다

Callable – 반환 값이 있는 작업

이러한 문제를 해결하기 위해 Callable이 등장했다

package java.util.concurrent;

public interface Callable<V> {
    V call() throws Exception;
}

Runnable vs Callable 비교

특징RunnableCallable
도입 시기Java 1.0Java 1.5
패키지java.langjava.util.concurrent
메서드void run()V call() throws Exception
반환 값없음제네릭 타입 V
예외 처리체크 예외 불가Exception 및 하위 예외 가능

Callable의 장점

  • 반환 값 지원: 제네릭을 통해 타입 안정성 보장
  • 예외 처리 간편: 체크 예외를 던질 수 있음
  • 결과 보관 필드 불필요: return으로 직접 반환

Callable과 Future 사용하기

기본 사용 예제

public class CallableMainV1 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService es = Executors.newFixedThreadPool(1);
        
        // Callable 작업 제출 및 Future 반환
        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);  // 2초 작업 시뮬레이션
            int value = new Random().nextInt(10);
            log("create value = " + value);
            log("Callable 완료");
            return value;  // 결과 직접 반환!
        }
    }
}

실행 결과

[pool-1-thread-1] Callable 시작
[pool-1-thread-1] create value = 7
[pool-1-thread-1] Callable 완료
[main] result value = 7

편의 메서드 사용

ThreadPoolExecutor를 직접 생성하는 대신, Executors 유틸리티를 사용하면 더 간결해진다

// 기존 방식
ExecutorService es = new ThreadPoolExecutor(
    1, 1, 0, TimeUnit.MILLISECONDS, 
    new LinkedBlockingQueue<>()
);

// 편의 메서드
ExecutorService es = Executors.newFixedThreadPool(1);
submit()과 Future의 관계
// ExecutorService의 submit() 메서드 정의
<T> Future<T> submit(Callable<T> task);

submit() 메서드는 Callable 작업을 받아 Future 객체를 즉시 반환한다

핵심 포인트
  • submit()은 즉시 반환된다 (논블로킹)
  • 작업의 실제 결과가 아닌 Future라는 약속 객체를 반환한다
  • future.get()을 호출해야 실제 결과를 받을 수 있다

Executor 프레임워크의 강점

// 이렇게 간결하게 사용 가능
Integer result = es.submit(new MyCallable()).get();
이 코드의 놀라운 점
  • 스레드 생성 코드 없음: new Thread() 없음
  • 스레드 제어 코드 없음: join() 없음
  • Thread라는 단어 조차 없음: 완전히 추상화

단순하게 작업을 요청하고 결과를 받는 것처럼 보이지만, 내부적으로는 복잡한 멀티스레드 작업이 진행된다

Future란 무엇인가

Future = 미래의 결과를 담는 객체

Future<Integer> future = es.submit(new MyCallable());

결과를 바로 반환하지 않고 Future를 반환하는 이유는 즉시 결과를 반환하는 것이 불가능하기 때문이다

  • MyCallable은 즉시 실행되지 않는다
  • 스레드 풀의 스레드가 미래의 어떤 시점에 실행한다
  • 언제 완료될지 알 수 없다

따라서 “미래에 결과를 받을 수 있는 약속” = Future를 제공한다

Future의 동작 원리

public class CallableMainV2 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService es = Executors.newFixedThreadPool(1);
        
        log("submit() 호출");
        Future<Integer> future = es.submit(new MyCallable());
        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() {
            log("Callable 시작");
            sleep(2000);
            int value = new Random().nextInt(10);
            log("create value = " + value);
            log("Callable 완료");
            return value;
        }
    }
}

실행 결과

[main] submit() 호출
[pool-1-thread-1] Callable 시작
[main] future 즉시 반환, future = FutureTask@46d56d67[Not completed, task = ...]
[main] future.get() [블로킹] 메서드 호출 시작 -> main 스레드 WAITING
[pool-1-thread-1] create value = 8
[pool-1-thread-1] Callable 완료
[main] future.get() [블로킹] 메서드 호출 완료 -> main 스레드 RUNNABLE
[main] result value = 8
[main] future 완료, future = FutureTask@46d56d67[Completed normally]

실핼 과정 단계별 분석

1단계: submit() 호출 (논블로킹)

submit() 호출
동작
  • ExecutorServiceFuture 객체를 생성한다
  • 실제 구현체는 FutureTask이다
  • Future 내부에 작업(taskA)을 보관한다
  • Future는 다음 정보를 가진다
    • 완료 여부: 아직 미완료 (Not completed)
    • 결과 값: 아직 없음
    • 연관 작업: MyCallable 인스턴스
핵심
  • taskA가 직접 BlockingQueue에 들어가는 것이 아니다
  • taskA를 감싸고 있는 Future가 BlockingQueue에 들어간다

2단계: Future 즉시 반환

future 즉시 반환, future = FutureTask@46d56d67[Not completed, ...]
중요 특징
  • Future는 즉시 반환 된다 (논블로킹)
  • 마치 Thread.start()를 호출한 것과 유사하다
  • 요청 스레드(main)는 대기하지 않고 다음 코드를 실행한다
Future의 상태
FutureTask@46d56d67[Not completed, task = MyCallable@14acaea5]
  • 완료 여부: Not completed (미완료)
  • 연관 작업: MyCallable 인스턴스

3단계: Callable 실행 시작

Callable 시작 (pool-1-thread-1)
동작
  • 스레드 풀의 스레드1이 BlockingQueue에서 Future를 꺼낸다
  • FutureTaskRunnable 인터페이스도 구현하고 있다
  • 스레드1은 FutureTask.run()을 실행한다
  • run()은 내부에서 MyCallable.call()을 호출한다
FutureTask의 비밀
// FutureTask는 RunnableFuture 구현
class FutureTask<V> implements RunnableFuture<V> {
    public void run() {
        Callable<V> c = callable;
        V result = c.call();  // Callable 호출!
        set(result);          // 결과 저장
    }
}

4단계: future.get() 호출 (블로킹)

future.get() [블로킹] 메서드 호출 시작 -> main 스레드 WAITING

여기서 두 가지 상황이 발생할 수 있다

상황 1 – Future가 완료 상태인 경우
  • Future에 이미 결과가 포함되어 있다
  • 요청 스레드는 대기하지 않고 즉시 값을 반환 받는다
상황 2 – Future가 완료 상태가 아닌 경우 (현재 상황)
  • 작업이 아직 수행 중이거나 시작하지 않았다
  • 요청 스레드는 WAITING 상태로 대기해야 한다
  • 결과를 받을 수 없으므로 어쩔 수 없다
블로킹 메서드

블로킹(Blocking) = 스레드가 어떤 결과를 얻기 위해 대기하는 것

대표적인 블로킹 메서드
  • Thread.join(): 대상 스레드가 종료될 때까지 대기
  • Future.get(): 작업 결과가 준비될 때까지 대기

이러한 메서드를 호출하면 호출한 스레드는 지정된 작업이 완료될 때까지 블록(차단)되어 다른 작업을 수행할 수가 없다

5단계: 작업 완료 및 스레드 깨우기

create value = 8 (pool-1-thread-1)
Callable 완료 (pool-1-thread-1)
스레드1의 작업
  • taskA 작업을 완료한다
  • Future에 결과 값(8)를 저장한다
  • Future의 상태를 완료로 변경한다
  • 대기 중인 요청 스레드를 깨운다
핵심 동작
  • 스레드1이 Future에 결과를 담고 완료 처리를 한다
  • Future는 어떤 스레드가 대기 중인지 알고 있다
  • 스레드1이 대기 중인 main 스레드를 깨운다
  • main 스레드: WAITING → RUNNABLE

6단계: 결과 반환

future.get() [블로킹] 메서드 호출 완료 -> main 스레드 RUNNABLE
result value = 8
요청 스레드 (main)
  • RUNNABLE 상태가 되었다
  • 완료 상태의 Future에서 결과를 받는다
  • taskA의 결과가 Future에 담겨있다
스레드1
  • 작업을 마치고 스레드 풀로 반환된다
  • RUNNABLE → WAITING 상태로 변경
  • BlockingQueue에 새 작업이 들어오기를 대기한다

7단계: 완료 상태 확인

future 완료, future = FutureTask@46d56d67[Completed normally]

Future가 상태가 “Completed normally”로 정상 완료되었음을 확인할 수 있다

Future 동작 원리 정리

Future의 핵심 특징

Future는 작업의 미래 결과를 받을 수 있는 객체
  • 전달한 작업의 미래 결과를 담고 있다
submit() 호출 시 Future는 즉시 반환 (논블로킹)
  • 요청 스레드는 블로킹되지 않는다
  • 필요한 다른 작업을 계속 수행할 수 있다
결과가 필요할 때 future.get() 호출

future.get()의 두 가지 상황

Future의 완료 상태인 경우
Future<Integer> future = es.submit(task);
// ... 시간이 충분히 지나서 작업 완료됨
Integer result = future.get();  // 즉시 반환
  • Future에 이미 결과가 포함되어 있다
  • 요청 스레드는 대기하지 않고 즉시 값을 받는다
Future의 완료 상태가 아닌 경우
Future<Integer> future = es.submit(task);
Integer result = future.get();  // 여기서 블로킹
  • 작업이 아직 수행 중이거나 시작하지 않았다
  • 요청 스레드는 결과를 받기 위해 블로킹 상태로 대기한다
  • 작업이 완료되면 해당 스레드가 요청 스레드를 깨운다

Future가 필요한 이유

왜 Future를 사용하는지 의문이 들 수가 있다

방법 1: Future를 반환 (현재 방식)
Future<Integer> future = es.submit(new MyCallable());  // 논블로킹
// ... 다른 작업 수행 가능
Integer result = future.get();  // 필요할 때 블로킹
방법 2: 결과를 직접 반환 (가정)
Integer result = es.submit(new MyCallable());  // 여기서 블로킹

언뜻 보면 방법 2가 더 간단해 보이지만 두 방식 모두 어차피 블로킹이 필요하다

  • 방법 1: future.get()에서 블로킹
  • 방법 2: submit()에서 블로킹

복잡하게 굳이 Future를 사용해야 하는 이유

시나리오 – 여러 작업을 동시에 실행

// Future 사용 (논블로킹)
Future<Integer> future1 = es.submit(task1);  // 즉시 반환
Future<Integer> future2 = es.submit(task2);  // 즉시 반환
Future<Integer> future3 = es.submit(task3);  // 즉시 반환

// 여기서 다른 작업 수행 가능
doSomethingElse();

// 필요할 때 결과 수집
Integer result1 = future1.get();  // 이미 완료되었을 수도 있음
Integer result2 = future2.get();
Integer result3 = future3.get();
// Future 없이 직접 반환 (블로킹)
Integer result1 = es.submit(task1);  // 여기서 대기
Integer result2 = es.submit(task2);  // 여기서 대기  
Integer result3 = es.submit(task3);  // 여기서 대기
// 순차적으로 실행되어 병렬성 활용 불가

Future의 진가

  • 여러 작업을 동시에 시작할 수 있다
  • 작업이 진행되는 동안 다른 작업을 수행할 수 있다
  • 필요할 때만 결과를 가져온다
  • 병렬 처리의 이점을 최대한 활용할 수 있다

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