안동민 개발노트

본문 시작

ExecutorService 정상 종료

ExecutorService의 단계적 종료와 남은 작업 회수를 익히고 스레드 풀 지표를 운영 수명주기에 연결합니다.

shutdown()은 종료 완료가 아니라 새 작업 접수를 막는 시작 신호입니다.

이미 실행 중인 작업과 큐에 들어온 작업은 계속 처리됩니다.

애플리케이션은 제한 시간 동안 종료를 기다리고, 넘기면 shutdownNow()로 인터럽트를 요청하며 반환된 미시작 작업을 기록해야 합니다.

실행기 상태는 isShutdown, isTerminated를 구별합니다.

운영 중에는 활성 작업자 수, 큐 크기, 완료 수의 추세와 지연 지표를 함께 보아 과부하 원인을 좁힙니다.


shutdown 직후의 잘못된 완료 판정

bad/ShutdownIsNotTermination.java
import java.util.concurrent.Executors;

public final class ShutdownIsNotTermination {
    public static void main(String[] args) {
        var executor = Executors.newSingleThreadExecutor();
        executor.execute(() -> {
            try { Thread.sleep(200); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            System.out.println("task-finished");
        });
        executor.shutdown();
        System.out.println("shutdown-complete=" + executor.isTerminated());
    }
}

작업이 아직 남아 있으면 shutdown-complete=false가 출력됩니다. 다만 main이 늦게 실행되면 작업이 먼저 끝날 수 있으므로 출력 순서와 isTerminated() 값은 고정되지 않습니다.

종료 단계와 완료 시점을 혼동하면 프로세스 자원 정리나 파일 닫기가 작업보다 먼저 일어날 수 있습니다.


단계적 종료 절차

  • shutdown으로 새 제출을 거절하고 기존 큐를 배출한다.
  • 첫 제한 시간 동안 awaitTermination으로 정상 완료를 기다린다.
  • 시간이 끝나면 shutdownNow로 실행 중 작업에 인터럽트를 요청한다.
  • 반환된 Runnable 목록은 시작되지 않은 작업이므로 손실 지표나 재처리에 남긴다.
  • 두 번째 제한 뒤에도 종료되지 않으면 스레드 덤프와 오류 로그를 남긴다.
  • 작업 코드는 인터럽트에 협력하고 finally에서 자체 자원을 정리한다.

재사용 가능한 우아한 종료 유틸리티

src/GracefulExecutorShutdown.java
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

public final class GracefulExecutorShutdown {
    static List<Runnable> close(ExecutorService executor, Duration graceful,
                                Duration forced) throws InterruptedException {
        executor.shutdown();
        if (executor.awaitTermination(graceful.toNanos(), TimeUnit.NANOSECONDS)) {
            return List.of();
        }
        List<Runnable> neverStarted = executor.shutdownNow();
        if (!executor.awaitTermination(forced.toNanos(), TimeUnit.NANOSECONDS)) {
            System.err.println("executor did not terminate");
        }
        return List.copyOf(neverStarted);
    }

    public static void main(String[] args) throws InterruptedException {
        var executor = java.util.concurrent.Executors.newFixedThreadPool(2);
        executor.execute(() -> {});
        System.out.println("neverStarted=" + close(executor,
                Duration.ofSeconds(1), Duration.ofSeconds(1)).size());
    }
}
종료 유틸리티의 반환과 종료 확인

종료 유틸리티의 반환과 종료 확인

종료 유틸리티의 반환과 종료 확인
close의 경로반환 또는 예외종료 확인
첫 await가 true빈 목록 반환정상 배출 완료
첫 await가 false, 두 번째는 trueshutdownNow가 회수한 목록 반환인터럽트 요청 뒤 종료 확인
두 번째 await도 false오류 출력 후 회수 목록 반환아직 종료되지 않음
대기에서 InterruptedException호출자에게 예외 전파이 메서드는 추가 종료 요청을 보장하지 않음
첫 await가 true
반환 또는 예외: 빈 목록 반환
종료 확인: 정상 배출 완료
첫 await가 false, 두 번째는 true
반환 또는 예외: shutdownNow가 회수한 목록 반환
종료 확인: 인터럽트 요청 뒤 종료 확인
두 번째 await도 false
반환 또는 예외: 오류 출력 후 회수 목록 반환
종료 확인: 아직 종료되지 않음
대기에서 InterruptedException
반환 또는 예외: 호출자에게 예외 전파
종료 확인: 이 메서드는 추가 종료 요청을 보장하지 않음

원문 close의 분기입니다. 목록 반환만으로 종료 성공을 판단할 수 없고, 두 번째 시간 초과만으로 인터럽트 무시를 단정할 수도 없습니다.

호출자가 대기 중 인터럽트되면 예외를 전파하며, 추가 shutdownNow() 요청이나 남은 작업 회수는 상위 수명주기 정책에서 결정해야 합니다.


풀 지표 스냅샷

src/PoolSnapshot.java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public final class PoolSnapshot {
    record Snapshot(int pool, int active, int queued, long completed) {}

    static Snapshot read(ThreadPoolExecutor executor) {
        return new Snapshot(executor.getPoolSize(), executor.getActiveCount(),
                executor.getQueue().size(), executor.getCompletedTaskCount());
    }

    public static void main(String[] args) throws InterruptedException {
        var executor = new ThreadPoolExecutor(2, 2, 0, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(4));
        for (int i = 0; i < 4; i++) {
            executor.execute(() -> {
                try { Thread.sleep(30); }
                catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            });
        }
        System.out.println("during=" + read(executor));
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.SECONDS);
        System.out.println("after=" + read(executor));
    }
}
풀 스냅샷의 네 측정값

