본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
22장 : 실행자와 스레드 풀

ExecutorService 정상 종료

`shutdown` 호출만으로 종료를 완료했다고 오해하는 실패를 고치고, 단계적 종료·남은 작업 회수·ThreadPoolExecutor 지표를 운영 수명주기에 연결합니다.

shutdown()은 종료 완료가 아니라 새 작업 접수를 막는 시작 신호입니다. 이미 실행 중인 작업과 큐에 들어온 작업은 계속 처리됩니다. 애플리케이션은 제한 시간 동안 종료를 기다리고, 넘기면 shutdownNow()로 인터럽트를 요청하며 반환된 미시작 작업을 기록해야 합니다.

실행기 상태는 isShutdown, isTerminated를 구별합니다. 운영 중에는 활성 count, 큐 size, 완료된 작업 count를 함께 보아 과부하가 작업자 부족인지 느린 의존성인지 판단합니다.

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());
    }
}

첫 출력은 false이고 작업은 나중에 끝납니다. 종료 단계와 완료 시점을 혼동하면 프로세스 자원 정리나 파일 닫기가 작업보다 먼저 일어날 수 있습니다.

단계적 종료 절차

  • 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());
    }
}

정상 작업은 미시작 목록 0으로 끝납니다. 호출자 자체가 인터럽트되면 예외를 전파하면서 실행기에 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));
    }
}

순간 수치는 스케줄에 따라 달라지지만 종료 후 완료된은 4입니다. 단일 스냅샷보다 시간에 따른 큐 증가와 완료율을 감시합니다.

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

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

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

서버 종료 훅은 새 요청 수신을 먼저 막고, 실행기에는 더 이상 작업이 들어오지 않는 상태를 만든 뒤 shutdown()을 호출해야 합니다. 수신기가 열린 채로 풀부터 닫으면 종료 과정에서 들어온 요청이 거부되어 불필요한 오류가 늘어납니다. 반대로 수신만 닫고 풀을 기다리지 않으면 응답을 쓰던 작업이 중간에 잘릴 수 있습니다.

대기 시간은 작업의 정상 상한보다 약간 길게 잡되 배포 플랫폼의 전체 종료 제한보다 짧아야 합니다. 1차 대기가 끝나면 shutdownNow()로 인터럽트를 보내고, 반환된 미시작 작업 목록을 기록하거나 재처리 채널로 넘깁니다. 두 번째 짧은 대기 뒤에도 남은 작업이 있다면 스레드 덤프와 작업 식별자를 남겨 다음 장애 분석에 사용합니다.

관측값은 종료 전후로 한 번씩 저장합니다. 제출 수, 완료 수, 거부 수, 큐 잔량을 대조하면 정상 배출인지 강제 중단인지 알 수 있습니다. 단순히 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());
    }
}

반환 목록에는 queued 작업이 들어갑니다. 실제 재처리는 멱등성과 내구 저장을 추가로 검토해야 합니다.

실행기 종료 확인 신호

종료는 메서드 한 번이 아니라 접수 중단, 배출 대기, 강제 취소, 미시작 회수의 상태 전이입니다. 작업 인터럽트 협력과 풀 지표를 함께 준비해야 배포 시 데이터 손실을 판단할 수 있습니다.