본문 바로가기
빌드노트 검색

빌드노트

submit한 작업이 실패했는데 호출부에도 핸들러에도 예외가 없다

ExecutorService.submit의 Future를 버리면 작업 실패가 호출부의 catch와 uncaught handler에 드러나지 않을 수 있다. execute·submit의 실제 비교로 완료 표식과 성공을 구분하고, 반환 Future를 관찰할 담당 경계를 정한다.

ExecutorService.submit()으로 보낸 작업이 실패했는데, 제출한 쪽의 catch에도 들어오지 않고 스레드의 uncaught handler에도 로그가 없을 수 있다. 기본 ThreadPoolExecutor 계열의 submit은 작업에서 나온 예외를 반환한 Future에 보관한다. 그 Future를 버리면 작업 실패를 읽을 통로도 놓친다. 제출 호출이 정상 반환한 것과 작업이 성공한 것은 따로 확인해야 한다.

기존 코드에서 먼저 볼 곳은 executor.submit(task);처럼 반환값을 사용하지 않는 줄이다. 이를 Future<?> result = executor.submit(task);로 받아 두고, 결과를 책임지는 호출자나 수집 경계까지 전달한다. 그 경계에서 get()의 정상 반환과 ExecutionException을 구분해야 작업 성공 여부를 알 수 있다. 변수에 저장만 하거나 isDone()만 출력하는 것으로는 부족하다.

제출문 전체를 try/catch로 감싸도 작업 스레드에서 나중에 발생한 예외가 그 catch로 돌아오지는 않는다. 제출 자체가 거절되는 경우는 별개다. API 계약상 이 글에서 사용한 기본 단일 스레드 executor는 종료 후 제출하면 RejectedExecutionException을 던진다. submit(Runnable) 계약 (새 탭에서 열림)도 제출 거절과 반환 Future를 따로 설명한다.

같은 실패 작업을 보내도 핸들러 호출은 달랐다

Windows의 OpenJDK 17.0.2에서 합성 IllegalStateException("synthetic task failure")을 던지는 같은 Runnable 객체를 세 번 사용했다. 비교할 때마다 Executors.newSingleThreadExecutor()로 새 executor를 만들고, 그 executor의 ThreadFactory에서 생성한 스레드에만 uncaught handler를 달았다. 작업 진입, finally 통과, 정상 끝 도달을 각각 세고, 뒤에 넣은 다른 작업의 실행도 확인했다. 실제 서비스 장애나 사용자 로그를 조사한 결과는 아니다.

  • execute로 보냈을 때 제출 호출은 정상 반환했고, uncaught handler는 1회 호출됐다. 실패 작업의 finally 표식은 1, 정상 끝 표식은 0이었다.
  • submit의 반환값을 버렸을 때도 제출 호출은 정상 반환했다. finally 표식은 1, 정상 끝 표식은 0이었지만 uncaught handler는 0회였다. 뒤에 넣은 작업도 실행됐다.
  • submit의 Future를 보관해 읽었을 때 uncaught handler는 여전히 0회였다. 대신 get()이 ExecutionException을 던졌고, getCause()는 작업이 던진 IllegalStateException 객체였다. 이 Future의 isDone()은 true였다.
  • 정상 Runnable을 submit한 대조에서는 정상 끝 표식이 1이고 get()은 null을 반환했다. 이때도 isDone()은 true였다.

finally에 도달했다거나 다음 작업이 실행됐다는 표식은 앞 작업의 성공을 증명하지 않았다. 핸들러 0회도 작업이 실행되지 않았다는 뜻이 아니었다. 비교에서는 latch와 제한 시간으로 진행을 기다렸고, 각 executor와 생성한 스레드가 모두 종료한 뒤 핸들러 횟수를 집계했다. 잠깐 기다려도 로그가 안 나왔다는 관찰과는 구분했다.

이 차이는 핸들러를 등록하는 위치만 바꿔 해결할 문제가 아니다. AbstractExecutorService 문서 (새 탭에서 열림)는 기본 submit 구현이 작업을 FutureTask로 감싼다고 설명한다. 이 작업 객체가 예외를 잡아 보관하므로, 작업의 실패가 작업 스레드의 미처리 예외 종료로 이어지지 않는다. UncaughtExceptionHandler (새 탭에서 열림)는 그런 미처리 예외로 스레드가 종료될 때 호출되는 장치다.

