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 비교
| 특징 | Runnable | Callable |
| 도입 시기 | Java 1.0 | Java 1.5 |
| 패키지 | java.lang | java.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() 호출
동작
- ExecutorService가 Future 객체를 생성한다
- 실제 구현체는
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를 꺼낸다
- FutureTask는
Runnable 인터페이스도 구현하고 있다 - 스레드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의 진가
- 여러 작업을 동시에 시작할 수 있다
- 작업이 진행되는 동안 다른 작업을 수행할 수 있다
- 필요할 때만 결과를 가져온다
- 병렬 처리의 이점을 최대한 활용할 수 있다