안동민 개발노트

본문 시작

Callable과 Future

Runnable의 결과 전달 문제를 확인하고 Callable·Future로 반환값과 실행 예외를 수집합니다.

Runnable은 반환값이 없고 검사된 예외를 선언할 수 없습니다.

결과를 공유 필드에 쓰고 join 뒤 읽는 방식은 작업 관리 코드와 동기화 책임을 호출자에게 퍼뜨립니다.

Callable<T>는 결과 타입과 예외를 작업 규칙에 포함하고, submit은 작업을 대표하는 Future<T>를 반환합니다. 여기의 고정 풀에서는 작업 결과를 기다리려면 별도로 get을 호출합니다.

Future는 결과 그 자체가 아니라 아직 진행 중일 수 있는 계산의 손잡이입니다.

get은 완료까지 기다리고 작업 예외를 ExecutionException의 원인으로 전달합니다.

호출자는 실패 원인을 분류하고 실행기를 종료해야 합니다.


결과 필드를 완료 전에 읽는 경쟁

bad/SharedResultWithoutJoin.java
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 결과 형식

  • 여기의 고정 풀은 submit으로 작업을 접수하고 Future를 반환한다. 호출자는 get으로 결과를 수집한다.
  • get은 완료 전 호출되면 현재 스레드를 대기시킨다.
  • 정상 반환값은 Callable의 타입 T로 전달된다.
  • 작업에서 던진 예외는 ExecutionException.getCause로 확인한다.
  • 취소된 Future의 get은 CancellationException을 던진다. 이 절의 main은 취소 경로를 실행하지 않는다.
  • 대기 중 인터럽트는 InterruptedException으로 호출자 취소를 전달한다.
  • 결과 수집 뒤에도 ExecutorService 수명주기를 종료해야 한다.

타입이 있는 결과와 오류 분류

src/CallableResultDemo.java
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의 결과 경계를 구분한다

SharedResultWithoutJoin의 직접 읽기와 CallableResultDemo의 ok.get, broken.get를 원문별로 비교합니다. 취소와 호출자 인터럽트 경로를 실행한 표가 아닙니다.

공유 필드 읽기와 Future.get의 결과 경계를 구분한다
원문의 읽기 지점얻는 값 또는 예외그 지점의 완료 의미
SharedResultWithoutJoin: task.result초기값 0 또는 기록된 42읽기 앞에 join이나 다른 완료 대기가 없음
CallableResultDemo: ok.get()Quote[symbol=JAVA, price=25]해당 Callable의 정상 완료 결과를 받음
CallableResultDemo: broken.get()ExecutionException · 원인은 IllegalArgumentException작업 예외를 Future에 보관했다가 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 호출 자체가 실행기 종료 완료를 확인하지는 않습니다.

ExecutionException 자체만 기록하면 실제 실패 종류가 가려지므로 원인을 업무 오류로 변환합니다.


Future 결과의 제출 순서

src/CallableBatch.java
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 CallableFuture로 수집
완료 순 처리CompletionService완료 큐 사용
비동기 조합CompletableFuture단계와 복구 연결

Future 소유권과 결과 회수 시점

Future를 반환하는 메서드는 작업의 수명주기를 호출자에게 넘깁니다.

호출자가 결과를 반드시 회수해야 하는지, 관심이 사라지면 취소해야 하는지, 메서드가 끝난 뒤에도 작업이 살아도 되는지를 API 설명에 적어야 합니다.

단순히 Future<T>만 반환하면 취소 책임과 제한 시간이 암묵적으로 남습니다.

여러 결과를 제출 순서대로 get()하면 앞 작업 하나가 느릴 때 뒤에서 이미 끝난 결과도 전달되지 않습니다.

입력 순서가 제품 요구라면 그대로 유지하되, 완료 순서가 중요하다면 ExecutorCompletionService나 CompletableFuture의 완료 콜백을 사용합니다.

순서 선택은 성능 기법이 아니라 소비자가 어떤 결과를 먼저 볼 수 있는지에 관한 규칙입니다.

예외 처리에서는 ExecutionException을 그대로 위로 던지지 말고 getCause()의 원인을 분류합니다.

입력 오류는 해당 항목의 실패 결과로, 의존성 장애는 재시도 후보로, 프로그래밍 결함은 즉시 알림 대상으로 바꿀 수 있습니다.

InterruptedException을 상위로 전파하지 않고 현재 접점에서 처리한다면, 인터럽트 상태를 복구하고 남은 Future의 취소를 요청한 뒤 수집을 끝내는 정책을 고려합니다. 취소 요청이 모든 작업의 종료를 확인해 주는 것은 아닙니다.

완료된 결과를 오래 들고 있으면 Future가 참조하는 큰 객체도 회수되지 않습니다.

대량 배치에서는 결과를 처리한 즉시 목록에서 제거하거나 스트림으로 내보내고, 전체 입력 수와 성공·실패·취소 수가 맞는지 마지막에 대조합니다.

누락된 결과를 정상 완료로 오인하지 않게 해 주는 작은 회계 장부입니다.

결과가 필요 없는 명령이라도 submit(Runnable)이 돌려주는 Future<?>를 보관하면 실패를 get()에서 확인할 수 있습니다.

관찰할 계획이 전혀 없다면 execute와 UncaughtExceptionHandler 같은 별도 실패 경로를 택해야 합니다.

반환값을 버린 submit은 작업 예외를 눈에 띄지 않게 숨길 수 있습니다.


연습 문제

문자열 세 개를 정수로 파싱하는 Callable을 제출하고 예외 대신 sealed 결과 타입으로 성공과 실패를 모두 수집하세요.

정답과 해설
exercise/TypedParseResultSolution.java
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으로 받는 앞 예제와 구별합니다.

시스템 장애는 여전히 예외로 전달합니다.


Future API 선택 기준

Callable은 결과와 실패를 작업 규칙으로 되돌립니다.

Future를 받았으면 어디서 언제 수집할지 정하고, 원인을 잃지 말며, 완료 후 실행기 종료까지 한 흐름으로 관리합니다.