본문 바로가기

ExecutorService를 안전하게 종료하기

shutdown, awaitTermination, shutdownNow로 실행기를 단계적으로 종료하고, 시작하지 못한 작업을 회수하며, 스레드 풀의 상태 값을 읽어 종료 과정을 확인하는 방법을 배웁니다.

스레드 풀을 만들어 작업을 맡기는 것까지는 지금까지의 예제로 익숙해졌습니다. 그런데 프로그램을 끝내거나 서버를 다시 배포할 때는 풀도 정리해야 합니다. 새 작업은 더 받지 않고, 실행 중인 작업은 가능하면 마저 끝내고, 큐에서 기다리던 작업은 버릴지 나중에 다시 처리할지 정해야 합니다. 이 과정을 대충 처리하면 프로그램이 끝나지 않거나, 반대로 처리 중이던 작업이 중간에 잘립니다.

이 글에서는 ExecutorService의 종료 메서드 shutdown, awaitTermination, shutdownNow가 각각 무엇을 하는지 실행 결과로 확인하고, 셋을 조합해 두 단계로 종료하는 유틸리티를 만듭니다. 이어서 Java 19부터 쓸 수 있는 try-with-resources 방식과 비교하고, 마지막으로 ThreadPoolExecutor가 알려 주는 상태 값을 읽어 봅니다. 인터럽트에 협력하는 작업은 Future 취소와 시간 초과, 실패 구분하기에서 다뤘습니다.

풀을 닫지 않으면 프로그램이 끝나지 않는다

먼저 아무것도 정리하지 않으면 어떻게 되는지 보겠습니다. 다음 프로그램은 스레드 하나짜리 고정 크기 풀에 작업을 하나 맡기고 main을 끝냅니다.

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

public final class NoShutdown {
    public static void main(String[] args) {
        var executor = Executors.newFixedThreadPool(1);
        executor.execute(() -> System.out.println("task-finished"));
        System.out.println("main-returns");
    }
}
실행 결과(한 예)
main-returns
task-finished

두 줄이 출력된 뒤에도 프로그램은 끝나지 않고 그대로 멈춰 있어서, 터미널에서 Ctrl+C로 직접 끝내야 합니다. 두 줄은 서로 다른 스레드가 출력하므로 순서가 바뀔 수도 있습니다. 원인은 풀의 작업자 스레드입니다. newFixedThreadPool이 만든 작업자는 작업을 마친 뒤에도 끝나지 않고 다음 작업이 큐에 들어오기를 기다립니다. 이 작업자는 데몬이 아닌 일반 스레드이므로, Runnable과 데몬 스레드에서 본 것처럼 JVM은 이 스레드가 끝날 때까지 종료하지 않습니다.

shutdown은 종료를 기다리지 않는다

풀을 끝내는 첫 번째 메서드는 shutdown()입니다. 다음 프로그램은 200ms가 걸리는 작업을 맡긴 뒤 곧바로 shutdown()을 호출하고, 실행기가 완전히 종료됐는지를 isTerminated()로 출력합니다.

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
task-finished

shutdown()을 호출한 직후 isTerminated()는 false였고, 작업은 그 뒤에 끝났습니다. shutdown()은 새 작업의 접수를 막을 뿐, 이미 맡긴 작업이 끝나기를 기다리지 않고 바로 돌아오기 때문입니다. 실행 중인 작업과 큐에서 기다리던 작업은 모두 끝까지 처리되고, 큐가 비면 작업자들이 하나씩 끝나면서 실행기가 종료됩니다. 그래서 이번에는 작업이 끝나자 프로그램도 정상적으로 끝났습니다. 여러 번 실행해도 결과는 같았지만, main 스레드가 200ms 넘게 멈추는 드문 상황이라면 작업이 먼저 끝나 true가 나올 수도 있습니다.

종료를 요청한 상태와 종료된 상태가 다르므로, 상태를 확인하는 메서드도 둘입니다. isShutdown()은 shutdown()이나 shutdownNow()가 호출됐으면 true이고, isTerminated()는 그 뒤에 모든 작업이 끝나 실행기가 완전히 멈췄을 때만 true입니다. 종료를 요청하지 않은 실행기의 isTerminated()는 언제나 false입니다. 또 기본 설정의 풀에서는 shutdown() 뒤에 새 작업을 맡기면 RejectedExecutionException이 발생합니다.

종료 요청과 종료를 혼동하면 정리 순서가 꼬입니다. 예를 들어 작업들이 쓰는 파일이나 데이터베이스 연결을 shutdown() 직후에 닫으면, 아직 실행 중인 작업이 닫힌 자원을 쓰다가 실패합니다. 작업이 끝나기를 기다리려면 awaitTermination(시간, 단위)를 씁니다. 이 메서드는 실행기가 종료될 때까지 최대 주어진 시간만큼 기다리고, 그 안에 종료되면 true를, 시간이 다 지나면 false를 반환합니다. 기다리는 도중 호출한 스레드가 인터럽트되면 InterruptedException을 던집니다.

shutdownNow는 큐를 비우고 인터럽트를 건다

awaitTermination이 false를 반환했다면 정해진 시간 안에 작업이 끝나지 않은 것입니다. 이때 쓰는 것이 shutdownNow()입니다. shutdownNow()는 새 작업의 접수를 막고, 큐에서 기다리던 작업을 모두 꺼내 목록으로 돌려주며, 실행 중인 작업자 스레드에 인터럽트를 겁니다.

