본문으로 건너뛰기

안동민 개발노트

본문 시작

Future 취소·시간 초과·실패

Future 취소와 인터럽트의 협력 관계를 이해하고 시간 제한·실행 실패·취소 예외를 구분합니다.

Future의 미완료에는 여러 이유가 있습니다.

계산 중일 수 있고, 시간 예산을 넘겼을 수 있으며, 취소되었거나 예외로 끝났을 수 있습니다.

이 상태를 하나의 null이나 일반 오류로 합치면 재시도와 사용자 메시지를 올바르게 선택할 수 없습니다.

cancel(true)는 실행 중 스레드에 인터럽트를 요청할 수 있지만 코드를 강제로 죽이지 않습니다.

작업이 인터럽트 상태를 무시하면 계속 실행될 수 있습니다.

취소는 호출자와 작업자가 함께 지키는 협력 규칙입니다.

cancel(true)는 중단 요청일 뿐 작업을 강제로 끝내지 않는다

Future 상태와 실제 부수 효과의 종료는 작업 코드가 인터럽트에 협력할 때 일치합니다.

  1. RUNNING

    작업 실행 중

  2. cancel(true)

    interrupt 요청

  3. catch

    예외를 삼킴

  4. CONTINUE

    남은 부수 효과 실행

  5. Future

    CANCELLED 표시


인터럽트를 삼켜 취소 뒤에도 계속되는 작업

bad/UncooperativeCancellation.java
import java.util.concurrent.Executors;

public final class UncooperativeCancellation {
    public static void main(String[] args) throws Exception {
        var executor = Executors.newSingleThreadExecutor();
        var future = executor.submit(() -> {
            for (int i = 0; i < 5; i++) {
                try { Thread.sleep(50); }
                catch (InterruptedException ignored) { }
                System.out.println("step=" + i);
            }
        });
        Thread.sleep(20);
        System.out.println("cancelled=" + future.cancel(true));
        executor.shutdown();
        executor.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS);
    }
}

Future는 취소 상태지만 작업은 인터럽트를 버리고 남은 단계를 실행합니다.

취소 결과와 실제 부수 효과 중단이 어긋납니다.

Future 결과는 완료·실패·취소·시간초과를 서로 다르게 처리한다

한 catch에서 모두 실패 문자열로 바꾸면 원인과 후속 정책을 잃습니다.

  1. 실행 실패

    ExecutionException 원인 분류

  2. 취소

    CancellationException

  3. 시간초과

    계속·cancel 선택


결과 상태별 처리

  • 정상 완료는 get의 반환값으로 전달된다.
  • 작업 예외는 ExecutionException의 원인으로 보존된다.
  • 취소된 Futureget은 CancellationException을 던진다.
  • 시간 제한 get의 예산 초과는 TimeoutException이며 작업은 자동 취소되지 않는다.
  • 대기 호출자의 인터럽트는 InterruptedException으로 나타난다.
  • 취소 작업은 블로킹 API의 인터럽트를 전파하거나 반복에서 상태를 확인한다.
협력적 취소는 블로킹 경계와 반복 경계에서 확인한다

작업이 오래 계산하거나 여러 단계를 돌면 적절한 체크 지점이 필요합니다.

  1. 1
    작업 시작

    자원 획득

  2. 2
    blocking API

    InterruptedException 전파

  3. 3
    loop boundary

    isInterrupted 확인

  4. 4
    cleanup

    finally 자원 정리

  5. 5
    terminate

    취소 상태 보존


인터럽트에 협력하는 검색 작업

src/CooperativeSearch.java
import java.time.Duration;
import java.util.concurrent.CancellationException;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public final class CooperativeSearch {
    static int search(int target) throws InterruptedException {
        for (int candidate = 0; candidate < Integer.MAX_VALUE; candidate++) {
            if ((candidate & 0x3fff) == 0 && Thread.interrupted()) {
                throw new InterruptedException("search cancelled");
            }
            if (candidate == target) return candidate;
        }
        return -1;
    }

    public static void main(String[] args) throws Exception {
        var executor = Executors.newSingleThreadExecutor();
        var future = executor.submit(() -> search(Integer.MAX_VALUE - 1));
        try {
            System.out.println(future.get(Duration.ofMillis(5).toNanos(), TimeUnit.NANOSECONDS));
        } catch (TimeoutException e) {
            future.cancel(true);
            try { future.get(); }
            catch (CancellationException cancelled) { System.out.println("cancelled"); }
        } finally {
            executor.shutdownNow();
        }
    }
}