풀 스냅샷의 네 측정값

풀 스냅샷의 네 측정값
필드측정 대상이 값만으로 알 수 없는 것
pool현재 풀의 작업자 수모든 작업자가 실행 중인지
active실행 중인 작업자 수의 근삿값개별 작업의 진행률
queued큐에서 실행을 기다리는 작업 수실행 중인 작업의 종료 여부
completed실행을 마친 작업 수의 근삿값업무 성공 여부와 응답 지연
pool
측정 대상: 현재 풀의 작업자 수
이 값만으로 알 수 없는 것: 모든 작업자가 실행 중인지
active
측정 대상: 실행 중인 작업자 수의 근삿값
이 값만으로 알 수 없는 것: 개별 작업의 진행률
queued
측정 대상: 큐에서 실행을 기다리는 작업 수
이 값만으로 알 수 없는 것: 실행 중인 작업의 종료 여부
completed
측정 대상: 실행을 마친 작업 수의 근삿값
이 값만으로 알 수 없는 것: 업무 성공 여부와 응답 지연

네 getter를 차례로 호출하므로 한 시점의 원자적 스냅샷은 아닙니다. 예제에는 제출·거부 횟수나 지연 시간 측정이 없습니다.

awaitTermination이 true를 반환해 종료가 확인되었다면 이 예제의 완료 작업 수는 4입니다. 원문은 반환값을 버리므로 after라는 출력 이름만으로 종료를 확정할 수 없습니다.

단일 스냅샷보다 시간에 따른 큐 증가와 완료율을 감시합니다.


서비스 종료와 지표 관측 결정

종료 상황조치남길 증거
정상 배포shutdown 대기완료 수
마감 초과shutdownNow미시작 목록
두 번째 대기 시간 초과미종료 원인 조사스레드 덤프
반복 과부하풀 지표 추세활성·queued·완료된

종료 신호와 프로세스 종료 연결

서버 종료 훅은 새 요청 수신을 먼저 막고, 실행기에는 더 이상 작업이 들어오지 않는 상태를 만든 뒤 shutdown()을 호출해야 합니다.

수신기가 열린 채로 풀부터 닫으면 종료 과정에서 들어온 요청이 거부되어 불필요한 오류가 늘어납니다.

반대로 수신만 닫고 풀을 기다리지 않으면 응답을 쓰던 작업이 중간에 잘릴 수 있습니다.

대기 시간은 작업의 정상 상한보다 약간 길게 잡되 배포 플랫폼의 전체 종료 제한보다 짧아야 합니다.

관측값은 종료 전후로 한 번씩 저장합니다.

제출 수, 완료 수, 거부 수, 큐 잔량은 배출 상태를 해석하는 근거입니다. 완료 수에는 업무 실패도 포함될 수 있으므로 데이터 보존 여부는 작업 결과와 내구 기록까지 대조합니다.

단순히 isShutdown()이 참이라는 사실은 실행 중 작업이 끝났다는 뜻이 아니며, 완전한 종료 판단은 isTerminated() 또는 awaitTermination() 결과로 해야 합니다.


연습 문제

이름 있는 Runnable을 큐에 넣고 강제 종료가 반환한 목록에서 작업 ID를 추출하세요.

실행 중 작업은 인터럽트에 반응해야 합니다.

정답과 해설
exercise/NeverStartedTaskSolution.java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public final class NeverStartedTaskSolution {
    record NamedTask(String id) implements Runnable {
        public void run() {
            try { Thread.sleep(5_000); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        var executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(3));
        executor.execute(new NamedTask("running"));
        executor.execute(new NamedTask("queued-1"));
        executor.execute(new NamedTask("queued-2"));
        Thread.sleep(20);
        var pending = executor.shutdownNow();
        executor.awaitTermination(1, TimeUnit.SECONDS);
        System.out.println(pending.stream().map(task -> ((NamedTask) task).id()).toList());
    }
}

반환 목록에는 shutdownNow()가 큐에서 회수한 작업이 들어갑니다. sleep(20)은 큐 잔량을 고정하지 않으므로 항상 두 ID가 나온다고 단정할 수 없습니다. 또한 대기 반환값을 확인하지 않았으므로 목록 출력 자체는 실행기 종료 확인이 아닙니다.

실제 재처리는 멱등성과 내구 저장을 추가로 검토해야 합니다.


실행기 종료 확인 신호

종료는 메서드 한 번이 아니라 접수 중단, 배출 대기, 강제 취소, 미시작 회수의 상태 전이입니다.

작업 인터럽트 협력과 풀 지표를 함께 준비해야 배포 시 데이터 손실을 판단할 수 있습니다.