Callable과 Future
Runnable의 결과 전달 문제를 확인하고 Callable·Future로 반환값과 실행 예외를 수집합니다.
Runnable은 반환값이 없고 검사 예외를 선언할 수 없습니다.
결과를 공유 필드에 쓰고 join 뒤 읽는 방식은 작업 관리 코드와 동기화 책임을 호출자에게 퍼뜨립니다.
Callable<T>는 결과 타입과 예외를 작업 규칙에 포함하고, submit은 작업을 대표하는 Future<T>를 반환합니다. 여기의 고정 풀에서는 작업 결과를 기다리려면 별도로 get을 호출합니다.
Future는 결과 그 자체가 아니라 아직 진행 중일 수 있는 계산의 상태를 확인하고 결과를 받을 수 있는 참조입니다.
get은 완료까지 기다리고 작업 예외를 ExecutionException의 원인으로 전달합니다.
호출자는 실패 원인을 분류하고 실행기를 종료해야 합니다.
결과 필드를 완료 전에 읽는 경쟁
public final class SharedResultWithoutJoin {
private int result;
void calculate() { result = 21 * 2; }
public static void main(String[] args) {
SharedResultWithoutJoin task = new SharedResultWithoutJoin();
Thread.ofPlatform().start(task::calculate);
System.out.println("result=" + task.result);
}
}한 번의 실행에서는 result=0을 출력했습니다. 완료 대기 없이 읽는 값은 0 또는 42일 수 있으며, 이 값 하나로 작업 완료 시점을 확인할 수는 없습니다.
volatile을 붙여도 완료 전에 읽는 순서 문제는 해결되지 않습니다.
join을 직접 쓰거나 Future의 완료 규칙을 사용해야 합니다.
Future 결과 형식
완료 여부와 성공 여부를 나누어 봅니다. isDone()은 정상 반환뿐 아니라 예외로 끝나거나 취소된 경우에도 true입니다. 완료 플래그만 보고 결과 값을 쓰지 말고 get()의 정상 반환·ExecutionException·CancellationException을 구별해야 합니다. get(시간, 단위)의 TimeoutException은 호출자의 이번 대기가 끝났다는 뜻이며 작업을 자동 취소하지 않습니다. 재대기할지, 별도로 cancel을 요청할지는 호출자의 정책입니다. 취소된 Future의 완료 상태가 실제 작업 코드의 종료 확인까지 대신하지도 않습니다.
- 여기의 고정 풀은 submit으로 작업을 접수하고 Future를 반환한다. 호출자는 get으로 결과를 수집한다.
get은 완료 전 호출되면 현재 스레드를 대기시킨다.- 정상 반환값은 Callable의 타입 T로 전달된다.
- 작업에서 던진 예외는 ExecutionException.getCause로 확인한다.
- 취소된 Future의 get은 CancellationException을 던진다. 이 절의 main은 취소 경로를 실행하지 않는다.
- 대기 중 인터럽트는 InterruptedException으로 호출자 취소를 전달한다.
- 결과 수집 뒤에도 ExecutorService 수명주기를 종료해야 한다.
타입이 있는 결과와 오류 분류
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class CallableResultDemo {
record Quote(String symbol, int price) {}
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<Quote> ok = executor.submit(() -> new Quote("JAVA", 25));
Future<Quote> broken = executor.submit(() -> {
throw new IllegalArgumentException("unknown symbol");
});
try {
System.out.println(ok.get());
broken.get();
} catch (ExecutionException e) {
System.out.println("cause=" + e.getCause().getClass().getSimpleName());
} finally {
executor.shutdownNow();
}
}
}공유 필드 읽기와 Future.get의 결과 경계를 구분한다
직접 필드 읽기와 Future.get 정상 결과/예외 전달의 완료 경계를 대조합니다. 작업 발생·대기·결과 관찰을 단계로 구분해 완료와 결과 전달의 차이를 확인할 수 있습니다.
SharedResultWithoutJoin의 직접 읽기와 CallableResultDemo의 ok.get, broken.get를 원문별로 비교합니다. 취소와 호출자 인터럽트 경로를 실행한 표가 아닙니다.
결과 읽기 · 직접 필드와 Future
공유 필드 읽기와 Future.get의 결과 경계를 구분한다
직접 읽기는 완료를 기다리지 않지만, get은 값 또는 작업 예외를 받습니다.
직접 읽기는 완료를 기다리지 않는다
작업자의 공유 필드 기록과 main의 대기 없는 읽기를 별도 경로로 표시한다. 값 0 또는 42는 다른 프로그램이며 Future 결과의 앞 단계가 아니다.
두 Future는 값과 작업 예외를 따로 전달한다
각 Future가 정상 값 또는 작업 원인을 보관한다. main은 ok.get 다음 broken.get을 호출하며 두 get에 시간 제한은 없다.
상태별 상세 해설
SharedResultWithoutJoin: task.result
- 얻는 값 또는 예외
- 초기값 0 또는 기록된 42
- 그 지점의 완료 의미
- 읽기 앞에 join이나 다른 완료 대기가 없음
CallableResultDemo: ok.get()
- 얻는 값 또는 예외
- Quote[symbol=JAVA, price=25]
- 그 지점의 완료 의미
- 해당 Callable의 정상 완료 결과를 받음
CallableResultDemo: broken.get()
- 얻는 값 또는 예외
- ExecutionException · 원인은 IllegalArgumentException
- 그 지점의 완료 의미
- 작업 예외를 Future에 보관했다가 get에서 전달
두 get에는 시간 제한이 없습니다. broken.get의 예외는 main이 잡아 cause를 출력하며, finally의 shutdownNow 호출 자체가 실행기 종료 완료를 확인하지는 않습니다.
이 예의 조건
- CallableResultDemo 한 프로그램의 정상 ok.get 뒤 실패 broken.get를 추적합니다. 두 get에는 시간 제한이 없습니다.
- SharedResultWithoutJoin의 직접 필드 읽기 0/42는 별도 프로그램이며 전체 표에 보존합니다.
- 취소와 호출자 인터럽트는 이 비교에서 실행한 경로가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 8 | 초깃값 0 또는 기록된 42 |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 공유 필드 읽기와 Future.get의 결과 경계를 구분한다 공유 필드 읽기와 Future.get의 결과 경계를 구분한다 SharedResultWithoutJoin의 직접 읽기와 CallableResultDemo의 ok.get, broken.get를 원문별로 비교합니다. 취소와 호출자 인터럽트 경로를 실행한 표가 아닙니다. 결과 읽기 · 직접 필드와 Future 공유 필드 읽기와 Future.get의 결과 경계를 구분한다 직접 읽기는 완료를 기다리지 않지만, get은 값 또는 작업 예외를 받습니다. SharedResultWithoutJoin: task.result 얻는 값 또는 예외 초깃값 0 또는 기록된 42 그 지점의 완료 의미 읽기 앞에 join이나 다른 완료 대기가 없음 CallableResultDemo: ok.get() 얻는 값 또는 예외 Quote[symbol=JAVA, price=25] 그 지점의 완료 의미 해당 Callable의 정상 완료 결과를 받음 CallableResultDemo: broken.get() 얻는 값 또는 예외 ExecutionException · 원인은 IllegalArgumentException 그 지점의 완료 의미 작업 예외를 Future에 보관했다가 get에서 전달 두 get에는 시간 제한이 없습니다. broken.get의 예외는 main이 잡아 cause를 출력하며, finally의 shutdownNow 호출 자체가 실행기 종료 완료를 확인하지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 Callable 결과 보관 제출한 정상·실패 Callable의 결과를 각 Future에서 받도록 준비합니다. |
|
|
| ok.get 정상 결과 main이 ok.get으로 해당 Callable의 정상 완료 결과를 받아 출력합니다. |
|
|
| broken.get 실패 전달 broken.get의 ExecutionException을 catch해 원인을 출력하고 종료를 요청합니다. |
|
|
공유 필드 읽기와 Future.get의 결과 경계를 구분한다
직접 필드 읽기와 Future.get 정상 결과/예외 전달의 완료 경계를 대조합니다. 작업 발생·대기·결과 관찰을 단계로 구분해 완료와 결과 전달의 차이를 확인할 수 있습니다.
SharedResultWithoutJoin의 직접 읽기와 CallableResultDemo의 ok.get, broken.get를 원문별로 비교합니다. 취소와 호출자 인터럽트 경로를 실행한 표가 아닙니다.
결과 읽기 · 직접 필드와 Future
공유 필드 읽기와 Future.get의 결과 경계를 구분한다
직접 읽기는 완료를 기다리지 않지만, get은 값 또는 작업 예외를 받습니다.
직접 읽기는 완료를 기다리지 않는다
작업자의 공유 필드 기록과 main의 대기 없는 읽기를 별도 경로로 표시한다. 값 0 또는 42는 다른 프로그램이며 Future 결과의 앞 단계가 아니다.
두 Future는 값과 작업 예외를 따로 전달한다
각 Future가 정상 값 또는 작업 원인을 보관한다. main은 ok.get 다음 broken.get을 호출하며 두 get에 시간 제한은 없다.
상태별 상세 해설
SharedResultWithoutJoin: task.result
- 얻는 값 또는 예외
- 초기값 0 또는 기록된 42
- 그 지점의 완료 의미
- 읽기 앞에 join이나 다른 완료 대기가 없음
CallableResultDemo: ok.get()
- 얻는 값 또는 예외
- Quote[symbol=JAVA, price=25]
- 그 지점의 완료 의미
- 해당 Callable의 정상 완료 결과를 받음
CallableResultDemo: broken.get()
- 얻는 값 또는 예외
- ExecutionException · 원인은 IllegalArgumentException
- 그 지점의 완료 의미
- 작업 예외를 Future에 보관했다가 get에서 전달
두 get에는 시간 제한이 없습니다. broken.get의 예외는 main이 잡아 cause를 출력하며, finally의 shutdownNow 호출 자체가 실행기 종료 완료를 확인하지는 않습니다.
이 예의 조건
- CallableResultDemo 한 프로그램의 정상 ok.get 뒤 실패 broken.get를 추적합니다. 두 get에는 시간 제한이 없습니다.
- SharedResultWithoutJoin의 직접 필드 읽기 0/42는 별도 프로그램이며 전체 표에 보존합니다.
- 취소와 호출자 인터럽트는 이 비교에서 실행한 경로가 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 8 | 초깃값 0 또는 기록된 42 |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 공유 필드 읽기와 Future.get의 결과 경계를 구분한다 공유 필드 읽기와 Future.get의 결과 경계를 구분한다 SharedResultWithoutJoin의 직접 읽기와 CallableResultDemo의 ok.get, broken.get를 원문별로 비교합니다. 취소와 호출자 인터럽트 경로를 실행한 표가 아닙니다. 결과 읽기 · 직접 필드와 Future 공유 필드 읽기와 Future.get의 결과 경계를 구분한다 직접 읽기는 완료를 기다리지 않지만, get은 값 또는 작업 예외를 받습니다. SharedResultWithoutJoin: task.result 얻는 값 또는 예외 초깃값 0 또는 기록된 42 그 지점의 완료 의미 읽기 앞에 join이나 다른 완료 대기가 없음 CallableResultDemo: ok.get() 얻는 값 또는 예외 Quote[symbol=JAVA, price=25] 그 지점의 완료 의미 해당 Callable의 정상 완료 결과를 받음 CallableResultDemo: broken.get() 얻는 값 또는 예외 ExecutionException · 원인은 IllegalArgumentException 그 지점의 완료 의미 작업 예외를 Future에 보관했다가 get에서 전달 두 get에는 시간 제한이 없습니다. broken.get의 예외는 main이 잡아 cause를 출력하며, finally의 shutdownNow 호출 자체가 실행기 종료 완료를 확인하지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 Callable 결과 보관 제출한 정상·실패 Callable의 결과를 각 Future에서 받도록 준비합니다. |
|
|
| ok.get 정상 결과 main이 ok.get으로 해당 Callable의 정상 완료 결과를 받아 출력합니다. |
|
|
| broken.get 실패 전달 broken.get의 ExecutionException을 catch해 원인을 출력하고 종료를 요청합니다. |
|
|
ExecutionException 자체만 기록하면 실제 실패 종류가 가려지므로 원인을 업무 오류로 변환합니다.
Future 결과의 제출 순서
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class CallableBatch {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(3);
try {
List<Future<Integer>> futures = new ArrayList<>();
for (int value : List.of(2, 3, 4)) {
futures.add(executor.submit(() -> value * value));
}
List<Integer> results = new ArrayList<>();
for (Future<Integer> future : futures) {
results.add(future.get());
}
System.out.println(results);
} finally {
executor.shutdown();
}
}
}작업 완료 순서가 달라도 결과 목록은 제출 순서를 따릅니다.
예시
3의 제곱 작업이 가장 먼저 끝나고 4, 2 순서로 끝났다고 가정해도 futures에는 2, 3, 4 순서의 Future가 들어 있습니다. 반복문은 첫 Future의 get부터 호출하므로 결과는 [4, 9, 16]입니다. 두 번째 결과 9가 준비되어 있어도 첫 결과 4를 기다리는 동안 이 반복문은 9를 먼저 추가하지 않습니다. 이는 원 main의 완료 시각을 측정한 기록이 아니라 수집 순서에 대한 코드 추론입니다.
빠른 결과부터 처리해야 한다면 ExecutorCompletionService나 CompletableFuture 조합을 사용합니다.
결과·예외·수집 순서 결정
| 필요 | 방식 | 의미 |
|---|---|---|
| 결과 없음 | execute Runnable | 작업 실패를 별도 경로로 관찰 |
| 값과 예외 | submit Callable | Future로 수집 |
| 완료 순 처리 | CompletionService | 완료 큐 사용 |
| 비동기 조합 | CompletableFuture | 단계와 복구 연결 |
Future 소유권과 결과 회수 시점
결과 타입이 실패를 담아도 Future는 정상 완료일 수 있습니다. 이 예제에서 Failure는 정수로 해석할 수 없는 입력을 표현하는 데이터입니다. parse가 잡지 않는 다른 실행 예외가 생기면 정상 Failure 반환이 아니라 get()의 ExecutionException 경로가 됩니다. 따라서 성공·입력 실패 개수와 작업 실행 예외 개수를 따로 셉니다.
Future를 반환하는 메서드는 작업의 수명주기를 호출자에게 넘깁니다.
호출자가 결과를 반드시 회수해야 하는지, 관심이 사라지면 취소해야 하는지, 메서드가 끝난 뒤에도 작업이 살아도 되는지를 API 설명에 적어야 합니다.
단순히 Future<T>만 반환하면 취소 책임과 제한 시간이 암묵적으로 남습니다.
여러 결과를 제출 순서대로 get()하면 앞 작업 하나가 느릴 때 뒤에서 이미 끝난 결과도 전달되지 않습니다.
입력 순서가 제품 요구라면 그대로 유지하되, 완료 순서가 중요하다면 ExecutorCompletionService나 CompletableFuture의 완료 콜백을 사용합니다.
순서 선택은 성능 기법이 아니라 소비자가 어떤 결과를 먼저 볼 수 있는지에 관한 규칙입니다.
예외 처리에서는 ExecutionException을 그대로 위로 던지지 말고 getCause()의 원인을 분류합니다.
입력 오류는 해당 항목의 실패 결과로, 의존성 장애는 재시도 후보로, 프로그래밍 결함은 즉시 알림 대상으로 바꿀 수 있습니다.
InterruptedException을 상위로 전파하지 않고 현재 접점에서 처리한다면, 인터럽트 상태를 복구하고 남은 Future의 취소를 요청한 뒤 수집을 끝내는 정책을 고려합니다. 취소 요청이 모든 작업의 종료를 확인해 주는 것은 아닙니다.
완료된 결과를 오래 들고 있으면 Future가 참조하는 큰 객체도 회수되지 않습니다.
대량 배치에서는 결과를 처리한 즉시 목록에서 제거하거나 스트림으로 내보내고, 전체 입력 수와 성공·실패·취소 수가 맞는지 마지막에 대조합니다.
누락된 결과를 정상 완료로 오인하지 않게 해 주는 작은 회계 장부입니다.
결과가 필요 없는 명령이라도 submit(Runnable)이 돌려주는 Future<?>를 보관하면 실패를 get()에서 확인할 수 있습니다.
관찰할 계획이 전혀 없다면 execute와 UncaughtExceptionHandler 같은 별도 실패 경로를 택해야 합니다.
반환값을 버린 submit은 작업 예외를 눈에 띄지 않게 숨길 수 있습니다.
연습 문제
문자열 세 개를 정수로 파싱하는 Callable을 제출하고 예외 대신 sealed 결과 타입으로 성공과 실패를 모두 수집하세요.
정답과 해설
import java.util.List;
import java.util.concurrent.Executors;
public final class TypedParseResultSolution {
sealed interface ParseResult permits Success, Failure {}
record Success(int value) implements ParseResult {}
record Failure(String input) implements ParseResult {}
static ParseResult parse(String input) {
try { return new Success(Integer.parseInt(input)); }
catch (NumberFormatException e) { return new Failure(input); }
}
public static void main(String[] args) throws Exception {
var executor = Executors.newFixedThreadPool(2);
try {
var futures = List.of("10", "bad", "30").stream()
.map(text -> executor.submit(() -> parse(text)))
.toList();
for (var future : futures) {
System.out.println(future.get());
}
} finally {
executor.shutdown();
}
}
}parse가 NumberFormatException을 잡아 만든 Failure[input=bad]는 get의 정상 반환값입니다. 작업 밖으로 전파된 예외를 ExecutionException으로 받는 앞 예제와 구별합니다.
시스템 장애는 여전히 예외로 전달합니다.
확인 문제
입력 목록을 "-4", "bad", "0"으로 바꾸면 get에서 받는 세 결과는 무엇인가요? bad가 먼저 끝났다는 사실이 출력 순서를 바꾸는지도 설명하세요.
확인 문제 해설
Success[value=-4], Failure[input=bad], Success[value=0] 순서입니다. parse 내부에서 만든 Failure도 정상 반환값이므로 bad에 대한 get은 그 값 자체를 받습니다. 제출 목록을 앞에서부터 순회하기 때문에 작업의 실제 완료 순서는 출력 순서를 바꾸지 않습니다.
Future API 선택 기준
Callable은 결과와 실패를 작업 규칙으로 되돌립니다.
Future를 받았으면 어디서 언제 수집할지 정하고, 원인을 잃지 말며, 완료 후 실행기 종료까지 한 흐름으로 관리합니다.
반환 데이터와 Future 상태를 구별하는 추가 연습
- 위
parse에"10","2147483648","-4"를 넣어 각각 하나의 Callable로 제출하고, 세 Future를 입력 순서로get()한다. 정수 범위와 기존catch만 적용하며 외부 취소·장애는 없다. 결과 세 개와 성공·입력 실패 개수는 무엇인가? 범위를 넘은 값 때문에get()이ExecutionException을 던지는가? - 한 작업이
IllegalStateException을 던진 뒤 해당 Future의isDone()이true인 것을 확인했다. 정상 값도 얻을 수 있다고 판단해도 되는가?get()은 어떤 예외와 원인을 전달하는가? 다른 성공 작업의 결과까지 같은 예외로 바뀌는가? - 별도의 Callable 하나가 시작했지만 종료 신호를 받을 때까지 반환하지 않는다고 하자. 취소는 아직 하지 않았고,
get(20, MILLISECONDS)대기 중에도 종료 신호가 오지 않는다. 이 호출은 무엇을 던지는가? 이후 종료 신호를 보내 작업이 42를 정상 반환했다면, 취소하지 않은 같은 Future의 다음get()결과는 무엇인가?
추가 연습 풀이
Success[value=10],Failure[input=2147483648],Success[value=-4]순서다. 성공 데이터 2개와 입력 실패 데이터 1개이며, 세 Future 모두 결과 객체를 정상 반환한다. 2147483648은int의 최댓값 2147483647을 넘으므로Integer.parseInt가 던진NumberFormatException을 기존catch가Failure로 변환한다. 이 예외는 작업 밖으로 나가지 않는다.- 정상 완료라고 판단할 수 없다.
isDone은 예외 완료에도 참이며,get()은 원인이IllegalStateException인ExecutionException을 던진다. 각 Future는 별도 작업을 대표하므로 다른 성공 Future의 정상 값은 그대로 수집할 수 있다. - 이번 호출은
TimeoutException을 던진다. 대기 시간 초과만으로 작업이나 Future를 취소하지 않으므로, 이후 작업이 정상 완료하면 같은 Future의 다음get()은 42다. 외부 취소나 다른 실패가 없다는 조건이 이 결론에 필요하다.