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

빌드노트

ScheduledExecutorService 주기 작업이 한 번 실패한 뒤 멈춘다

ScheduledExecutorService의 주기 작업은 한 회차의 예외가 밖으로 나오면 후속 실행을 중단한다. 반환 Future와 회차 기록을 읽고, 다음 회차를 허용할 실패만 안에서 처리하는 경계를 실제 비교로 확인한다.

ScheduledExecutorService로 등록한 작업이 처음에는 실행되다가 예외 한 번 뒤로 조용해졌는데, Java 프로세스는 여전히 살아 있을 수 있다. 반복 작업의 한 회차에서 예외가 밖으로 나오면 그 작업의 후속 실행은 중단된다. 프로세스가 종료돼야만 반복이 멈추는 것은 아니다.

Java 21의 ScheduledExecutorService 계약 (새 탭에서 열림)은 이때 반환 Future가 완료 상태가 되고, get()이 원래 예외를 원인으로 가진 ExecutionException을 던진다고 설명한다. scheduleWithFixedDelay (새 탭에서 열림)도 같은 중단 규칙을 따른다. 두 방식 사이에서 예약 메서드만 바꾸어 해결할 증상이 아니다.

멈춘 예약의 Future부터 읽는다

기존 코드의 scheduleAtFixedRate(...) 또는 scheduleWithFixedDelay(...) 호출을 찾고, 그 호출이 반환한 ScheduledFuture<?>를 보관한다. 이미 멈춘 작업의 원인을 읽어야 하므로, 진단하려고 같은 작업을 새로 예약한 뒤 그 Future를 조사하면 안 된다. 반환값을 버렸다면 다음 등록부터 보관하고, 기존 회차 로그의 마지막 시작·성공·실패 기록을 함께 확인한다.

다음 메서드는 보관한 예약이 아직 미완료인지, 취소됐는지, 예외로 끝났는지 구별한다. 파일의 import 영역에 java.util.concurrent.*를 추가하고 기존 Java 클래스 안에 메서드를 둔 뒤, 진단 경계에서 inspectPeriodic(보관한Future)를 호출한다. 인자는 반복 예약의 반환값이며, 개별 회차에서 따로 제출한 작업의 Future가 아니다.

static void inspectPeriodic(ScheduledFuture<?> periodic)
        throws InterruptedException {
    if (!periodic.isDone()) {
        System.out.println("periodic unfinished");
        return;
    }
    try {
        periodic.get();
    } catch (ExecutionException e) {
        System.err.println("periodic failed");
        e.getCause().printStackTrace();
    } catch (CancellationException e) {
        System.out.println("periodic cancelled");
    }
}

예외로 끝났다면 periodic failed와 원인 스택이 나온다. 취소됐다면 periodic cancelled가 나오므로 등록 해제·종료 경로를 살펴본다. 이 출력은 진단용이며, 서비스에서는 작업 식별자와 원인 예외 객체를 기존 로깅 경계에 남긴다. 메서드는 InterruptedException을 호출자에게 전달하므로 호출부에서도 그 처리를 정해야 한다.

periodic unfinished는 성공 판정이 아니다. 아직 다음 실행을 기다리거나, 현재 회차가 끝나지 않았거나, 다른 작업 때문에 실행을 기다리는 경우를 더 살펴봐야 한다. 회차 시작과 종료 기록이 이 구분에 필요하다. 반대로 isDone()이 true인 것만으로도 성공을 뜻하지 않는다. Future 문서 (새 탭에서 열림)의 완료에는 예외와 취소도 포함된다.

여기서는 완료를 확인한 뒤에만 get()을 호출한다. 반복 예약의 Future는 한 회차의 성공 결과가 아니라 반복 전체의 종료 상태를 나타낸다. 정상적으로 계속 반복 중일 때 무조건 get()을 호출하면 정상 결과가 돌아오기를 계속 기다리게 된다. 특히 같은 단일 스케줄러의 작업 안에서 그 대기를 수행하지 않는다.

두 번째 회차만 실패시켜 비교했다

Windows 11의 OpenJDK 17.0.2에서 각 조건마다 Executors.newSingleThreadScheduledExecutor()를 새로 만들었다. 한 회차는 짧은 합성 작업이고, 실패 조건에서는 두 번째 회차에만 예외를 던졌다. 실제 서비스 장애를 조사한 기록은 아니다. scheduleAtFixedRate와 scheduleWithFixedDelay 각각에 아래 네 조건을 실행했다.

  • 실패가 없는 조건은 1·2·3회차가 성공했고, 정리 전 Future의 isDone()은 false였다.
  • 두 번째 회차의 TemporaryReadFailure를 밖으로 내보낸 조건은 1회차만 성공했다. 2회차의 finally는 실행됐지만 3회차는 실행되지 않았다. Future는 isDone=true, isCancelled=false였고, get()의 ExecutionException은 던졌던 예외 객체를 그대로 원인으로 가졌다.
  • 같은 예외를 회차 안에서 처리한 조건은 2회차 실패 기록 뒤 3회차가 성공했다. 정리 전 isDone()은 false였다. 실패한 2회차 자체가 다시 실행된 것은 아니다.
  • 그 처리 코드를 유지하고 2회차에 IllegalStateException을 던지자 3회차는 실행되지 않았다. Future의 원인도 이 예외로 바뀌었다.

