빌드노트
CompletableFuture.orTimeout 뒤에도 작업이 계속된다
CompletableFuture.orTimeout() 뒤에도 작업의 변경이 실행될 수 있다. Future 완료와 작업 종료를 latch로 따로 확인하고, 중단 요청을 보내는 곳과 실제 변경 전에 확인할 곳을 구분한다.
CompletableFuture.orTimeout()으로 시간 초과를 처리했는데, 잠시 뒤 작업 본문에서 값이 바뀔 수 있다. 타임아웃은 Future의 결과를 실패로 확정한다. 이미 실행 중인 작업을 멈추는 신호까지 보내지는 않는다. 뒤늦은 변경을 막아야 한다면 호출자의 예외 처리와 작업 안의 중단 확인을 함께 설계해야 한다.
먼저 자신의 코드에서 orTimeout()을 붙인 Future와 supplyAsync()에 넘긴 작업 본문을 나눠 본다. Future의 isDone()만 찍으면 결과를 더 기다릴 필요가 없는지는 알 수 있지만, 작업이 변경을 마쳤는지는 알 수 없다. 작업 본문의 finally에 별도의 종료 표식을 두어야 둘을 비교할 수 있다.
Java 21의 orTimeout 계약 (새 탭에서 열림)은 기한 전에 완료되지 않은 그 Future 자체를 TimeoutException으로 예외 완료한다고 설명한다. 반환값도 같은 객체다. 별도의 관찰용 Future가 생긴다고 생각하고 원본을 다른 곳에서 공유하면 그쪽도 타임아웃 결과를 읽게 된다. 이 메서드는 Java 9부터 제공된다. 아래 실행에 사용한 Java 17 문서 (새 탭에서 열림)에서도 이 계약을 확인했다.
Future가 끝난 뒤 작업의 표식은 남아 있었다
Windows의 OpenJDK 17.0.2에서 외부 입출력 없이 비교했다. 각 조건마다 새 단일 스레드 executor를 만들고, 작업이 시작됐다는 latch를 확인했다. 그 뒤 작업을 다른 latch에서 기다리게 해 메모리 값 변경 직전에 멈춰 뒀다. 정상 조건은 먼저 문을 열어 changed 결과를 받았다. 문제 조건은 문을 닫은 채 orTimeout(100, MILLISECONDS)을 적용했다.
문제 조건의 실제 출력에서 필요한 부분이다. worker_finished는 Future 조회가 아니라 작업 본문의 finally에서 남긴 표식이다.
result=TimeoutException
before_release future_done=true worker_finished=false changed=false
after_release worker_finished=true changed=true stop_seen=false interrupted=false
타임아웃을 확인한 시점에도 작업은 latch 안에서 기다리고 있었다. 이후 문을 열자 작업은 값을 변경하고 finally에 도달했다. 작업이 뒤늦게 반환한 뒤 같은 Future를 다시 조회해도 결과는 TimeoutException이었다. Future에 성공 결과가 보이지 않는다는 사실과 작업의 부작용이 없다는 판단은 별개다.
100ms는 사건 순서를 만드는 입력이다. 작업 속도나 실서비스 응답 시간의 측정값이 아니다. 임의의 시간만큼 자고 추측한 대신 시작·해제·종료 latch로 순서를 확인했으며, 작업의 대기에는 별도의 상한을 뒀다.
중단 요청을 보내고, 변경 전에 읽는다
다음은 기존 supplyAsync() 제출부를 바꾸는 형태다. executor는 애플리케이션이 관리하는 실행기, prepare()는 String을 반환하는 준비 작업, save(String)은 변경을 수행하고 String을 반환하는 기존 메서드로 바꿔 읽는다. 필요한 타입은 java.util.concurrent의 CompletableFuture, CompletionException, TimeoutException, TimeUnit, CountDownLatch와 java.util.concurrent.atomic.AtomicBoolean이다. 독립 실행용 전체 클래스가 아니라 자신의 작업과 예외 처리 경계에 적용할 코드다.
이 코드에서 확인할 것은 두 가지다. 타임아웃을 처리하는 쪽이 중단 요청을 쓰는지, 작업 쪽이 실제 변경 전에 그 요청을 읽는지다.
var stop = new AtomicBoolean(false);
var workerFinished = new CountDownLatch(1);
CompletableFuture<String> response = CompletableFuture.supplyAsync(() -> {
try {
String prepared = prepare();
if (stop.get()) return "stopped";
return save(prepared);
} finally {
workerFinished.countDown();
}
}, executor);
try {
response.orTimeout(100, TimeUnit.MILLISECONDS).join();
} catch (CompletionException e) {
if (e.getCause() instanceof TimeoutException) {
stop.set(true);
System.out.println("future_done=" + response.isDone());
System.out.println("worker_finished=" + (workerFinished.getCount() == 0));
}
throw e;
}
AtomicBoolean은 두 스레드가 공유하는 중단 요청이다. get/set 계약 (새 탭에서 열림)에 따라 읽기·쓰기의 메모리 가시성을 갖는다. 일반 지역 boolean을 복사해 넘기는 것으로 대신하지 않는다. workerFinished는 진단용이며, false는 표식 위치에 아직 도달하지 않았다는 뜻이다. 그 값만으로 작업이 어디서 기다리는지까지 알 수는 없다. CountDownLatch (새 탭에서 열림)도 자신에게 연결한 동작의 도달 여부를 확인하는 도구다.
같은 격리 조건에서 타임아웃 뒤 stop을 설정하고, 그 후에 latch를 열었다. 작업이 중단 요청을 읽은 대조 결과는 다음과 같았다.
after_release worker_finished=true changed=false stop_seen=true interrupted=false
본문 코드도 prepare()와 save()를 메모리 안의 합성 작업으로 대체해 실행했다. 중단 요청을 보내는 경우에는 save() 호출이 0회였고, 요청을 보내는 줄만 뺀 경우에는 1회였다. 코드가 반환하는 "stopped"는 작업의 조기 반환값일 뿐, 이미 시간 초과된 Future를 성공으로 바꾸지 않는다.
이 비교에서는 중단 요청이 변경 전 검사보다 먼저 도착하도록 순서를 고정했다. 실제 코드에서는 검사 직후부터 save()가 시작되기 전까지도 타임아웃이 발생할 수 있다. 플래그 한 번으로 “기한 이후 변경은 절대 없다”는 보장은 생기지 않는다. 준비 작업이 오래 기다리는 입출력이라면 플래그를 읽을 지점까지 도달하지 못할 수도 있다.
따라서 먼저 계속해도 되는 작업인지 결정한다. 결과를 기다리지 않아도 끝까지 수행해야 하는 작업이라면 뒤늦은 완료와 실패를 추적한다. 중간에 그만둬도 된다면 반복 작업의 단위 사이, 준비 후 저장 전처럼 안전한 중단 지점을 둔다. prepare() 자체에 이미 변경이 있었다면 뒤의 중단 확인으로 되돌릴 수 없다. 외부 변경이 기한 뒤 일어나면 안 되는 요구는 플래그만으로 해결했다고 표시하지 말고, 실제 저장 경계에서 상태를 확인하는 방식까지 검토해야 한다. 이 실험은 DB 트랜잭션이나 원격 요청의 취소를 검증한 것이 아니다.
cancel(true)를 붙여도 같은 보장이 생기지 않는다
시간 초과를 확인한 Future에 cancel(true)를 추가한 실행에서는 반환값이 false였고, 작업은 계속됐다. 이미 타임아웃으로 완료된 상태를 뒤에서 취소로 바꾸지 못했다. 아직 완료되지 않은 Future를 직접 cancel(true)로 취소한 별도 조건에서는 반환값과 isCancelled()가 모두 true였지만, latch를 푼 뒤 값은 변경됐고 작업 인터럽트 관찰값은 false였다.
이는 CompletableFuture.cancel 계약 (새 탭에서 열림)의 범위와 맞는다. 이 구현은 mayInterruptIfRunning 인자로 실행을 제어하지 않는다. 다른 Future 구현에서의 취소 계약을 그대로 옮겨 해석하면 안 된다. Future.cancel 계약 (새 탭에서 열림) 자체도 구현이 실행 스레드를 아는지 등의 조건을 둔다.
실험에서는 각 전용 executor에 종료를 요청하고 awaitTermination()으로 종료를 확인했다. 이를 요청 하나가 시간 초과될 때마다 공유 실행기를 종료하라는 처방으로 옮기지는 않는다. ExecutorService 문서 (새 탭에서 열림)도 shutdownNow()의 실행 중 작업 중단을 최선의 시도로 설명한다.
확인한 범위는 이미 시작한 단일 합성 작업의 정상 완료, 시간 초과, 협력 중단, 직접 취소다. Java 21은 문서만 대조했고 해당 런타임 실행은 하지 않았다. 실제 저장소나 네트워크 작업을 적용할 때는 Future의 예외 로그와 함께 작업의 종료·실제 변경 여부를 별도로 확인해야 한다.