여기에도 주의할 점이 둘 있습니다. 첫째, shutdownNow()도 기다리지 않습니다. 인터럽트를 건 뒤 바로 돌아오므로, 작업이 실제로 멈췄는지는 다시 awaitTermination으로 확인해야 합니다. ExecutorService 문서는 실행 중인 작업을 멈추려는 시도가 최선을 다하는 것(best-effort) 이상을 약속하지 않으며, 인터럽트에 반응하지 않는 작업은 영영 끝나지 않을 수 있다고 적고 있습니다. 둘째, 돌려받는 목록은 한 번도 시작하지 못한 작업의 목록입니다. 인터럽트를 받은 실행 중인 작업은 이 목록에 들어 있지 않습니다.

submit으로 맡긴 작업이라면 인터럽트를 받은 뒤 그 Future가 어떻게 끝나는지도 알아 둘 만합니다. shutdownNow()는 Future를 취소하는 것이 아니라 작업자 스레드에 인터럽트만 걸기 때문입니다. 작업 안의 sleep이 InterruptedException을 던지고 그 예외가 작업 밖으로 나가면, 그 Future는 취소가 아니라 예외 완료가 됩니다. 실제로 확인해 보면 get()이 원인이 InterruptedException인 ExecutionException을 던지고, isCancelled()는 false입니다.

두 단계로 종료하는 유틸리티

세 메서드를 조합하면 실무에서 쓰는 단계적 종료가 됩니다. 먼저 shutdown()으로 새 작업을 막고 정해진 시간 동안 정상 종료를 기다립니다. 그래도 끝나지 않으면 shutdownNow()로 남은 작업을 회수하고 실행 중인 작업에 인터럽트를 건 뒤, 한 번 더 기다립니다. 두 번째 기다림도 실패하면 오류를 기록합니다.