두 예약 방식에서 같은 구분이 나왔다. 반복이 중단된 뒤에도 같은 스케줄러에 넣은 별도의 일회성 작업은 실행됐고, 그 작업의 스레드 ID도 첫 회차와 같았다. 이 관찰에서는 작업 스레드가 사라진 것이 아니라 해당 반복 예약이 끝난 것이다. 프로세스 생존이나 다른 작업의 실행만 보고 이 예약도 정상이라고 판단할 수 없다.

계속 반복된 조건에서도 시간 제한 0인 get()을 호출해 봤다. 결과는 정상 반환이 아니라 TimeoutException이었다. 앞선 세 회차의 성공과 반복 전체의 미완료가 함께 존재했다. 반복 예약의 반환값 설명 (새 탭에서 열림)도 정상적인 반복 중에는 get()이 정상 반환하지 않는다고 명시한다.

다음 회차를 허용할 실패만 안에서 처리한다

다음 회차에 다시 읽어도 되는 일시적 조회 실패라면, 한 회차를 감싸는 경계에서 그 예외를 처리하고 정상 반환하도록 바꿀 수 있다. 아래의 task는 기존 한 회차를 수행하는 Runnable이다. TemporaryReadFailure는 JDK 예외가 아니라, 실험에서 복구 가능 조건을 구별하려고 정의한 예외다.

다음 코드는 그 예외만 기록하고 다음 회차를 허용하는 형태다. 기존 반복 등록 호출에는 task 대신 guarded를 전달한다. 실제 코드에서는 회차를 건너뛰어도 되는 예외 종류를 애플리케이션 계약으로 먼저 정하고 해당 타입을 사용한다.

static final class TemporaryReadFailure extends RuntimeException {
    TemporaryReadFailure(String message) { super(message); }
}

// task가 선언된 기존 등록 메서드 안에 둔다.
Runnable guarded = () -> {
    try {
        task.run();
    } catch (TemporaryReadFailure e) {
        System.err.println("round skipped; retry on next tick");
        e.printStackTrace();
    }
};

예외 이름이 일시적이라는 이유만으로 복구 가능해지는 것은 아니다. 앞 회차가 일부 쓰기를 끝낸 상태라면 다음 회차가 중복 변경을 만들 수 있다. 다음 회차에서 다시 처리할 대상이 남는지, 부분 변경을 정리했는지, 실패 기록이 성공 기록과 구분되는지 확인해야 한다. 위 코드도 실패 회차를 즉시 재시도하거나 성공으로 바꾸지 않는다. 회차 실패는 로그·실패 횟수·마지막 성공 시각으로 별도 관찰해야 한다.

catch (Exception)으로 모든 실패를 같은 정책에 넣거나 catch (Throwable)로 심각한 Error까지 삼키는 방식으로 넓히지 않는다. 이 실험에서도 처리 대상으로 정하지 않은 IllegalStateException은 밖으로 나가 반복을 중단했다. 계속할 근거가 있는 실패만 안에서 처리하고, 나머지는 Future의 실패와 원인을 관찰하는 경계로 보낸다. get()에서 예외를 읽거나 로그를 남기는 것만으로 이미 끝난 예약이 살아나지는 않는다. 다시 등록하려면 원인 해결과 기존 예약의 종료를 확인하고 중복 등록을 막아야 한다.

실험은 latch로 필요한 회차의 종료를 기다리고 대기에 기한을 두었으며, 만든 스케줄러는 모두 종료했다. 중단 조건에서는 Future의 예외 완료를 읽은 뒤 별도 일회성 작업까지 실행되는 구간에서 후속 회차가 없음을 확인했다. 계속된 조건은 3회차 성공을 확인한 뒤 취소했으므로 장기간의 회복을 검증한 것은 아니다.

100ms 간격으로 예약했지만 정확히 그 시각에 실행된다고 검증하지 않았다. ScheduledThreadPoolExecutor 문서 (새 탭에서 열림)는 지연 이후의 실제 시작 시각을 실시간 보장하지 않는다. Java 21의 문서 계약은 확인했지만 실행한 JVM은 17.0.2다. 프레임워크가 예외를 먼저 처리하는 스케줄러, 오래 걸리거나 멈춘 회차, 실제 외부 I/O 실패, Error 발생 조건은 이번에 실행하지 않았다.

← 전체 글 보기