Callable과 Future
Runnable 공유 필드의 결과 전달 문제를 재현하고, Callable 반환값과 ExecutionException 원인을 요청 접점에서 수집하는 실행 흐름을 완성합니다.
Runnable은 반환값이 없고 검사된 예외를 선언할 수 없습니다.
결과를 공유 필드에 쓰고 join 뒤 읽는 방식은 작업 관리 코드와 동기화 책임을 호출자에게 퍼뜨립니다.
Callable<T>는 결과 타입과 예외를 작업 규칙에 포함하고 submit은 완료를 대표하는 Future<T>를 즉시 반환합니다.
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);
}
}메인은 작업 완료를 기다리지 않아 0을 볼 수 있습니다.
volatile을 붙여도 완료 전에 읽는 순서 문제는 해결되지 않습니다.
join을 직접 쓰거나 Future의 완료 규칙을 사용해야 합니다.
Future 결과 형식
submit은 작업 접수 후Future를 반환하며 계산 완료를 기다리지 않는다.get은 완료 전 호출되면 현재 스레드를 대기시킨다.- 정상 반환값은 Callable의 타입 T로 전달된다.
- 작업에서 던진 예외는 ExecutionException.getCause로 확인한다.
- 대기 중 인터럽트는 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();
}
}
}첫 결과는 Quote이고 둘째 원인은 IllegalArgumentException입니다. 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();
}
}
}작업 완료 순서가 달라도 결과 목록은 제출 순서를 따릅니다.
빠른 결과부터 처리해야 한다면 ExecutorCompletionService나 CompletableFuture 조합을 사용합니다.
결과·예외·수집 순서 결정
| 필요 | 방식 | 의미 |
|---|---|---|
| 결과 없음 | execute Runnable | 화재 후 망각 금지, 실패 관찰 필요 |
| 값과 예외 | submit Callable | Future로 수집 |
| 완료 순 처리 | CompletionService | 완료 큐 사용 |
| 비동기 조합 | CompletableFuture | 단계와 복구 연결 |
Future 소유권과 결과 회수 시점
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();
}
}
}업무상 예상 가능한 파싱 실패를 결과 타입으로 표현하면 ExecutionException과 구별됩니다. 시스템 장애는 여전히 예외로 전달합니다.
Future API 선택 기준
Callable은 결과와 실패를 작업 규칙으로 되돌립니다.
Future를 받았으면 어디서 언제 수집할지 정하고, 원인을 잃지 말며, 완료 후 실행기 종료까지 한 흐름으로 관리합니다.