빌드노트
CompletableFuture.join()에서 원래 예외 대신 CompletionException이 잡힌다
CompletableFuture.join()의 짧은 catch에서 실제 실패 원인을 확인한다. get()의 포장 예외와 인터럽트, 직접 취소와 후속 단계의 차이를 구분해 결과를 받는 경계의 오류 처리를 정한다.
비동기 작업은 IllegalArgumentException으로 실패했는데 CompletableFuture.join() 바깥에서는 CompletionException만 잡힐 수 있다. 실제 join() 호출을 감싼 catch에서 CompletionException을 잡고, getCause()로 작업의 실패 원인을 확인한다. 원래 예외 타입의 catch만 두면 이 실패를 처리하지 못한다.
Java 17의 join() 문서 (새 탭에서 열림)는 작업 실패를 원인으로 담은 포장 예외를 설명한다. 따라서 기존 코드에서 먼저 찾을 곳은 작업을 제출하는 줄이 아니라 결과를 읽는 join()과 그 주변의 예외 처리다.
짧은 실패 입력으로 catch 대상을 확인한다
JDK 17이 설치되고 jshell을 실행할 수 있는 터미널에서 jshell --execution local --feedback concise를 연다. 다음 입력은 실제 예외를 넣어 이미 실패한 Future를 만들고, join()에서 잡힌 클래스와 그 원인을 출력한다. 별도 작업 스레드는 만들지 않는다.
import java.util.concurrent.*;
CompletableFuture<String> future = CompletableFuture.failedFuture(
new IllegalArgumentException("bad id"));
try {
future.join();
} catch (CompletionException e) {
System.out.println(e.getClass().getName());
System.out.println(e.getCause());
}
/exit
Windows의 OpenJDK 17.0.2에서 같은 입력을 실행한 출력이다. 첫 줄이 catch 대상이고, 둘째 줄이 원인 예외다.
java.util.concurrent.CompletionException
java.lang.IllegalArgumentException: bad id
이 catch는 차이를 보는 진단용이다. 실제 처리 코드에서는 e.getCause() instanceof IllegalArgumentException처럼 원인 타입을 확인하고, 해당 경계에서 처리하기로 한 입력 오류만 변환한다. 처리할 수 없는 실패는 throw e로 다시 전달해 원인을 보존한다. 로그도 메시지 하나로 줄이지 말고 잡힌 예외 객체와 스택을 남긴다.
별도 비교 실행에서도 원래 타입의 catch는 실행되지 않고 CompletionException 분기로 들어갔다. 이미 실패한 Future뿐 아니라 직접 만든 단일 스레드 executor의 짧은 supplyAsync 실패도 같은 포장과 원인을 보였다. 이때는 직접 소유한 executor만 종료하고 종료 상태를 확인했다.
get()으로 바꾸어도 원래 예외가 바로 나오지는 않는다
같은 실패 Future를 get()으로 읽었을 때는 ExecutionException이 잡혔고, 그 cause는 동일한 IllegalArgumentException 객체였다. 정상 완료된 Future에서는 두 조회 모두 ok를 반환했다. 조회 메서드를 바꾸는 것으로 작업 실패가 해결되지는 않았다.
CompletionException (새 탭에서 열림)은 RuntimeException의 하위 타입이고, ExecutionException (새 탭에서 열림)은 검사 예외다. get() 계약 (새 탭에서 열림)에는 기다리던 호출 스레드가 인터럽트됐을 때의 InterruptedException도 있다. get()을 쓰는 경계라면 이를 호출자에게 전달하거나 별도로 처리해야 한다. 작업이 던진 원인 예외와 한데 묶어 재시도하지 않는다.
취소는 원본과 후속 단계에서 다르게 보인다
미완료 Future에 직접 cancel(false)를 호출한 비교에서는 join()과 get() 모두 CancellationException을 바로 던졌고 cause는 null이었다. 앞의 CompletionException catch만으로는 직접 취소를 처리하지 못하므로 취소 정책이 필요한 경계에는 CancellationException 처리도 둔다.
취소 전에 그 Future의 thenApply(String::trim)으로 만든 후속 단계는 달랐다. join()은 CompletionException, get()은 ExecutionException을 던졌고, 두 원인은 CancellationException이었다. 이는 미완료 의존 단계로 실패가 전파되는 cancel() 계약 (새 탭에서 열림)과 맞는다. 후속 단계도 읽는 코드라면 포장 안의 취소 원인을 함께 분류한다. 같은 문서에서 mayInterruptIfRunning은 실행 중단 제어에 쓰이지 않는다고 명시하므로, 취소 성공을 작업 스레드 중단으로 해석하지 않는다.
실제 코드의 원인이 예상과 다르면 읽은 대상이 원본인지 후속 단계인지, 중간에서 예외를 다시 감쌌는지 확인한다. 이미 CompletionException으로 감싼 별도 입력에서는 join()이 그 포장 객체를 그대로 던졌다. 직접 만든 포장 예외에는 원인이 없을 수도 있으므로 getCause() 한 번을 항상 최종 원인이라고 가정하지 않는다.
이번 실행은 완료된 성공·실패·취소 결과와 짧은 단일 작업의 실패를 비교했다. 대기 중 인터럽트, 시간 초과, 여러 작업의 실패 경쟁은 실행하지 않았다. InterruptedException 설명은 직접 관찰한 결과가 아닌 API 계약이다.