시간 초과 후 명시적으로 취소하고 작업은 인터럽트 검사를 통해 빠져나옵니다.

CPU 루프는 적절한 간격으로 검사해 오버헤드와 취소 지연을 균형 잡습니다.


실패 원인의 도메인 결과 분류

src/FutureOutcomeClassifier.java
import java.time.Duration;
import java.util.concurrent.*;

public final class FutureOutcomeClassifier {
    sealed interface Outcome<T> permits Success, Failed, TimedOut, Cancelled {}
    record Success<T>(T value) implements Outcome<T> {}
    record Failed<T>(Throwable cause) implements Outcome<T> {}
    record TimedOut<T>() implements Outcome<T> {}
    record Cancelled<T>() implements Outcome<T> {}

    static <T> Outcome<T> await(Future<T> future, Duration timeout) throws InterruptedException {
        try {
            return new Success<>(future.get(timeout.toNanos(), TimeUnit.NANOSECONDS));
        } catch (TimeoutException e) {
            return new TimedOut<>();
        } catch (CancellationException e) {
            return new Cancelled<>();
        } catch (ExecutionException e) {
            return new Failed<>(e.getCause());
        }
    }

    public static void main(String[] args) throws Exception {
        var executor = Executors.newSingleThreadExecutor();
        try {
            Future<Integer> failed = executor.submit(() -> {
                throw new IllegalStateException("boom");
            });
            System.out.println(await(failed, Duration.ofSeconds(1)));
        } finally {
            executor.shutdownNow();
        }
    }
}

호출자 인터럽트는 결과로 바꾸지 않고 상위 취소로 전파합니다.

업무상 분류 가능한 Future 상태만 sealed 결과로 변환합니다.


취소·시간 제한·실패 결과 결정

사건호출자 조치작업자 책임
시간 초과계속·취소 결정자동 중단 아님
취소 trueget 중단 처리인터럽트 협력
실행 예외원인 분류원인 보존
호출자 인터럽트상위 전파정리 후 종료
취소 방식은 작업 단계와 부수 효과 경계에 맞춘다

강제 종료 대신 언제 안전하게 멈출 수 있는지 작업 계약에 드러냅니다.

상황방식주의
대기 중interrupt예외 전파
긴 계산flag check주기적 경계
Future 대기cancel(true)협력 필요
commit 전취소 가능변경 없음
commit 후완료·보상중복 방지

연습 문제

바이트 배열을 여러 번 해시하는 Callable을 만들고 20밀리초 안에 끝나지 않으면 취소하세요.

반복 사이 인터럽트를 확인하고 최종 상태를 문자열로 출력합니다.

정답과 해설
exercise/TimedHashCancellationSolution.java
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public final class TimedHashCancellationSolution {
    static String hash(byte[] input) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] value = input;
        for (int i = 0; i < 1_000_000; i++) {
            if (Thread.interrupted()) throw new InterruptedException();
            value = digest.digest(value);
        }
        return HexFormat.of().formatHex(value);
    }

    public static void main(String[] args) throws Exception {
        var executor = Executors.newSingleThreadExecutor();
        var future = executor.submit(() -> hash("java".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
        try {
            System.out.println(future.get(20, TimeUnit.MILLISECONDS));
        } catch (TimeoutException e) {
            future.cancel(true);
            System.out.println("timed-out-and-cancelled=" + future.isCancelled());
        } finally {
            executor.shutdownNow();
        }
    }
}

빠른 환경에서 완료될 가능성도 있으므로 테스트는 두 정상 경로를 허용합니다.

취소를 결정했다면 작업이 인터럽트를 관찰하도록 구현한 것이 핵심입니다.


Future 중단 규칙의 최종 점검

시간 초과는 관찰자가 기다리기를 멈춘 사건이고 취소는 작업 중단 요청입니다.

둘을 연결할지 호출자가 결정합니다.

작업자는 인터럽트에 협력하고, 실패 원인은 ExecutionException의 원인에서 잃지 않아야 합니다.