src/GracefulExecutorShutdown.java
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
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 = Executors.newFixedThreadPool(1);
        for (int i = 0; i < 3; i++) {
            executor.execute(() -> {
                try {
                    Thread.sleep(5_000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }
        List<Runnable> neverStarted = close(executor,
                Duration.ofMillis(100), Duration.ofSeconds(1));
        System.out.println("neverStarted=" + neverStarted.size());
        System.out.println("terminated=" + executor.isTerminated());
    }
}

main은 스레드 하나짜리 풀에 5초씩 잠드는 작업 세 개를 맡깁니다. 첫 작업은 바로 실행되고 나머지 둘은 큐에서 기다립니다. 정상 종료에는 100ms만 주었으므로 첫 번째 awaitTermination은 false를 반환하고, shutdownNow()가 큐의 두 작업을 꺼내 돌려주면서 실행 중인 첫 작업에 인터럽트를 겁니다.

실행 결과
neverStarted=2
terminated=true

첫 줄의 2는 큐에서 기다리다 회수된 두 작업이고, 실행 중이던 첫 작업은 이 목록에 없습니다. 첫 작업은 sleep 도중 인터럽트를 받아 플래그를 되돌리고 바로 끝났습니다. 그래서 두 번째 awaitTermination이 1초 안에 true를 반환했고, 두 번째 줄처럼 실행기도 종료됐습니다. 여러 번 실행해도 같은 결과가 나왔습니다.

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

shutdown 뒤 첫 await·shutdownNow·둘째 await·반환의 정리 과정입니다. 회수 목록 반환과 실제 종료 확인의 다른 상태를 단계로 보여줍니다.

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

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

실행기 종료 · 반환과 확인의 차이

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

회수한 목록을 반환해도 두 번째 await가 false면 종료를 확인하지 못한 상태입니다.

두 번의 대기와 반환 목록

두 번의 대기와 반환 목록shutdown 뒤 첫 await true는 빈 목록으로 종료 확인한다. 첫 false만 shutdownNow와 두 번째 await로 진행하며, 둘째 false에도 회수 목록은 반환된다. 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아니다. 중단 요청 자체는 worker 완료 확인이 아니다.truefalsetruefalseshutdown새 접수 중지첫 awaittrue빈 목록false회수·중단두 번째 awaittrue종료 확인false미확인목록 반환
두 번의 대기와 반환 목록shutdown 뒤 첫 await true는 빈 목록으로 종료 확인한다. 첫 false만 shutdownNow와 두 번째 await로 진행하며, 둘째 false에도 회수 목록은 반환된다. 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아니다. 중단 요청 자체는 worker 완료 확인이 아니다.truefalsetruefalseshutdown새 접수 중지첫 awaittrue빈 목록false회수·중단두 번째 awaittrue종료 확인false미확인목록 반환

shutdown 뒤 첫 await true는 빈 목록으로 종료 확인한다. 첫 false만 shutdownNow와 두 번째 await로 진행하며, 둘째 false에도 회수 목록은 반환된다. 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아니다. 중단 요청 자체는 worker 완료 확인이 아니다.

대기 중 호출자 인터럽트

대기 중 호출자 인터럽트어느 await에서든 InterruptedException이 호출자로 전파된다. 이 메서드가 추가 종료 요청을 보장하지 않는다.await 중 호출자 중단InterruptedException호출자에게 전파추가 종료 요청상위 정책의 책임
대기 중 호출자 인터럽트어느 await에서든 InterruptedException이 호출자로 전파된다. 이 메서드가 추가 종료 요청을 보장하지 않는다.await 중 호출자 중단InterruptedException호출자에게 전파추가 종료 요청상위 정책의 책임

어느 await에서든 InterruptedException이 호출자로 전파된다. 이 메서드가 추가 종료 요청을 보장하지 않는다.

상태별 상세 해설

첫 await가 true

반환 또는 예외
빈 목록 반환
종료 확인
정상 배출 완료

첫 await가 false, 두 번째는 true

반환 또는 예외
shutdownNow가 회수한 목록 반환
종료 확인
인터럽트 요청 뒤 종료 확인

두 번째 await도 false

반환 또는 예외
오류 출력 후 회수 목록 반환
종료 확인
아직 종료되지 않음

대기에서 InterruptedException

반환 또는 예외
호출자에게 예외 전파
종료 확인
이 메서드는 추가 종료 요청을 보장하지 않음

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

이 예의 조건

  • 유효하고 나노초로 변환 가능한 두 대기 예산을 전제로 종료 유틸리티의 각 분기를 같은 관찰 단계에서 비교합니다.
  • 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아닙니다. 목록 반환만으로 종료 성공을 판단하지 않습니다.
  • InterruptedException은 호출자에게 전파하며 추가 종료 요청은 상위 정책의 책임입니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보종료 유틸리티의 반환과 종료 확인 종료 유틸리티의 반환과 종료 확인 종료 유틸리티의 반환과 종료 확인 실행기 종료 · 반환과 확인의 차이 종료 유틸리티의 반환과 종료 확인 회수한 목록을 반환해도 두 번째 await가 false면 종료를 확인하지 못한 상태입니다. 첫 await가 true 반환 또는 예외 빈 목록 반환 종료 확인 정상 배출 완료 첫 await가 false, 두 번째는 true 반환 또는 예외 shutdownNow가 회수한 목록 반환 종료 확인 인터럽트 요청 뒤 종료 확인 두 번째 await도 false 반환 또는 예외 오류 출력 후 회수 목록 반환 종료 확인 아직 종료되지 않음 대기에서 InterruptedException 반환 또는 예외 호출자에게 예외 전파 종료 확인 이 메서드는 추가 종료 요청을 보장하지 않음 원문 close의 분기입니다. 목록 반환만으로 종료 성공을 판단할 수 없고, 두 번째 시간 초과만으로 인터럽트 무시를 단정할 수도 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
shutdown과 첫 대기

새 접수를 끝내는 shutdown 뒤 첫 await에서 배출 여부를 기다립니다.

  • 첫 true: 정상 종료 확인 · 빈 목록 반환
  • 첫 false: shutdownNow·두 번째 await 필요
  • 대기 중 호출자 중단: InterruptedException 전파 경로
  • 첫 대기의 결과가 정상 반환·강제 요청·호출자 예외 중 다음 처리를 정합니다.
강제 요청과 각 대기 결과

첫 false인 경로만 미시작 목록을 회수하고 두 번째 예산으로 다시 기다립니다.

  • 첫 true: 추가 강제 요청 없이 종료
  • 둘째 true: shutdownNow 회수 목록 · 종료 확인
  • 둘째 false: 오류 출력 · 종료 미확인
  • 어느 await의 중단: 예외 전파 · 이미 수행한 요청은 남음
  • 강제 요청 뒤에도 종료 확인은 두 번째 await의 true에 달리며 호출자 중단은 대기에서 이탈합니다.
반환·예외와 확인 범위

정상 목록 반환과 실행기 종료 확인을 구분해 각 출구를 읽습니다.

  • 첫 true: 빈 목록 · 정상 배출 완료
  • 둘째 true: 회수 목록 · 요청 뒤 종료 확인
  • 둘째 false: 회수 목록 반환 · 아직 종료되지 않음
  • 호출자 중단: 예외 전파 · 추가 요청 보장 없음
  • 같은 목록 반환에도 종료 확인 여부가 다르며 timeout만으로 worker가 인터럽트를 무시했다고 단정하지 않습니다.

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

shutdown 뒤 첫 await·shutdownNow·둘째 await·반환의 정리 과정입니다. 회수 목록 반환과 실제 종료 확인의 다른 상태를 단계로 보여줍니다.

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

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

실행기 종료 · 반환과 확인의 차이

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

회수한 목록을 반환해도 두 번째 await가 false면 종료를 확인하지 못한 상태입니다.

두 번의 대기와 반환 목록

두 번의 대기와 반환 목록shutdown 뒤 첫 await true는 빈 목록으로 종료 확인한다. 첫 false만 shutdownNow와 두 번째 await로 진행하며, 둘째 false에도 회수 목록은 반환된다. 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아니다. 중단 요청 자체는 worker 완료 확인이 아니다.truefalsetruefalseshutdown새 접수 중지첫 awaittrue빈 목록false회수·중단두 번째 awaittrue종료 확인false미확인목록 반환
두 번의 대기와 반환 목록shutdown 뒤 첫 await true는 빈 목록으로 종료 확인한다. 첫 false만 shutdownNow와 두 번째 await로 진행하며, 둘째 false에도 회수 목록은 반환된다. 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아니다. 중단 요청 자체는 worker 완료 확인이 아니다.truefalsetruefalseshutdown새 접수 중지첫 awaittrue빈 목록false회수·중단두 번째 awaittrue종료 확인false미확인목록 반환

shutdown 뒤 첫 await true는 빈 목록으로 종료 확인한다. 첫 false만 shutdownNow와 두 번째 await로 진행하며, 둘째 false에도 회수 목록은 반환된다. 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아니다. 중단 요청 자체는 worker 완료 확인이 아니다.

대기 중 호출자 인터럽트

대기 중 호출자 인터럽트어느 await에서든 InterruptedException이 호출자로 전파된다. 이 메서드가 추가 종료 요청을 보장하지 않는다.await 중 호출자 중단InterruptedException호출자에게 전파추가 종료 요청상위 정책의 책임
대기 중 호출자 인터럽트어느 await에서든 InterruptedException이 호출자로 전파된다. 이 메서드가 추가 종료 요청을 보장하지 않는다.await 중 호출자 중단InterruptedException호출자에게 전파추가 종료 요청상위 정책의 책임

어느 await에서든 InterruptedException이 호출자로 전파된다. 이 메서드가 추가 종료 요청을 보장하지 않는다.

상태별 상세 해설

첫 await가 true

반환 또는 예외
빈 목록 반환
종료 확인
정상 배출 완료

첫 await가 false, 두 번째는 true

반환 또는 예외
shutdownNow가 회수한 목록 반환
종료 확인
인터럽트 요청 뒤 종료 확인

두 번째 await도 false

반환 또는 예외
오류 출력 후 회수 목록 반환
종료 확인
아직 종료되지 않음

대기에서 InterruptedException

반환 또는 예외
호출자에게 예외 전파
종료 확인
이 메서드는 추가 종료 요청을 보장하지 않음

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

이 예의 조건

  • 유효하고 나노초로 변환 가능한 두 대기 예산을 전제로 종료 유틸리티의 각 분기를 같은 관찰 단계에서 비교합니다.
  • 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아닙니다. 목록 반환만으로 종료 성공을 판단하지 않습니다.
  • InterruptedException은 호출자에게 전파하며 추가 종료 요청은 상위 정책의 책임입니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보종료 유틸리티의 반환과 종료 확인 종료 유틸리티의 반환과 종료 확인 종료 유틸리티의 반환과 종료 확인 실행기 종료 · 반환과 확인의 차이 종료 유틸리티의 반환과 종료 확인 회수한 목록을 반환해도 두 번째 await가 false면 종료를 확인하지 못한 상태입니다. 첫 await가 true 반환 또는 예외 빈 목록 반환 종료 확인 정상 배출 완료 첫 await가 false, 두 번째는 true 반환 또는 예외 shutdownNow가 회수한 목록 반환 종료 확인 인터럽트 요청 뒤 종료 확인 두 번째 await도 false 반환 또는 예외 오류 출력 후 회수 목록 반환 종료 확인 아직 종료되지 않음 대기에서 InterruptedException 반환 또는 예외 호출자에게 예외 전파 종료 확인 이 메서드는 추가 종료 요청을 보장하지 않음 원문 close의 분기입니다. 목록 반환만으로 종료 성공을 판단할 수 없고, 두 번째 시간 초과만으로 인터럽트 무시를 단정할 수도 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
shutdown과 첫 대기

새 접수를 끝내는 shutdown 뒤 첫 await에서 배출 여부를 기다립니다.

  • 첫 true: 정상 종료 확인 · 빈 목록 반환
  • 첫 false: shutdownNow·두 번째 await 필요
  • 대기 중 호출자 중단: InterruptedException 전파 경로
  • 첫 대기의 결과가 정상 반환·강제 요청·호출자 예외 중 다음 처리를 정합니다.
강제 요청과 각 대기 결과

첫 false인 경로만 미시작 목록을 회수하고 두 번째 예산으로 다시 기다립니다.

  • 첫 true: 추가 강제 요청 없이 종료
  • 둘째 true: shutdownNow 회수 목록 · 종료 확인
  • 둘째 false: 오류 출력 · 종료 미확인
  • 어느 await의 중단: 예외 전파 · 이미 수행한 요청은 남음
  • 강제 요청 뒤에도 종료 확인은 두 번째 await의 true에 달리며 호출자 중단은 대기에서 이탈합니다.
반환·예외와 확인 범위

정상 목록 반환과 실행기 종료 확인을 구분해 각 출구를 읽습니다.

  • 첫 true: 빈 목록 · 정상 배출 완료
  • 둘째 true: 회수 목록 · 요청 뒤 종료 확인
  • 둘째 false: 회수 목록 반환 · 아직 종료되지 않음
  • 호출자 중단: 예외 전파 · 추가 요청 보장 없음
  • 같은 목록 반환에도 종료 확인 여부가 다르며 timeout만으로 worker가 인터럽트를 무시했다고 단정하지 않습니다.

그림은 close의 출구를 모두 보여 줍니다. 첫 번째 기다림이 성공하면 shutdownNow()를 부르지 않고 빈 목록을 반환합니다. 첫 번째가 실패하고 두 번째가 성공하면 회수한 목록을 반환하며, 이때는 실행기의 종료까지 확인된 상태입니다. 두 번째 기다림까지 실패하면 오류를 출력한 뒤 회수한 목록을 반환하는데, 이때 실행기는 아직 종료되지 않았습니다. 기다리는 도중 close를 호출한 스레드가 인터럽트되면 InterruptedException이 그대로 호출자에게 전달됩니다.

두 번째 기다림이 실패했다고 해서 원인을 '작업이 인터럽트를 무시했다'로 단정할 수는 없습니다. 작업이 인터럽트에 협력하더라도 자원을 정리하는 데 시간이 오래 걸리면 같은 결과가 나옵니다. 정확한 원인은 그 시점의 스레드 덤프로 작업자가 어디에서 멈춰 있는지 확인해야 알 수 있습니다.

마지막 경우, 즉 기다리다 인터럽트를 받았을 때를 다루는 방식은 공식 문서의 예와 다릅니다. 문서의 shutdownAndAwaitTermination 예제는 기다리다 인터럽트를 받으면 shutdownNow()를 한 번 더 호출하고, 인터럽트 플래그를 되돌린 뒤 정상적으로 반환합니다. 이 글의 close는 그런 판단을 하지 않고 예외를 호출자에게 넘겨, 강제 종료를 더 요청할지는 호출하는 쪽이 정하게 했습니다. 어느 방식이든 인터럽트를 조용히 삼키지만 않으면 됩니다.

close의 반환값만으로는 알 수 없는 것도 있습니다. 첫 번째 기다림에 성공해도 빈 목록이 반환되고, 두 기다림에 모두 실패했지만 큐가 비어 있었어도 빈 목록이 반환됩니다. 앞의 경우는 정상 종료이고 뒤의 경우는 아직 종료되지 않은 상태인데, 호출자는 둘을 구별할 수 없습니다. 연습 문제 2에서 종료 여부도 함께 돌려주도록 고쳐 봅니다.

두 기다림의 시간은 각각 따로 주어지는 예산입니다. 정상 종료 시간은 작업 하나가 보통 걸리는 시간보다 조금 길게 잡고, 두 시간의 합은 배포 환경이 프로세스 종료를 기다려 주는 시간보다 짧게 잡습니다. 회수와 로그 기록에도 시간이 들므로, 실제로 close가 반환하는 시각은 두 예산의 합보다 조금 늦을 수 있습니다. 이 메서드는 나노초로 바꿀 수 있는 크기의 양수 시간을 받는다고 가정합니다.

회수한 작업을 찾고 정리하기

shutdownNow()가 돌려준 목록으로 무엇을 할 수 있는지는 작업을 어떻게 맡겼는지에 따라 다릅니다. execute(작업)으로 맡겼다면 목록에는 맡긴 객체가 그대로 들어 있으므로, 작업 클래스에 ID를 넣어 두면 어떤 작업이 회수됐는지 알 수 있습니다. 연습 문제 1이 이 방식입니다. 반면 submit으로 맡긴 작업은 실행기가 FutureTask로 감싸서 큐에 넣으므로, 목록에서 꺼낸 객체를 원래 작업 클래스로 형변환할 수 없습니다. 이때는 제출할 때 받은 Future와 작업 ID를 따로 기록해 두고 대조합니다.

회수된 FutureTask에는 조심해야 할 점이 하나 더 있습니다. 큐에서 회수됐다고 해서 그 Future가 취소되는 것은 아닙니다. 다음 예제는 스레드 하나짜리 풀에서 오래 걸리는 작업이 실행되는 동안 report 작업을 하나 더 맡기고, 그 상태로 shutdownNow()를 호출합니다.

src/DrainedFutures.java
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public final class DrainedFutures {
    public static void main(String[] args) throws Exception {
        var executor = Executors.newSingleThreadExecutor();
        executor.submit(() -> {
            try {
                Thread.sleep(5_000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        Future<String> queued = executor.submit(() -> "report");
        Thread.sleep(50);

        List<Runnable> drained = executor.shutdownNow();
        System.out.println("drained=" + drained.size());
        System.out.println("terminated=" + executor.awaitTermination(1, TimeUnit.SECONDS));
        System.out.println("queued.isDone=" + queued.isDone());
        try {
            queued.get(200, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            System.out.println("get: still waiting");
        }

        for (Runnable task : drained) {
            if (task instanceof Future<?> future) {
                future.cancel(false);
            }
        }
        try {
            queued.get();
        } catch (CancellationException e) {
            System.out.println("get: cancelled");
        }
    }
}
실행 결과
drained=1
terminated=true
queued.isDone=false
get: still waiting
get: cancelled

report 작업은 큐에서 회수됐고 실행기도 종료됐지만, 그 Future는 끝나지 않은 상태로 남았습니다. 그래서 200ms 제한을 둔 get은 시간이 초과됐고, 제한 없이 get()을 호출했다면 영영 돌아오지 않았을 것입니다. 회수한 목록의 원소를 instanceof로 Future인지 확인해 cancel(false)로 취소하고 나서야, get()이 CancellationException을 던지며 돌아왔습니다. 아직 시작하지 않은 작업이므로 인터럽트할 필요가 없어 false를 넘겼습니다.

따라서 다른 스레드가 이 Future들의 결과를 기다리고 있을 수 있다면, 회수한 작업을 그냥 버리지 말고 취소하거나 나중에 다시 처리할 곳에 기록해야 합니다. 다시 처리한다면 같은 작업이 두 번 실행돼도 문제가 없는지도 확인해야 합니다. 그리고 Future를 취소했다고 해서 그 업무가 처리된 것은 아니라는 점도 기억해 두세요.

try-with-resources로 닫기

Java 19부터 ExecutorService는 AutoCloseable을 구현하므로, try-with-resources와 공통 예외 처리에서 본 try 문으로 닫을 수 있습니다. try 블록을 빠져나갈 때 호출되는 close()는 shutdown()을 호출한 뒤, 모든 작업이 끝나 실행기가 종료될 때까지 기다립니다.

src/TryWithResourcesExecutor.java
import java.util.concurrent.Executors;

public final class TryWithResourcesExecutor {
    public static void main(String[] args) {
        try (var executor = Executors.newFixedThreadPool(2)) {
            for (int i = 1; i <= 3; i++) {
                int id = i;
                executor.execute(() -> {
                    try {
                        Thread.sleep(100L * id);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    System.out.println("task-" + id + " finished");
                });
            }
        }
        System.out.println("all tasks finished");
    }
}
실행 결과
task-1 finished
task-2 finished
task-3 finished
all tasks finished

스레드 둘이 100ms짜리와 200ms짜리 작업을 먼저 실행하고, 300ms짜리 세 번째 작업은 첫 작업이 끝난 100ms 시점에 시작해 400ms에 끝납니다. try 블록을 빠져나올 때 호출된 close()가 이 모든 작업이 끝날 때까지 기다렸으므로 all tasks finished가 마지막에 나왔습니다. 작업이 끝나는 시각이 100ms 이상씩 벌어져 있어서 여러 번 실행해도 순서는 같았습니다.

close()는 편리하지만 기다리는 시간에 제한이 없습니다. 인터럽트에 반응하지 않는 작업이 하나라도 있으면 close()도 돌아오지 않습니다. 기다리는 도중 호출한 스레드가 인터럽트되면 shutdownNow()처럼 실행 중인 작업에 인터럽트를 걸고, 큐의 작업은 실행하지 않은 채 실행 중인 작업이 끝날 때까지 계속 기다립니다. 그리고 반환하기 전에 인터럽트 플래그를 다시 세웁니다. 짧은 프로그램이나 테스트에서는 close()로 충분하고, 종료 시간에 제한이 있는 서버라면 앞의 두 단계 종료가 맞습니다.

스레드 풀의 상태 읽기

종료가 제대로 진행되는지, 평소에 풀이 과부하인지 알려면 풀의 상태를 읽어야 합니다. ThreadPoolExecutor는 이를 위한 조회 메서드를 제공합니다. 다음 예제는 그중 네 가지를 레코드 하나에 담아, 작업을 맡긴 직후와 종료한 뒤에 출력합니다. 풀은 작업자 2개, 큐 용량 4로 만들고 30ms짜리 작업을 네 개 맡깁니다.

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();
        System.out.println("terminated=" + executor.awaitTermination(1, TimeUnit.SECONDS));
        System.out.println("after=" + read(executor));
    }
}
실행 결과(한 예)
during=Snapshot[pool=2, active=2, queued=2, completed=0]
terminated=true
after=Snapshot[pool=0, active=0, queued=0, completed=4]

pool은 지금 풀에 있는 작업자 스레드 수, active는 그중 작업을 실행하고 있는 스레드 수, queued는 큐에서 기다리는 작업 수, completed는 실행을 마친 작업 수입니다. 작업을 맡긴 직후에는 작업자 둘이 모두 작업을 실행 중이고, 나머지 두 작업이 큐에서 기다립니다. 종료가 확인된 뒤에는 네 작업이 모두 끝났고, 실행기가 종료되면서 작업자도 모두 사라져 pool이 0이 됐습니다. awaitTermination의 반환값도 출력했으므로, after 줄이 종료를 확인한 뒤에 읽은 값이라는 것을 알 수 있습니다.

during 줄은 20번 실행해서 모두 같았지만, 언제나 이렇게 나온다고 기대해서는 안 됩니다. main이 작업을 맡긴 뒤 상태를 읽기 전에 30ms 넘게 멈췄다면 작업 일부가 이미 끝나 queued가 줄고 completed가 늘었을 것입니다. 반대로 작업자 스레드가 아직 작업을 시작하기 전이라면 active가 2보다 작게 나올 수도 있습니다.

풀 스냅샷의 네 측정값

풀 스냅샷의 네 측정값

풀 계측 · 네 getter의 측정 범위

풀 스냅샷의 네 측정값

pool·active·queued·completed는 서로 다른 대상을 차례로 읽은 값입니다.

pool

측정 대상
현재 풀의 작업자 수
이 값만으로 알 수 없는 것
모든 작업자가 실행 중인지

active

측정 대상
실행 중인 작업자 수의 근삿값
이 값만으로 알 수 없는 것
개별 작업의 진행률

queued

측정 대상
큐에서 실행을 기다리는 작업 수
이 값만으로 알 수 없는 것
실행 중인 작업의 종료 여부

completed

측정 대상
실행을 마친 작업 수의 근삿값
이 값만으로 알 수 없는 것
업무 성공 여부와 응답 지연

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

이 값들을 읽을 때는 세 가지를 기억해야 합니다. 첫째, getActiveCount()와 getCompletedTaskCount()는 문서에 근삿값(approximate)이라고 적혀 있습니다. 다른 스레드가 계속 상태를 바꾸는 중에 세는 값이기 때문입니다. 둘째, 네 메서드는 차례로 호출되므로 레코드 하나에 담았어도 같은 순간의 값이 아닙니다. 그사이에 작업 하나가 끝나면 네 값을 맞춰 봤을 때 앞뒤가 맞지 않을 수 있습니다. 셋째, completed는 실행을 마친 작업 수일 뿐 성공한 작업 수가 아닙니다. 예외를 던지고 끝난 작업도 여기에 포함되므로, 업무 성공 수는 작업 쪽에서 따로 세야 합니다.

그래서 운영에서는 한 번 읽은 값보다 시간에 따른 변화를 봅니다. 큐 길이가 계속 늘어난다면 작업이 들어오는 속도가 처리 속도보다 빠르다는 뜻입니다. completed를 일정한 간격으로 두 번 읽어 그 차이를 간격으로 나누면 초당 처리량을 어림할 수 있습니다. 여기에 작업 쪽에서 센 성공 수, 실패 수, 거부 수와 응답 시간을 함께 보면 과부하의 원인을 좁힐 수 있습니다.

서버를 종료할 때의 순서

서버 애플리케이션을 종료할 때는 실행기만이 아니라 요청을 받는 쪽과도 순서를 맞춰야 합니다. 먼저 새 요청을 받지 않도록 수신을 멈추고, 그다음 실행기에 shutdown()을 호출합니다. 수신을 열어 둔 채 풀부터 닫으면, 종료하는 동안 들어온 요청이 RejectedExecutionException으로 거부돼 불필요한 오류가 생깁니다. 반대로 수신만 닫고 풀이 끝나기를 기다리지 않은 채 프로세스를 끝내면, 응답을 쓰던 작업이 중간에 잘립니다.

종료 전후에는 앞에서 본 상태 값과 작업 쪽의 기록을 남겨 둡니다. 맡긴 작업 수, 완료한 작업 수, 거부된 작업 수, 회수한 작업 수를 대조해야 종료 과정에서 처리되지 않은 작업이 있었는지 알 수 있습니다. 앞에서 말했듯 완료한 작업 수에는 실패한 작업도 들어 있으므로, 데이터가 빠짐없이 처리됐는지는 작업 결과와 저장된 기록까지 확인해야 판단할 수 있습니다.

종료 상황할 일확인할 것
정상 배포shutdown() 뒤 awaitTermination완료한 작업 수, awaitTermination의 반환값
정상 종료 시간 초과shutdownNow() 뒤 다시 대기회수한 작업 목록, 각 작업의 재처리 여부
두 번째 대기도 시간 초과끝나지 않는 원인 조사스레드 덤프, 인터럽트에 반응하지 않는 작업
평소에 반복되는 과부하풀 상태 값의 추세 확인실행 중인 작업자, 대기 작업, 완료 작업의 변화

더 깊이: ThreadPoolExecutor의 실행 상태

ThreadPoolExecutor는 내부에 RUNNING, SHUTDOWN, STOP, TIDYING, TERMINATED 다섯 가지 실행 상태를 두고, 이 순서에서 뒤쪽으로만 옮겨 가며 되돌아가지 않습니다. shutdown()은 상태를 SHUTDOWN으로 바꿔 새 작업은 거절하되 큐의 작업은 계속 처리하게 하고, shutdownNow()는 STOP으로 바꿔 큐를 비우고 실행 중인 작업자를 인터럽트합니다. 작업자와 큐가 모두 비면 TIDYING을 거쳐 TERMINATED가 되는데, 그 사이에 아무 일도 하지 않는 terminated() 메서드를 호출하므로 하위 클래스에서 재정의해 종료 시점에 할 일을 넣을 수 있습니다. 공개 메서드로 보면 isShutdown()은 RUNNING이 아니면 true, isTerminating()은 종료를 요청했지만 아직 TERMINATED가 아닐 때 true, isTerminated()는 TERMINATED일 때만 true입니다.

정리

  • 고정 크기 풀의 작업자는 데몬 스레드가 아니고 다음 작업을 계속 기다리므로, 풀을 종료하지 않으면 main이 끝나도 프로그램이 끝나지 않습니다.
  • shutdown()은 새 작업을 막을 뿐 기다리지 않습니다. 종료를 확인하려면 awaitTermination의 반환값이나 isTerminated()를 봅니다.
  • shutdownNow()는 큐에서 시작하지 못한 작업을 꺼내 돌려주고 실행 중인 작업에 인터럽트를 걸지만, 역시 기다리지 않습니다. 인터럽트에 반응하지 않는 작업은 끝나지 않을 수 있습니다.
  • 실무에서는 shutdown, 대기, shutdownNow, 대기의 두 단계로 종료하고, 회수한 작업과 종료 여부를 기록합니다. 회수한 FutureTask는 자동으로 취소되지 않습니다.
  • Java 19부터는 try-with-resources로 실행기를 닫을 수 있지만, close()는 제한 없이 기다립니다.
  • 풀의 상태 값은 근삿값이고 같은 순간의 값이 아니며, 완료 수에는 실패한 작업도 포함됩니다. 한 번 읽은 값보다 추세를 봅니다.

연습 문제

1. 회수한 작업의 ID 찾기

ID를 가진 Runnable을 스레드 하나짜리 풀에 세 개 맡기고, shutdownNow()가 돌려준 목록에서 회수된 작업의 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();
        System.out.println("terminated=" + executor.awaitTermination(1, TimeUnit.SECONDS));
        System.out.println(pending.stream().map(task -> ((NamedTask) task).id()).toList());
    }
}
실행 결과
terminated=true
[queued-1, queued-2]

첫 작업 running은 풀이 작업자를 만들면서 곧바로 넘겨주므로 큐를 거치지 않고, 나머지 두 작업이 큐에서 기다립니다. shutdownNow()는 큐의 두 작업을 꺼내 돌려주고 running에 인터럽트를 겁니다. running은 sleep 도중 깨어나 플래그를 되돌리고 끝나므로 awaitTermination이 true를 반환했습니다. 작업을 execute로 맡겼기 때문에 목록의 원소가 NamedTask 그대로여서 형변환으로 ID를 꺼낼 수 있습니다. submit으로 맡겼다면 원소가 FutureTask이므로 이 형변환은 ClassCastException을 일으킵니다.

sleep(20)은 첫 작업이 시작될 시간을 주려는 것이지만, 이 풀에서는 그 시간이 결과를 바꾸지 않습니다. 첫 작업은 큐를 거치지 않고, 큐의 작업은 첫 작업이 5초를 다 채우거나 인터럽트로 끝나기 전에는 시작할 수 없기 때문입니다. 그래서 회수 목록은 언제나 두 ID입니다. 회수한 작업을 실제로 다시 실행하려면, 같은 작업이 두 번 실행돼도 결과가 같은지와 회수 목록을 어디에 안전하게 저장할지를 함께 정해야 합니다.

2. 종료 여부도 함께 돌려주기

GracefulExecutorShutdown.close는 정상 종료했을 때와 두 번째 기다림까지 실패했을 때 모두 빈 목록을 돌려줄 수 있어서, 호출자가 두 경우를 구별하지 못합니다. 종료 여부와 회수한 작업 목록을 함께 돌려주도록 고치고, 인터럽트를 확인하지 않고 1초 동안 계산만 하는 작업으로 시험해 보세요. 두 기다림에는 100ms씩 줍니다.

정답과 해설
exercise/ShutdownWithStatus.java
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public final class ShutdownWithStatus {
    record ShutdownResult(boolean terminated, List<Runnable> neverStarted) {}

    static ShutdownResult close(ExecutorService executor, Duration graceful,
                                Duration forced) throws InterruptedException {
        executor.shutdown();
        if (executor.awaitTermination(graceful.toNanos(), TimeUnit.NANOSECONDS)) {
            return new ShutdownResult(true, List.of());
        }
        List<Runnable> neverStarted = executor.shutdownNow();
        boolean terminated = executor.awaitTermination(forced.toNanos(), TimeUnit.NANOSECONDS);
        return new ShutdownResult(terminated, List.copyOf(neverStarted));
    }

    public static void main(String[] args) throws InterruptedException {
        var executor = Executors.newSingleThreadExecutor();
        executor.execute(() -> {
            long end = System.nanoTime() + 1_000_000_000L;
            while (System.nanoTime() < end) {
                // 인터럽트를 확인하지 않고 1초 동안 계산한다
            }
        });
        ShutdownResult result = close(executor, Duration.ofMillis(100), Duration.ofMillis(100));
        System.out.println("terminated=" + result.terminated()
                + ", neverStarted=" + result.neverStarted().size());
    }
}
실행 결과
terminated=false, neverStarted=0

작업이 인터럽트를 확인하지 않고 1초 동안 계산하므로 shutdownNow()의 인터럽트에도 멈추지 않고, 두 기다림이 모두 실패합니다. 큐에서 기다리던 작업은 없었으므로 회수 목록은 비어 있습니다. 원래 close라면 이 상황에서도 빈 목록만 돌려줘 정상 종료와 구별되지 않았겠지만, 이제는 terminated=false를 보고 아직 종료되지 않았다는 것을 호출자가 알 수 있습니다. 이 프로그램은 결과를 출력한 뒤에도 작업이 1초를 다 채울 때까지 끝나지 않습니다.

3. 완료 수로 처리량 어림하기

같은 실행기에서 getCompletedTaskCount()를 3초 간격으로 읽었더니 500과 518이 나왔습니다. 그사이 실행기를 바꾸지는 않았습니다. 이 두 값으로 계산할 수 있는 것과 알 수 없는 것을 설명하세요.

정답과 해설

3초 동안 518 − 500 = 18개의 작업이 실행을 마쳤으므로, 평균 처리량은 18 ÷ 3 = 초당 6개 정도로 어림할 수 있습니다. 하지만 두 값은 근삿값이고, 18개가 모두 성공했다고 볼 수도 없습니다. 예외를 던지고 끝난 작업도 완료로 세기 때문입니다.

또 이 두 값만으로는 작업마다 걸린 시간, 거부된 작업 수, 그사이 새로 들어온 작업 수를 알 수 없습니다. 그런 값은 작업 쪽에서 직접 세거나, 큐 길이 같은 다른 상태 값과 함께 봐야 합니다.

다음 글

ThreadPoolExecutor의 스레드 수와 작업 큐에서는 ThreadPoolExecutor가 작업을 받을 때 작업자를 새로 만들지, 큐에 넣을지, 거부할지를 어떤 순서로 정하는지 살펴봅니다. 이 글에서 읽은 pool과 queued 값이 그 과정에서 어떻게 바뀌는지도 확인합니다.