ThreadPoolExecutor를 상속해 공통 로그를 남기는 코드도 같은 경계를 확인해야 한다. afterExecute 문서 (새 탭에서 열림)는 FutureTask 안에 보관된 예외가 두 번째 Throwable 인자로 전달되지 않는다고 명시하고, 완료된 Future를 추가로 읽는 예를 제공한다. 이 설명은 문서 계약이며, 이번 실험에서 afterExecute를 재정의해 측정한 것은 아니다.

Future를 읽는 짧은 경계부터 확인한다

JDK 17의 jshell을 쓸 수 있는 터미널에서 jshell --execution local --feedback concise를 연다. 다음 입력은 실패하는 Runnable의 제출 반환과 결과 조회를 나누어 확인한다. 직접 만든 executor 하나만 사용하며 마지막에 종료한다.

import java.util.concurrent.*;
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
    Runnable task = () -> { throw new IllegalStateException("synthetic task failure"); };
    Future<?> result = executor.submit(task);
    System.out.println("submit returned");
    try {
        System.out.println("get=" + result.get(2, TimeUnit.SECONDS));
    } catch (ExecutionException e) {
        System.out.println("cause=" + e.getCause());
    }
    System.out.println("isDone=" + result.isDone());
} finally {
    executor.shutdownNow();
    System.out.println("terminated=" + executor.awaitTermination(2, TimeUnit.SECONDS));
}
/exit

같은 입력에서 나온 결과 행이다. JShell의 프롬프트와 입력 에코는 제외했다.

submit returned
cause=java.lang.IllegalStateException: synthetic task failure
isDone=true
terminated=true

같은 코드에서 task 선언만 Runnable task = () -> {};로 바꾸어 새 JShell에서 실행한 대조에서는 원인 출력 대신 get=null이 나왔다. 값이 없는 Runnable의 정상 완료는 null로 읽힌다. 반면 isDone=true는 두 경우 모두 나왔다. Future 문서 (새 탭에서 열림)에서 완료는 정상 종료뿐 아니라 예외와 취소도 포함한다. 완료 표식을 성공 로그로 사용하면 실패를 다시 놓친다.

예제의 catch는 원인을 화면에 보이기 위한 진단용이다. 실제 코드에서는 결과를 담당한 경계가 작업 식별자와 예외 객체를 로그에 남기거나, 원인을 보존해 호출자에게 전달하도록 정한다. 메시지만 출력하고 성공 경로로 계속 진행하면 실패를 관찰해도 처리 결과는 여전히 성공처럼 보일 수 있다. 작업 내부에서 예외를 잡아 버리는 코드가 있다면 Future가 정상 완료할 수도 있으므로 그 경계도 함께 확인한다.

제출 직후 반드시 기다릴 필요는 없다. 비동기 처리를 유지하려면 반환 Future를 호출자에게 돌려주거나 결과 수집 담당자에게 넘기고, 누가 그 결과를 읽는지 정한다. 요청을 받았다는 응답과 백그라운드 작업의 성공 기록도 나누어야 한다. execute로 바꾸어 핸들러에 맡기는 선택만으로 각 작업의 성공 결과를 돌려받을 수 있는 것은 아니다. 이미 FutureTask로 감싼 Runnable을 execute에 넣는 경우에도 예외가 그 안에 보관될 수 있다.

기다림의 실패도 작업의 실패와 나눈다. 시간 제한이 있는 get 계약 (새 탭에서 열림)에서 TimeoutException은 정한 시간 안에 결과를 받지 못했다는 뜻이며, 그 호출만으로 작업이 취소되지는 않는다. InterruptedException은 기다리는 스레드의 인터럽트이고, CancellationException은 취소된 결과다. 일반 메서드에 옮길 때는 인터럽트를 호출자에게 전달하거나, 잡아서 처리한다면 현재 스레드의 인터럽트 상태를 복원하는 정책도 필요하다.

이번 실행은 OpenJDK 17.0.2의 기본 단일 스레드 executor와 짧은 합성 작업에 한정한다. Java 21의 대응 문서도 확인했지만 Java 21 JVM에서 실행하지는 않았다. 프레임워크가 감싼 executor, 취소, 대기 중 인터럽트와 실제 시간 초과 발생 경로는 검증하지 않았다. 이 경계들의 설명은 직접 측정값이 아닌 API 계약이다.

← 전체 글 보기