Future 취소와 시간 초과, 실패 구분하기
시간 제한을 둔 get과 인터럽트로 작업에 중단을 요청하는 cancel의 동작을 익히고, 정상 결과·실패·취소·시간 초과를 구분해 처리하는 방법을 배웁니다.
검색 요청을 받은 서버가 검색 작업을 스레드 풀에 맡기고 Future.get()으로 결과를 기다린다고 해 봅시다. 검색이 평소보다 오래 걸리면 get()은 결과가 나올 때까지 한없이 기다리고, 그동안 요청을 보낸 사용자도 함께 기다립니다. 그래서 실제 코드에서는 Future.get을 부르는 시점과 병렬 합산에서 본 것처럼 기다릴 시간에 제한을 둡니다. 그런데 제한 시간이 지나 기다리기를 그만두면, 아직 돌고 있는 검색 작업은 어떻게 될까요?
이 글은 그 질문에 답합니다. 먼저 Future가 끝나는 세 가지 방법과, 그 어디에도 속하지 않는 시간 초과를 구별합니다. 이어서 cancel로 작업을 취소해 보고, 취소가 작업을 강제로 멈추지 않고 인터럽트로 협력을 요청할 뿐이라는 것을 실행 결과로 확인합니다. get과 ExecutionException은 Callable과 Future로 작업 결과 받기에서, 인터럽트 플래그는 interrupt 플래그와 InterruptedException 처리에서 다뤘습니다.
Future가 끝나는 세 가지 방법
Future가 대표하는 작업은 세 가지 중 한 가지 방법으로 끝납니다. 작업이 값을 반환하면 정상 완료, 작업이 예외를 던지면 예외 완료, 누군가 cancel로 취소하면 취소 완료입니다. 세 경우 모두 isDone()은 true를 반환하므로, isDone()만 보고는 결과를 쓸 수 있는지 알 수 없습니다. 어떤 방법으로 끝났는지는 get()을 호출했을 때 무엇이 돌아오는지로 구별합니다.
Java 19부터는 get()을 호출하지 않고도 끝난 방법을 바로 확인할 수 있습니다. state()가 Future.State 열거형 값을 돌려주고, 이미 끝난 Future라면 resultNow()로 결과를, exceptionNow()로 작업이 던진 예외를 기다리지 않고 꺼낼 수 있습니다. 다음 예제는 세 작업을 서로 다른 방법으로 끝낸 뒤 각각의 상태를 출력합니다.
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class FutureStateDemo {
public static void main(String[] args) throws Exception {
var executor = Executors.newFixedThreadPool(2);
Future<Integer> ok = executor.submit(() -> 42);
Future<Integer> failed = executor.submit(() -> {
throw new IllegalStateException("boom");
});
Future<Integer> slow = executor.submit(() -> {
Thread.sleep(5_000);
return 0;
});
Thread.sleep(100);
slow.cancel(true);
for (Future<Integer> future : List.of(ok, failed, slow)) {
switch (future.state()) {
case SUCCESS -> System.out.println("SUCCESS " + future.resultNow());
case FAILED -> System.out.println("FAILED " + future.exceptionNow());
case CANCELLED -> System.out.println("CANCELLED");
case RUNNING -> System.out.println("RUNNING");
}
}
executor.shutdown();
}
}SUCCESS 42
FAILED java.lang.IllegalStateException: boom
CANCELLED첫 작업은 42를 반환했으므로 SUCCESS이고, resultNow()가 그 값을 꺼냅니다. 두 번째 작업은 IllegalStateException을 던졌으므로 FAILED인데, exceptionNow()는 ExecutionException으로 감싸지 않은 원래 예외를 돌려줍니다. 세 번째 작업은 5초 동안 잠드는 도중에 cancel(true)로 취소돼 CANCELLED가 됐습니다. main이 100ms를 기다린 뒤 상태를 읽으므로 앞의 두 작업은 대개 그사이에 이미 끝나 있고, 여러 번 실행해도 결과는 같았습니다.
switch에 RUNNING이 있는 이유는 state()가 아직 끝나지 않은 작업에 RUNNING을 돌려주기 때문입니다. 이름과 달리 아직 시작하지 못하고 큐에서 기다리는 작업도 RUNNING입니다. 또 resultNow()와 exceptionNow()는 기다리지 않는 대신, 조건이 맞지 않으면 IllegalStateException을 던집니다. 예를 들어 취소된 Future에는 결과가 없으므로 resultNow()가 예외를 던집니다. 그래서 이 예제처럼 state()로 먼저 확인한 다음에 씁니다.
정리하면 작업이 끝난 방법에 따라 state()와 get()은 다음과 같이 반응합니다.
| 작업이 끝난 방법 | state() | get()의 반응 |
|---|---|---|
| 값을 반환함 | SUCCESS | 그 값을 반환 |
| 예외를 던짐 | FAILED | ExecutionException을 던짐. getCause()가 작업이 던진 예외 |
| 취소됨 | CANCELLED | CancellationException을 던짐 |
| 아직 끝나지 않음 | RUNNING | 끝날 때까지 기다림 |
시간 초과는 Future의 상태가 아니다
get(시간, 단위)는 정해진 시간까지만 기다리는 get입니다. 그 안에 작업이 끝나면 get()과 똑같이 반응하고, 끝나지 않으면 TimeoutException을 던집니다. 여기서 눈여겨볼 점은 TimeoutException이 앞 표의 마지막 줄, 즉 '아직 끝나지 않음'에서 나온다는 것입니다. 작업은 실패하지도 취소되지도 않았고, 기다리던 쪽이 기다리기를 그만뒀을 뿐입니다.
그래서 시간이 초과된 뒤에도 작업은 계속 실행됩니다. 결과가 더 이상 필요 없다면 작업에 그만두라고 따로 알려야 하는데, 그 수단이 cancel입니다. 반대로 사용자에게는 '처리 중'이라고 답하고 결과는 나중에 다시 확인하는 설계라면 취소하지 않고 둘 수도 있습니다. 시간 초과를 취소로 이어 갈지는 이렇게 호출하는 쪽이 정합니다.
이 네 가지 결과를 하나의 null이나 '오류'로 뭉뚱그리면 다음에 할 일을 고를 수 없습니다. 작업이 실패했다면 원인에 따라 재시도할 수 있고, 시간 초과라면 잠시 뒤 다시 확인할 수 있습니다. 취소는 누군가 의도한 결과이므로 오류로 알릴 필요가 없습니다. 이 글의 마지막 예제는 이 구분을 타입으로 표현합니다.
cancel은 작업을 강제로 멈추지 않는다
cancel(mayInterruptIfRunning)이 하는 일은 작업이 어디까지 진행됐는지에 따라 다릅니다. 아직 시작하지 않은 작업이라면 앞으로도 실행되지 않습니다. 이미 끝난 작업이라면 아무 효과가 없고 false를 반환합니다. 실행 중인 작업이라면 인자가 true일 때 그 작업을 실행하는 스레드에 인터럽트를 걸고, false일 때는 끝까지 실행하게 둡니다.
취소에 성공하면 Future는 그 즉시 취소 완료 상태가 됩니다. isDone()과 isCancelled()가 true를 반환하고, get()은 바로 CancellationException을 던집니다. 하지만 이것은 Future라는 '결과를 담는 상자'의 상태일 뿐, 작업 코드가 실제로 멈췄다는 뜻은 아닙니다. cancel(true)가 하는 일은 작업자 스레드의 인터럽트 플래그를 세우는 데까지이고, 그 플래그를 보고 멈출지는 작업 코드에 달려 있습니다.
Java가 실행 중인 코드를 강제로 끊지 않는 이유는 시간 제한 join과 interrupt에서 본 협력적 취소와 같습니다. 계좌 이체처럼 여러 단계로 된 변경을 하던 작업이 중간에 끊기면, 객체가 일관성을 잃은 상태로 남습니다. 그래서 작업이 멈춰도 안전한 지점에서 스스로 멈추도록 인터럽트로 요청만 합니다. 다음 두 예제는 인터럽트를 무시하는 작업과 인터럽트에 협력하는 작업이 같은 cancel(true)에 어떻게 다르게 반응하는지 보여 줍니다.
인터럽트를 무시하면 취소된 뒤에도 계속 실행된다
다음 작업은 50ms씩 잠들기를 다섯 번 반복하며 step=0부터 step=4까지 출력합니다. 잠든 동안 인터럽트를 받으면 sleep이 InterruptedException을 던지는데, 이 코드는 그 예외를 잡고 아무것도 하지 않습니다. main은 작업을 맡기고 20ms 뒤에 cancel(true)를 호출합니다.
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
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, TimeUnit.SECONDS);
}
}step=0
cancelled=true
step=1
step=2
step=3
step=4cancel(true)는 true를 반환했고 Future는 취소됐지만, 작업은 step=4까지 모두 출력했습니다. 시간 순서로 따라가 보면 이렇습니다. 20ms에 main이 인터럽트를 걸자 첫 번째 sleep(50)이 30ms 일찍 깨어나 InterruptedException을 던집니다. catch가 예외를 버리고 곧바로 step=0을 출력하므로, 이 줄은 main의 cancelled=true와 거의 같은 순간에 나옵니다. 그 뒤로는 인터럽트를 다시 받을 일이 없으므로 나머지 네 번의 sleep은 50ms씩 정상적으로 잠듭니다.
step=0과 cancelled=true는 서로 다른 스레드가 거의 동시에 출력하므로 실행할 때마다 순서가 바뀔 수 있고, 실제로 여러 번 실행하면 cancelled=true가 맨 앞에 나오기도 합니다. 반면 step=1부터 step=4까지는 50ms 간격으로 나오므로 언제나 그 뒤에 이어집니다.
취소 결과와 람다 본문의 실행 여부를 나누어 읽는다
본문 진입 전 취소/진입 뒤 취소/완료 뒤 취소의 Future 상태와 worker의 계속 출력이 다릅니다. 두 수명을 분리해 가능 경로별 cancel 결과와 본문 상태를 따라갑니다.
UncooperativeCancellation의 FutureTask 취소와 원문 람다의 실행을 세 경우로 나누며, 취소가 작업 본문의 완료를 기다리지 않는다는 점을 보여줍니다.
취소 경합 · Future와 작업 본문
취소 결과와 람다 본문의 실행 여부를 나누어 읽는다
cancelled=true여도 인터럽트를 무시하는 실행 본문은 끝까지 진행할 수 있습니다.
취소 판정과 작업 본문의 수명
cancel(true)의 세 시점 비교다. 진입 전 취소와 실행 중 취소가 모두 true일 수 있지만, 인터럽트를 무시하는 실행 중 본문은 step 0부터 4까지 계속된다. 완료가 먼저면 false다. 진입 전·완료 후 취소는 미관측 조건이며 sleep(20)은 본문 진입을 보장하지 않는다.
상태별 상세 해설
본문 진입을 막은 취소
- main의 cancel(true) 결과
cancelled=true- 람다의 step 출력
- step 출력 없음
본문이 실행된 상태에서 취소 성공
- main의 cancel(true) 결과
cancelled=true- 람다의 step 출력
- step=0부터 step=4까지 출력 · 인터럽트에 중단하는 분기 없음
정상 완료가 취소보다 앞섬
- main의 cancel(true) 결과
cancelled=false- 람다의 step 출력
- step=0부터 step=4 뒤 취소 결과 출력
이번 실행은 step=0, cancelled=true, step=1부터 step=4 순으로 출력했습니다. 다른 실행의 cancelled=true 줄은 step 줄 앞·사이·뒤에 올 수 있고, sleep(20)은 본문 시작을 보장하지 않습니다. 표의 시작 전 취소와 완료 후 취소는 미관측 경로입니다.
이 예의 조건
- 취소 시점 세 조건을 공통 판정·본문·관측 단계마다 비교합니다. sleep(20)은 본문 진입을 보장하지 않습니다.
- 시작 전·완료 후 취소는 미관측 조건입니다. 원 관측은 step=0, cancelled=true, step=1..4입니다.
- 본문은 InterruptedException을 무시합니다. awaitTermination(1초)의 반환값은 검사하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 취소 결과와 람다 본문의 실행 여부를 나누어 읽는다 취소 결과와 람다 본문의 실행 여부를 나누어 읽는다 UncooperativeCancellation의 FutureTask 취소와 원문 람다의 실행을 세 경우로 나누며, 취소가 작업 본문의 완료를 기다리지 않는다는 점을 보여줍니다. 취소 경합 · Future와 작업 본문 취소 결과와 람다 본문의 실행 여부를 나누어 읽는다 cancelled=true여도 인터럽트를 무시하는 실행 본문은 끝까지 진행할 수 있습니다. 본문 진입을 막은 취소 main의 cancel(true) 결과 cancelled=true 람다의 step 출력 step 출력 없음 본문이 실행된 상태에서 취소 성공 main의 cancel(true) 결과 cancelled=true 람다의 step 출력 step=0부터 step=4까지 출력 · 인터럽트에 중단하는 분기 없음 정상 완료가 취소보다 앞섬 main의 cancel(true) 결과 cancelled=false 람다의 step 출력 step=0부터 step=4 뒤 취소 결과 출력 이번 실행은 step=0 , cancelled=true , step=1부터 step=4 순으로 출력했습니다. 다른 실행의 cancelled=true 줄은 step 줄 앞·사이·뒤에 올 수 있고, sleep(20)은 본문 시작을 보장하지 않습니다. 표의 시작 전 취소와 완료 후 취소는 미관측 경로입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 취소 시점별 판정 본문 진입 전·진행 중·정상 완료 뒤 중 어느 시점이 앞섰는지에 따라 cancel 결과가 달라집니다. |
|
|
| 본문과 Future의 상태 취소된 Future와 실제 람다 실행 여부를 각 조건에서 따로 읽습니다. |
|
|
| 출력과 종료 확인의 범위 각 조건의 출력과 main의 종료 대기를 worker 완료 보장과 구분합니다. |
|
|
취소 결과와 람다 본문의 실행 여부를 나누어 읽는다
본문 진입 전 취소/진입 뒤 취소/완료 뒤 취소의 Future 상태와 worker의 계속 출력이 다릅니다. 두 수명을 분리해 가능 경로별 cancel 결과와 본문 상태를 따라갑니다.
UncooperativeCancellation의 FutureTask 취소와 원문 람다의 실행을 세 경우로 나누며, 취소가 작업 본문의 완료를 기다리지 않는다는 점을 보여줍니다.
취소 경합 · Future와 작업 본문
취소 결과와 람다 본문의 실행 여부를 나누어 읽는다
cancelled=true여도 인터럽트를 무시하는 실행 본문은 끝까지 진행할 수 있습니다.
취소 판정과 작업 본문의 수명
cancel(true)의 세 시점 비교다. 진입 전 취소와 실행 중 취소가 모두 true일 수 있지만, 인터럽트를 무시하는 실행 중 본문은 step 0부터 4까지 계속된다. 완료가 먼저면 false다. 진입 전·완료 후 취소는 미관측 조건이며 sleep(20)은 본문 진입을 보장하지 않는다.
상태별 상세 해설
본문 진입을 막은 취소
- main의 cancel(true) 결과
cancelled=true- 람다의 step 출력
- step 출력 없음
본문이 실행된 상태에서 취소 성공
- main의 cancel(true) 결과
cancelled=true- 람다의 step 출력
- step=0부터 step=4까지 출력 · 인터럽트에 중단하는 분기 없음
정상 완료가 취소보다 앞섬
- main의 cancel(true) 결과
cancelled=false- 람다의 step 출력
- step=0부터 step=4 뒤 취소 결과 출력
이번 실행은 step=0, cancelled=true, step=1부터 step=4 순으로 출력했습니다. 다른 실행의 cancelled=true 줄은 step 줄 앞·사이·뒤에 올 수 있고, sleep(20)은 본문 시작을 보장하지 않습니다. 표의 시작 전 취소와 완료 후 취소는 미관측 경로입니다.
이 예의 조건
- 취소 시점 세 조건을 공통 판정·본문·관측 단계마다 비교합니다. sleep(20)은 본문 진입을 보장하지 않습니다.
- 시작 전·완료 후 취소는 미관측 조건입니다. 원 관측은 step=0, cancelled=true, step=1..4입니다.
- 본문은 InterruptedException을 무시합니다. awaitTermination(1초)의 반환값은 검사하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 취소 결과와 람다 본문의 실행 여부를 나누어 읽는다 취소 결과와 람다 본문의 실행 여부를 나누어 읽는다 UncooperativeCancellation의 FutureTask 취소와 원문 람다의 실행을 세 경우로 나누며, 취소가 작업 본문의 완료를 기다리지 않는다는 점을 보여줍니다. 취소 경합 · Future와 작업 본문 취소 결과와 람다 본문의 실행 여부를 나누어 읽는다 cancelled=true여도 인터럽트를 무시하는 실행 본문은 끝까지 진행할 수 있습니다. 본문 진입을 막은 취소 main의 cancel(true) 결과 cancelled=true 람다의 step 출력 step 출력 없음 본문이 실행된 상태에서 취소 성공 main의 cancel(true) 결과 cancelled=true 람다의 step 출력 step=0부터 step=4까지 출력 · 인터럽트에 중단하는 분기 없음 정상 완료가 취소보다 앞섬 main의 cancel(true) 결과 cancelled=false 람다의 step 출력 step=0부터 step=4 뒤 취소 결과 출력 이번 실행은 step=0 , cancelled=true , step=1부터 step=4 순으로 출력했습니다. 다른 실행의 cancelled=true 줄은 step 줄 앞·사이·뒤에 올 수 있고, sleep(20)은 본문 시작을 보장하지 않습니다. 표의 시작 전 취소와 완료 후 취소는 미관측 경로입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 취소 시점별 판정 본문 진입 전·진행 중·정상 완료 뒤 중 어느 시점이 앞섰는지에 따라 cancel 결과가 달라집니다. |
|
|
| 본문과 Future의 상태 취소된 Future와 실제 람다 실행 여부를 각 조건에서 따로 읽습니다. |
|
|
| 출력과 종료 확인의 범위 각 조건의 출력과 main의 종료 대기를 worker 완료 보장과 구분합니다. |
|
|
그림은 cancel이 호출된 시점에 따라 세 경우를 나눠 보여 줍니다. sleep(20)이 그사이 작업이 시작된다는 것을 보장하지는 않으므로, 작업이 시작하기도 전에 취소되면 cancelled=true만 출력되고 step 줄은 하나도 나오지 않습니다. 반대로 main이 어떤 이유로 250ms 넘게 늦어져 작업이 먼저 끝나면, step=0부터 step=4까지 나온 뒤 cancelled=false가 출력됩니다. 이번 실행은 그 사이, 작업이 실행되는 도중에 취소된 경우입니다.
마지막 줄의 awaitTermination(1, TimeUnit.SECONDS)는 반환값을 확인하지 않습니다. 이 예제에서는 작업이 남은 200ms 남짓 안에 끝나므로 1초 안에 종료되지만, 작업이 더 길었다면 false를 반환한 채 main이 끝났을 것입니다. 그래도 프로세스는 바로 끝나지 않습니다. 이 실행기의 작업자는 데몬 스레드가 아니므로 JVM은 작업이 끝날 때까지 기다립니다. main이 반환한 것과 작업이 끝난 것, 프로세스가 종료된 것은 모두 다른 사건입니다.
인터럽트에 협력하도록 고치기
고치는 방법은 간단합니다. InterruptedException을 잡았다면 그것을 '그만두라'는 요청으로 받아들여 반복을 끝내면 됩니다. 아래 코드는 catch에서 인터럽트 플래그를 다시 세우고 return합니다. sleep이 InterruptedException을 던질 때 플래그가 지워지므로, 예외를 다시 던지지 않을 때는 플래그를 되돌려 두는 것이 interrupt 플래그와 InterruptedException 처리에서 본 관례입니다.
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public final class CooperativeCancellation {
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 e) {
Thread.currentThread().interrupt();
System.out.println("stopped at step=" + i);
return;
}
System.out.println("step=" + i);
}
});
Thread.sleep(20);
System.out.println("cancelled=" + future.cancel(true));
executor.shutdown();
System.out.println("terminated=" + executor.awaitTermination(1, TimeUnit.SECONDS));
}
}stopped at step=0
cancelled=true
terminated=true이번에는 첫 sleep이 깨어나자마자 작업이 끝나므로 step= 줄이 하나도 없습니다. 앞의 두 줄은 이번에도 두 스레드가 거의 동시에 출력하므로 순서가 바뀔 수 있습니다. 세 번째 줄의 terminated=true는 awaitTermination이 1초 안에 실행기의 종료를 확인했다는 뜻입니다. 앞 예제와 달리 반환값을 출력했으므로, 이 줄을 보면 main이 끝날 때 작업자도 이미 끝났다는 것을 알 수 있습니다.
시간 제한으로 기다리다가 취소하기
이제 처음의 질문으로 돌아가겠습니다. 다음 예제는 0부터 하나씩 세어 가며 목표 값을 찾는 검색 작업을 제출하고, 결과를 5ms만 기다립니다. 그 안에 결과가 나오지 않으면 작업을 취소합니다.
앞의 두 예제와 다른 점은 작업이 sleep 없이 CPU로 계산만 한다는 것입니다. sleep이나 wait처럼 기다리는 메서드는 인터럽트를 받으면 InterruptedException을 던지지만, 덧셈과 비교만 하는 반복문은 인터럽트를 받아도 아무 일 없다는 듯이 계속 돕니다. 그래서 계산 작업은 인터럽트 플래그를 스스로 확인해야 합니다.
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(5, TimeUnit.MILLISECONDS));
} catch (TimeoutException e) {
future.cancel(true);
try {
future.get();
} catch (CancellationException cancelled) {
System.out.println("cancelled");
}
} finally {
executor.shutdownNow();
}
}
}cancelledsearch는 candidate가 16,384의 배수일 때마다 Thread.interrupted()로 인터럽트 플래그를 확인합니다. candidate & 0x3fff는 음이 아닌 candidate를 16,384로 나눈 나머지와 같으므로, 이 값이 0인 순간에만 검사하는 것입니다. 플래그가 서 있으면 InterruptedException을 던져 작업을 끝냅니다. Thread.interrupted()는 플래그를 읽으면서 지우므로, InterruptedException을 던질 때는 플래그가 지워져 있다는 관례와도 맞습니다.
main의 흐름은 이렇습니다. 이 작업은 목표 값에 닿기까지 21억 번 넘게 반복해야 해서, 이 글을 실행한 환경에서는 끝까지 세는 데 1.5초쯤 걸렸습니다. 그러니 5ms 안에는 끝나지 않고 첫 get이 TimeoutException을 던집니다. catch에서 cancel(true)로 인터럽트를 걸고 다시 get()을 호출하면, Future가 이미 취소 상태이므로 곧바로 CancellationException이 나고 cancelled가 출력됩니다. 여러 번 실행해도 결과는 같았습니다.
시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다
첫 get→timeout 뒤 cancel→두 번째 get에서 취소 성공/완료 경합 결과가 갈립니다. 각 호출의 반환과 출력 여부를 추적하되 관측한 cancelled를 유일한 결과로 만들지 않습니다.
CooperativeSearch.main의 첫 timed get과 timeout 이후 cancel 및 두 번째 get이 만드는 세 출력 경로를 비교합니다.
검색 대기 · 완료와 취소의 세 경로
시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다
시간 초과 뒤 정상 완료가 취소보다 앞서면, 두 번째 get의 값은 출력하지 않습니다.
첫 get과 취소 경합의 세 출력
첫 timed get이 정상 반환한 때만 숫자를 출력한다. timeout 뒤 취소 true는 cancelled를 출력하며, 완료 선행 false는 두 번째 get의 값을 버린다. 첫 정상값은 2147483646이다. 원 관측은 cancelled이고 정상 숫자·무출력은 미관측 가능 경로다. shutdownNow 요청은 worker 완료 확인이 아니다.
상태별 상세 해설
첫 get이 정상 반환
- 후속 처리
- 그 값을 바로 println
- main이 출력하는 값
2147483646
시간 초과 뒤 취소 성공
- 후속 처리
- 두 번째 get의 CancellationException을 catch
- main이 출력하는 값
cancelled
시간 초과 뒤 정상 완료가 취소에 앞섬
- 후속 처리
- cancel은 false · 두 번째 get의 정상값을 버림
- main이 출력하는 값
- 출력 없음 · 정상 종료 가능
이번 실행은 cancelled를 출력했습니다. 표의 정상값 출력과 무출력 정상 종료는 미관측 가능 경로입니다. 원문은 cancel 반환값을 검사하지 않으며, finally의 shutdownNow도 작업자 종료를 기다리지는 않습니다.
이 예의 조건
- 첫 timed get 정상·timeout 뒤 취소 성공·timeout 뒤 정상 완료 선행의 세 조건을 공통 단계마다 비교합니다.
- 원 관측은 cancelled이며 정상값 출력·무출력 정상 종료는 미관측 가능 경로입니다.
- cancel 반환값은 원 main이 검사하지 않습니다. 5ms 예산과 shutdownNow 요청은 worker 종료 상한·완료 확인이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다 시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다 CooperativeSearch.main의 첫 timed get과 timeout 이후 cancel 및 두 번째 get이 만드는 세 출력 경로를 비교합니다. 검색 대기 · 완료와 취소의 세 경로 시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다 시간 초과 뒤 정상 완료가 취소보다 앞서면, 두 번째 get의 값은 출력하지 않습니다. 첫 get이 정상 반환 후속 처리 그 값을 바로 println main이 출력하는 값 2147483646 시간 초과 뒤 취소 성공 후속 처리 두 번째 get의 CancellationException을 catch main이 출력하는 값 cancelled 시간 초과 뒤 정상 완료가 취소에 앞섬 후속 처리 cancel은 false · 두 번째 get의 정상값을 버림 main이 출력하는 값 출력 없음 · 정상 종료 가능 이번 실행은 cancelled 를 출력했습니다. 표의 정상값 출력과 무출력 정상 종료는 미관측 가능 경로입니다. 원문은 cancel 반환값을 검사하지 않으며, finally의 shutdownNow도 작업자 종료를 기다리지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 get의 결과 판정 5ms timed get이 정상 반환하거나 timeout이 되어 후속 처리를 선택합니다. |
|
|
| 후속 조치와 두 번째 get timeout 뒤 cancel과 완료가 경합하여 두 번째 get의 결과가 달라집니다. |
|
|
| 각 출력과 정리 요청 각 경로가 원 코드의 출력 규칙을 따르고 finally에서 shutdownNow를 요청합니다. |
|
|
시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다
첫 get→timeout 뒤 cancel→두 번째 get에서 취소 성공/완료 경합 결과가 갈립니다. 각 호출의 반환과 출력 여부를 추적하되 관측한 cancelled를 유일한 결과로 만들지 않습니다.
CooperativeSearch.main의 첫 timed get과 timeout 이후 cancel 및 두 번째 get이 만드는 세 출력 경로를 비교합니다.
검색 대기 · 완료와 취소의 세 경로
시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다
시간 초과 뒤 정상 완료가 취소보다 앞서면, 두 번째 get의 값은 출력하지 않습니다.
첫 get과 취소 경합의 세 출력
첫 timed get이 정상 반환한 때만 숫자를 출력한다. timeout 뒤 취소 true는 cancelled를 출력하며, 완료 선행 false는 두 번째 get의 값을 버린다. 첫 정상값은 2147483646이다. 원 관측은 cancelled이고 정상 숫자·무출력은 미관측 가능 경로다. shutdownNow 요청은 worker 완료 확인이 아니다.
상태별 상세 해설
첫 get이 정상 반환
- 후속 처리
- 그 값을 바로 println
- main이 출력하는 값
2147483646
시간 초과 뒤 취소 성공
- 후속 처리
- 두 번째 get의 CancellationException을 catch
- main이 출력하는 값
cancelled
시간 초과 뒤 정상 완료가 취소에 앞섬
- 후속 처리
- cancel은 false · 두 번째 get의 정상값을 버림
- main이 출력하는 값
- 출력 없음 · 정상 종료 가능
이번 실행은 cancelled를 출력했습니다. 표의 정상값 출력과 무출력 정상 종료는 미관측 가능 경로입니다. 원문은 cancel 반환값을 검사하지 않으며, finally의 shutdownNow도 작업자 종료를 기다리지는 않습니다.
이 예의 조건
- 첫 timed get 정상·timeout 뒤 취소 성공·timeout 뒤 정상 완료 선행의 세 조건을 공통 단계마다 비교합니다.
- 원 관측은 cancelled이며 정상값 출력·무출력 정상 종료는 미관측 가능 경로입니다.
- cancel 반환값은 원 main이 검사하지 않습니다. 5ms 예산과 shutdownNow 요청은 worker 종료 상한·완료 확인이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다 시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다 CooperativeSearch.main의 첫 timed get과 timeout 이후 cancel 및 두 번째 get이 만드는 세 출력 경로를 비교합니다. 검색 대기 · 완료와 취소의 세 경로 시간 초과 뒤 취소가 완료와 경합하면 출력도 달라진다 시간 초과 뒤 정상 완료가 취소보다 앞서면, 두 번째 get의 값은 출력하지 않습니다. 첫 get이 정상 반환 후속 처리 그 값을 바로 println main이 출력하는 값 2147483646 시간 초과 뒤 취소 성공 후속 처리 두 번째 get의 CancellationException을 catch main이 출력하는 값 cancelled 시간 초과 뒤 정상 완료가 취소에 앞섬 후속 처리 cancel은 false · 두 번째 get의 정상값을 버림 main이 출력하는 값 출력 없음 · 정상 종료 가능 이번 실행은 cancelled 를 출력했습니다. 표의 정상값 출력과 무출력 정상 종료는 미관측 가능 경로입니다. 원문은 cancel 반환값을 검사하지 않으며, finally의 shutdownNow도 작업자 종료를 기다리지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 get의 결과 판정 5ms timed get이 정상 반환하거나 timeout이 되어 후속 처리를 선택합니다. |
|
|
| 후속 조치와 두 번째 get timeout 뒤 cancel과 완료가 경합하여 두 번째 get의 결과가 달라집니다. |
|
|
| 각 출력과 정리 요청 각 경로가 원 코드의 출력 규칙을 따르고 finally에서 shutdownNow를 요청합니다. |
|
|
그림처럼 이론상으로는 출력이 세 가지로 갈립니다. 첫 get이 5ms 안에 결과를 받으면 2147483646을 출력하고 끝납니다. 시간이 초과된 뒤 취소에 성공하면 이번처럼 cancelled가 나옵니다. 시간이 초과된 직후, cancel을 부르기 직전에 작업이 끝나 버리면 cancel은 false를 반환하고 두 번째 get()은 정상 값을 돌려줍니다. 이 코드는 그 값을 출력하지 않으므로 아무것도 출력하지 않은 채 프로그램이 끝납니다. 실제 코드라면 cancel의 반환값이나 두 번째 get의 결과도 처리해야 합니다.
5ms는 main이 기다리는 시간일 뿐, 작업이 멈추는 시각의 상한은 아닙니다. 작업은 다음 검사 지점, 즉 candidate가 다음 16,384의 배수가 될 때 비로소 인터럽트를 알아차립니다. finally의 shutdownNow()도 인터럽트를 한 번 더 요청할 뿐, 작업자가 끝나기를 기다리지는 않습니다.
검사 간격은 비용과 반응 속도 사이의 선택입니다. 매 반복마다 검사하면 취소에 가장 빨리 반응하지만 반복마다 검사 비용이 붙고, 간격을 너무 넓히면 취소를 요청한 뒤에도 한참 동안 계산이 이어집니다. 반복 한 번이 아주 가벼운 이 예제는 16,384번에 한 번씩 검사하고, 반복 한 번이 무거운 작업이라면 매번 검사해도 비용이 거의 들지 않습니다. 연습 문제 1이 그런 경우입니다.
결과를 네 가지로 나눠 돌려주기
지금까지 본 결과를 호출할 때마다 try와 여러 catch로 처리하면 코드가 길어지고, 실수로 한 경우를 빠뜨리기도 쉽습니다. 다음 await 메서드는 정해진 시간만큼 기다린 뒤, 결과를 성공·실패·시간 초과·취소 가운데 하나의 값으로 바꿔 돌려줍니다.
결과 타입 Outcome은 sealed 인터페이스로 만들었습니다. permits 뒤에 나열한 네 레코드만 이 인터페이스를 구현할 수 있으므로, 컴파일러는 Outcome의 종류가 정확히 넷이라는 것을 압니다. sealed는 sealed 계층과 상태 분기에서 자세히 다루니, 여기서는 '구현 클래스를 미리 정해 둔 인터페이스'로 읽으면 충분합니다.
import java.time.Duration;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
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();
}
}
}Failed[cause=java.lang.IllegalStateException: boom]main은 곧바로 예외를 던지는 작업을 제출하고 1초까지 기다립니다. get이 ExecutionException을 던지면 await는 getCause()로 꺼낸 원래 예외를 Failed에 담습니다. 그래서 출력에는 감싼 예외가 아니라 작업이 던진 IllegalStateException과 메시지 boom이 그대로 보입니다. 작업이 1초 안에 실행조차 되지 못할 만큼 느린 환경이라면 TimedOut[]이 나올 수도 있습니다.
FutureOutcomeClassifier.await가 get의 정상값과 네 예외를 처리하는 방법을 비교하며 Future의 완료 상태와 호출자의 대기 사건을 구분합니다.
대기 결과 · Future 상태와 호출자 사건
get의 반환·예외를 도메인 결과와 상위 전파로 나눈다
시간 초과와 호출자 인터럽트는 이번 대기의 사건이며 자동 취소를 뜻하지 않습니다.
| get에서 관찰한 결과 | await의 처리 | Future·작업에 대한 의미 |
|---|---|---|
| 정상 반환 value | Success(value) 반환 | Future 정상 완료 |
ExecutionException | Failed(e.getCause()) 반환 | Future 예외 완료 · 원인 객체 보존 |
CancellationException | Cancelled() 반환 | Future 취소 완료 · 작업자 종료와 별개 |
TimeoutException | TimedOut() 반환 | 이번 대기만 만료 · 자동 취소 안 함 |
InterruptedException | catch하지 않고 상위로 전파 | 대기 호출자의 인터럽트 · 자동 취소 안 함 |
- 정상 반환 value
- await의 처리
Success(value)반환- Future·작업에 대한 의미
- Future 정상 완료
ExecutionException- await의 처리
Failed(e.getCause())반환- Future·작업에 대한 의미
- Future 예외 완료 · 원인 객체 보존
CancellationException- await의 처리
Cancelled()반환- Future·작업에 대한 의미
- Future 취소 완료 · 작업자 종료와 별개
TimeoutException- await의 처리
TimedOut()반환- Future·작업에 대한 의미
- 이번 대기만 만료 · 자동 취소 안 함
InterruptedException- await의 처리
- catch하지 않고 상위로 전파
- Future·작업에 대한 의미
- 대기 호출자의 인터럽트 · 자동 취소 안 함
이번 main은 Failed 결과에 IllegalStateException: boom을 담아 출력했습니다. 1초 대기가 먼저 만료되면 TimedOut 경로도 가능합니다. 그 경로와 나머지 행은 이번에 관측하지 않은 await 코드·API의 처리 범위입니다.
그림의 마지막 줄처럼 await는 InterruptedException만은 값으로 바꾸지 않고 메서드 밖으로 던집니다. 이 예외는 작업이 아니라 결과를 기다리던 호출자 스레드가 인터럽트됐다는 뜻이기 때문입니다. 누군가 호출자에게 그만두라고 요청한 것이므로, 이것을 실패나 시간 초과로 바꿔 버리면 그 요청이 사라집니다. 그래서 throws InterruptedException으로 선언해 상위 코드가 처리하게 넘깁니다.
인터럽트가 작업 쪽에서 일어난 경우와는 구별해야 합니다. 아무도 취소하지 않은 작업의 본문에서 InterruptedException이 밖으로 던져졌다면, 예를 들어 실행기의 shutdownNow()가 건 인터럽트 때문이라면, 그것은 작업이 던진 예외입니다. 따라서 Future는 예외 완료가 되고 get()은 ExecutionException으로 감싸 전달하므로, await는 이 경우를 Failed로 분류합니다. 어느 쪽이든 get이 예외로 끝났다고 Future가 자동으로 취소되지는 않습니다.
sealed 타입의 장점은 결과를 쓰는 쪽에서 드러납니다. 호출하는 코드는 switch로 네 경우를 처리할 수 있고, 하나라도 빠뜨리면 컴파일 오류가 납니다. default 없이도 모든 경우를 다뤘는지 컴파일러가 확인해 주는 것입니다.
String message = switch (await(future, Duration.ofSeconds(1))) {
case Success<Integer> s -> "결과: " + s.value();
case Failed<Integer> f -> "실패: " + f.cause().getMessage();
case TimedOut<Integer> t -> "아직 처리 중입니다";
case Cancelled<Integer> c -> "취소되었습니다";
};await에 0이나 음수 시간을 넘기면 기다리지 않고 바로 판정합니다. 이미 끝난 Future라면 그 결과를, 아니면 TimedOut을 돌려줍니다. 반대로 Duration.toNanos()는 약 292년을 넘는 시간에서 ArithmeticException을 던지므로, 외부에서 받은 값을 그대로 넘기는 공개 API로 만든다면 범위를 따로 검사해야 합니다.
취소해도 되돌아가지 않는 일
취소는 앞으로 할 일을 멈추게 할 수는 있어도, 이미 한 일을 되돌리지는 못합니다. 작업이 외부 서비스에 요청을 보냈거나 데이터베이스에 값을 썼다면, Future를 취소해도 그 요청과 기록은 그대로 남습니다. 게다가 cancel(false)는 실행 중인 작업을 끝까지 실행하게 두므로, 취소에 성공한 뒤에도 작업이 새로운 변경을 만들 수 있습니다.
그래서 외부에 흔적을 남기는 작업은 같은 요청을 다시 보내도 한 번 보낸 것과 결과가 같도록(멱등성) 만들거나, 중간에 멈췄을 때 되돌리는 보상 절차를 따로 정해 둡니다. 이 글의 검색과 해시 계산은 외부에 아무것도 쓰지 않으므로 이런 문제가 없습니다.
더 깊이: FutureTask가 취소를 처리하는 방식
submit이 돌려주는 Future는 보통 FutureTask입니다. FutureTask는 내부에 NEW, COMPLETING, NORMAL, EXCEPTIONAL, CANCELLED, INTERRUPTING, INTERRUPTED 일곱 가지 상태를 둡니다. 결과를 기록하려는 작업 쪽과 취소하려는 쪽은 모두 CAS와 재시도 루프에서 본 CAS로 상태를 NEW에서 다른 값으로 바꾸려 하고, 먼저 성공한 쪽이 Future의 최종 결과를 정합니다. cancel(true)가 이기면 상태는 INTERRUPTING을 거쳐 INTERRUPTED가 되고, 그 뒤에 작업 본문이 값을 반환하더라도 그 값은 버려집니다. UncooperativeCancellation처럼 인터럽트를 무시한 작업이 끝까지 실행돼도 get()이 계속 CancellationException을 던지는 이유입니다. 이런 경합이 있기 때문에 Future 문서는 cancel의 반환값이 지금 취소된 상태인지를 반드시 알려 주지는 않으니 isCancelled()로 확인하라고 안내합니다.
취소할 때 세운 인터럽트 플래그가 다음 작업으로 새어 나가지 않을지도 궁금할 수 있습니다. 풀의 작업자 스레드는 재사용되므로, 플래그가 남아 있으면 다음 작업이 엉뚱하게 인터럽트를 받을 수 있기 때문입니다. ThreadPoolExecutor는 실행기가 멈추는 중이 아니라면 다음 작업을 시작하기 전에 작업자의 인터럽트 플래그를 지웁니다. 실제로 인터럽트를 무시한 채 끝난 작업 바로 뒤에 같은 스레드에서 실행한 작업은 플래그가 지워진 상태를 봤습니다. 그러니 작업 안에서 플래그를 되돌려 두는 관례를 따라도 다음 작업에는 영향이 없습니다.
정리
- Future는 정상 완료, 예외 완료, 취소 중 하나로 끝나고, 셋 모두
isDone()이true입니다. Java 19부터는state(),resultNow(),exceptionNow()로 기다리지 않고 구별할 수 있습니다. get(시간, 단위)의TimeoutException은 Future의 상태가 아니라 기다리기를 그만뒀다는 뜻입니다. 작업은 계속 실행되므로, 결과가 필요 없으면cancel을 따로 호출합니다.cancel(true)는 작업자 스레드에 인터럽트를 요청할 뿐입니다. 취소에 성공하면 Future는 바로 취소 상태가 되지만, 작업 코드가 실제로 멈추는지는 인터럽트에 협력하는지에 달려 있습니다.sleep같은 대기 메서드는InterruptedException으로 인터럽트를 알려 주고, CPU 계산 반복은 적당한 간격으로 플래그를 직접 확인해야 합니다.- 결과는 성공·실패·시간 초과·취소로 나눠 다루고, 호출자 자신이 받은
InterruptedException은 결과로 바꾸지 말고 위로 전달합니다.
연습 문제
1. 시간 안에 끝나지 않는 해시 계산 취소하기
바이트 배열에 SHA-256 해시를 백만 번 거듭 적용하는 작업을 제출하고, get으로 20밀리초까지만 기다리세요. 시간이 초과되면 취소를 요청하고 Future가 취소됐는지를 출력합니다. 작업은 반복할 때마다 인터럽트를 확인해야 합니다.
정답과 해설
import java.nio.charset.StandardCharsets;
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(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();
}
}
}timed-out-and-cancelled=trueMessageDigest.getInstance("SHA-256")은 SHA-256 해시를 계산하는 객체를 만들고, digest(value)는 바이트 배열 하나의 해시를 계산해 새 바이트 배열로 돌려준 뒤 다음 계산을 위해 상태를 처음으로 되돌립니다. 마지막의 HexFormat.of().formatHex(value)는 그 바이트 배열을 16진수 문자열로 바꿉니다. SHA-256 같은 해시 함수가 무엇인지는 암호화의 기초에서 다룹니다.
이 글을 실행한 환경에서 해시 백만 번은 0.3초쯤 걸렸으므로, 20ms 안에는 끝나지 않아 매번 이 결과가 나왔습니다. hash는 digest를 호출하기 전마다 Thread.interrupted()를 확인합니다. 해시 한 번은 반복 한 번치고 무거운 편이라 매번 검사해도 비용이 거의 들지 않습니다. 다만 digest 한 번을 계산하는 도중에는 멈추지 않고, 그 계산이 끝난 뒤 다음 검사에서 멈춥니다.
출력이 달라지는 두 경우도 생각해 볼 수 있습니다. 20ms 안에 해시가 끝나면 첫 get이 16진수 해시 문자열을 출력합니다. 시간이 초과된 직후 cancel을 부르기 전에 계산이 끝나면 취소가 실패해 timed-out-and-cancelled=false가 출력됩니다. 또 이 main은 취소를 요청한 뒤 작업자가 실제로 끝나기를 기다리지 않습니다. 그것까지 확인하려면 shutdownNow() 뒤에 awaitTermination을 호출해 반환값을 봐야 합니다.
2. 이미 끝난 Future를 취소하면
어떤 Future가 이미 42를 반환하고 정상 완료했습니다. 이 Future에 cancel(true)를 호출한 뒤, FutureOutcomeClassifier.await에 1초를 주어 결과를 분류합니다. 다른 스레드는 이 Future를 건드리지 않습니다. cancel의 반환값, isCancelled(), await의 결과를 예측하세요.
정답과 해설
cancel은 false, isCancelled()도 false이고, await는 Success[value=42]를 돌려줍니다. 이미 끝난 작업에 대한 cancel은 아무 효과가 없으므로 Future는 정상 완료 상태 그대로이고, get은 기다리지 않고 42를 반환하기 때문입니다. 이 조건을 그대로 만들어 실행해도 cancel과 isCancelled()는 false, 분류 결과는 Success[value=42]였습니다.
3. cancel(false)로 취소한 뒤에도 실행되는 본문
작업 본문이 시작하자마자 CountDownLatch에서 기다리게 만들고, 본문이 시작된 것을 확인한 다음 cancel(false)를 호출했더니 true가 반환됐습니다. 래치는 아직 열지 않았습니다. 이때 isDone(), isCancelled(), get()의 결과는 무엇이고, 나중에 래치를 열면 본문은 어떻게 될까요?
정답과 해설
isDone()과 isCancelled()는 모두 true이고, get()은 바로 CancellationException을 던집니다. 취소에 성공한 순간 Future는 취소 완료 상태가 되기 때문입니다. 하지만 false는 인터럽트를 걸지 않겠다는 뜻이므로 본문은 래치에서 계속 기다리고, 래치가 열리면 남은 코드를 끝까지 실행합니다. 본문이 반환한 값은 버려지지만, 그사이에 외부에 쓴 내용이 있다면 그대로 남습니다. 이 상황을 직접 만들어 실행해 보면 get()이 예외를 던진 뒤에도 래치를 열면 본문의 마지막 출력이 나타납니다.
4. 누가 인터럽트를 받았는가
await 안의 get에서 InterruptedException이 발생하는 경우와, 아무도 취소하지 않은 작업의 본문이 InterruptedException을 밖으로 던지며 끝나는 경우를 비교하세요. 각 경우에 await는 무엇을 하며, 둘 중 어느 경우든 Future가 자동으로 취소될까요?
정답과 해설
첫째는 결과를 기다리던 호출자 스레드가 인터럽트된 경우입니다. await는 이 예외를 잡지 않으므로 Outcome을 하나도 반환하지 않고, InterruptedException이 호출자에게 그대로 전달됩니다. 둘째는 작업이 던진 예외이므로 get()이 ExecutionException으로 감싸 던지고, await는 원인인 InterruptedException을 담은 Failed를 반환합니다.
두 경우 모두 Future는 자동으로 취소되지 않습니다. 특히 첫째 경우에는 작업이 아직 실행 중일 수 있으므로, 결과가 더 필요 없다면 cancel을 호출할지를 상위 코드가 정해야 합니다.
다음 글
invokeAll과 CompletableFuture로 결과 모으기에서는 서로 독립적인 작업 여러 개를 한꺼번에 실행하고 결과를 모으는 방법을 배웁니다. 결과를 모으는 도구가 달라져도, 이 글에서 본 정상 완료·실패·취소의 구분은 그대로 필요합니다.