ExecutorService 정상 종료
ExecutorService의 단계적 종료와 남은 작업 회수를 익히고 스레드 풀 지표를 운영 수명주기에 연결합니다.
shutdown()은 종료 완료가 아니라 새 작업 접수를 막는 시작 신호입니다.
이미 실행 중인 작업과 큐에 들어온 작업은 계속 처리됩니다.
애플리케이션은 제한 시간 동안 종료를 기다리고, 넘기면 shutdownNow()로 인터럽트를 요청하며 반환된 미시작 작업을 기록해야 합니다.
실행기 상태는 isShutdown, isTerminated를 구별합니다.
운영 중에는 활성 작업자 수, 큐 크기, 완료 수의 추세와 지연 지표를 함께 보아 과부하 원인을 좁힙니다.
shutdown 직후의 잘못된 완료 판정
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에서 자체 자원을 정리한다.
재사용 가능한 단계적 종료 유틸리티
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());
}
}종료 유틸리티의 반환과 종료 확인
shutdown 뒤 첫 await·shutdownNow·둘째 await·반환의 정리 과정입니다. 회수 목록 반환과 실제 종료 확인의 다른 상태를 단계로 보여줍니다.
종료 유틸리티의 반환과 종료 확인
실행기 종료 · 반환과 확인의 차이
종료 유틸리티의 반환과 종료 확인
회수한 목록을 반환해도 두 번째 await가 false면 종료를 확인하지 못한 상태입니다.
두 번의 대기와 반환 목록
shutdown 뒤 첫 await true는 빈 목록으로 종료 확인한다. 첫 false만 shutdownNow와 두 번째 await로 진행하며, 둘째 false에도 회수 목록은 반환된다. 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아니다. 중단 요청 자체는 worker 완료 확인이 아니다.
대기 중 호출자 인터럽트
어느 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에서 배출 여부를 기다립니다. |
|
|
| 강제 요청과 각 대기 결과 첫 false인 경로만 미시작 목록을 회수하고 두 번째 예산으로 다시 기다립니다. |
|
|
| 반환·예외와 확인 범위 정상 목록 반환과 실행기 종료 확인을 구분해 각 출구를 읽습니다. |
|
|
종료 유틸리티의 반환과 종료 확인
shutdown 뒤 첫 await·shutdownNow·둘째 await·반환의 정리 과정입니다. 회수 목록 반환과 실제 종료 확인의 다른 상태를 단계로 보여줍니다.
종료 유틸리티의 반환과 종료 확인
실행기 종료 · 반환과 확인의 차이
종료 유틸리티의 반환과 종료 확인
회수한 목록을 반환해도 두 번째 await가 false면 종료를 확인하지 못한 상태입니다.
두 번의 대기와 반환 목록
shutdown 뒤 첫 await true는 빈 목록으로 종료 확인한다. 첫 false만 shutdownNow와 두 번째 await로 진행하며, 둘째 false에도 회수 목록은 반환된다. 회수 목록은 미시작 큐 작업이며 실행 중 작업 목록이 아니다. 중단 요청 자체는 worker 완료 확인이 아니다.
대기 중 호출자 인터럽트
어느 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에서 배출 여부를 기다립니다. |
|
|
| 강제 요청과 각 대기 결과 첫 false인 경로만 미시작 목록을 회수하고 두 번째 예산으로 다시 기다립니다. |
|
|
| 반환·예외와 확인 범위 정상 목록 반환과 실행기 종료 확인을 구분해 각 출구를 읽습니다. |
|
|
호출자가 대기 중 인터럽트되면 예외를 전파하며, 추가 shutdownNow() 요청이나 남은 작업 회수는 상위 수명주기 정책에서 결정해야 합니다.
강제 종료도 종료 완료를 보장하지 않습니다. shutdownNow()는 대기 큐의 작업을 회수하고 실행 중 작업의 중단을 시도합니다. 이때 반환 목록은 현재 스레드가 실행 중인 작업의 목록이 아닙니다. execute(new NamedTask(...))와 달리 submit은 보통 작업을 FutureTask로 감싸므로 반환 요소를 원래 작업 타입으로 형변환해 ID를 얻을 수 없습니다. 작업 ID와 제출 시 받은 Future를 별도 기록하는 이유입니다. 이 유틸리티는 유효하고 나노초로 변환 가능한 대기 시간을 전제로 하며, 두 번의 대기 시간은 각각의 예산입니다.
풀 지표 스냅샷
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));
}
}풀 스냅샷의 네 측정값
풀 계측 · 네 getter의 측정 범위
풀 스냅샷의 네 측정값
pool·active·queued·completed는 서로 다른 대상을 차례로 읽은 값입니다.
pool
- 측정 대상
- 현재 풀의 작업자 수
- 이 값만으로 알 수 없는 것
- 모든 작업자가 실행 중인지
active
- 측정 대상
- 실행 중인 작업자 수의 근삿값
- 이 값만으로 알 수 없는 것
- 개별 작업의 진행률
queued
- 측정 대상
- 큐에서 실행을 기다리는 작업 수
- 이 값만으로 알 수 없는 것
- 실행 중인 작업의 종료 여부
completed
- 측정 대상
- 실행을 마친 작업 수의 근삿값
- 이 값만으로 알 수 없는 것
- 업무 성공 여부와 응답 지연
네 getter를 차례로 호출하므로 한 시점의 원자적 스냅샷은 아닙니다. 예제에는 제출·거부 횟수나 지연 시간 측정이 없습니다.
awaitTermination이 true를 반환해 종료가 확인되었다면 이 예제의 완료 작업 수는 4입니다. 원문은 반환값을 버리므로 after라는 출력 이름만으로 종료를 확정할 수 없습니다.
단일 스냅샷보다 시간에 따른 큐 증가와 완료율을 감시합니다.
getActiveCount()와 getCompletedTaskCount()는 근삿값이고, 네 getter는 순서대로 호출되므로 함께 묶었다고 한 시점의 원자적 측정이 되지는 않습니다. completed는 실행이 끝난 작업 수이며 업무 성공 수와 다릅니다. 따라서 큐 감소와 완료 수 증가를 확인하더라도 성공 응답·거부·재시도는 별도의 계측과 대조해야 합니다.
서비스 종료와 지표 관측 결정
| 종료 상황 | 조치 | 남길 증거 |
|---|---|---|
| 정상 배포 | shutdown 대기 | 완료 수 |
| 마감 초과 | shutdownNow | 미시작 목록 |
| 두 번째 대기 시간 초과 | 미종료 원인 조사 | 스레드 덤프 |
| 반복 과부하 | 풀 지표 추세 | 활성 작업자·대기 작업·완료 작업 |
종료 신호와 프로세스 종료 연결
서버 종료 훅은 새 요청 수신을 먼저 막고, 실행기에는 더 이상 작업이 들어오지 않는 상태를 만든 뒤 shutdown()을 호출해야 합니다.
수신기가 열린 채로 풀부터 닫으면 종료 과정에서 들어온 요청이 거부되어 불필요한 오류가 늘어납니다.
반대로 수신만 닫고 풀을 기다리지 않으면 응답을 쓰던 작업이 중간에 잘릴 수 있습니다.
대기 시간은 작업의 정상 상한보다 약간 길게 잡되 배포 플랫폼의 전체 종료 제한보다 짧아야 합니다.
관측값은 종료 전후로 한 번씩 저장합니다.
제출 수, 완료 수, 거부 수, 큐 잔량은 배출 상태를 해석하는 근거입니다. 완료 수에는 업무 실패도 포함될 수 있으므로 데이터 보존 여부는 작업 결과와 내구 기록까지 대조합니다.
단순히 isShutdown()이 참이라는 사실은 실행 중 작업이 끝났다는 뜻이 아니며, 완전한 종료 판단은 isTerminated() 또는 awaitTermination() 결과로 해야 합니다.
연습 문제
이름 있는 Runnable을 큐에 넣고 강제 종료가 반환한 목록에서 작업 ID를 추출하세요.
실행 중 작업은 인터럽트에 반응해야 합니다.
정답과 해설
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가 나온다고 단정할 수 없습니다. 또한 대기 반환값을 확인하지 않았으므로 목록 출력 자체는 실행기 종료 확인이 아닙니다.
실제 재처리는 멱등성과 내구 저장을 추가로 검토해야 합니다.
확인 문제
GracefulExecutorShutdown.close에서 첫 await가 false, shutdownNow의 반환 목록이 비어 있고 두 번째 await도 false였다고 가정하세요. close의 반환값은 무엇인가요? 빈 목록을 받았다는 이유로 정상 종료를 보고해도 되는지, 첫 await가 true인 경우와 비교하세요.
확인 문제 해설
오류를 기록한 뒤 빈 목록을 반환합니다. 그 목록은 “큐에서 회수한 미시작 작업이 없음”만 나타내며, 실행 중 작업의 종료는 두 번째 await에서도 확인하지 못했습니다. 첫 await가 true인 경우에도 반환값은 빈 목록이지만 그 경우에는 정상 배출이 확인되었습니다. 같은 반환값만으로 두 경로를 구별할 수 없으므로 호출자가 필요한 종료 상태는 별도 결과나 기록으로 전달해야 합니다. 두 번째 false만으로 원인을 인터럽트 무시라고 단정할 수도 없습니다.
실행기 종료 확인 신호
종료는 메서드 한 번이 아니라 접수 중단, 배출 대기, 강제 취소, 미시작 회수의 상태 전이입니다.
작업 인터럽트 협력과 풀 지표를 함께 준비해야 배포 시 데이터 손실을 판단할 수 있습니다.
적용: 회수한 Future와 종료 예산·완료율 해석
조건 1. 기본 ThreadPoolExecutor 작업자 하나가 래치에서 대기 중임을 확인했습니다. 이어 submit으로 Q1·Q2를 제출했고 두 작업은 큐에 있습니다. 이 두 Future의 취소는 아직 요청하지 않았습니다. shutdownNow()가 두 미시작 작업을 반환하고 실행 중 작업은 인터럽트에 협력해 종료했습니다. 큐에서 회수한 두 Future도 자동으로 취소됐다고 판단해도 될까요?
풀이. 안 됩니다. 이 조건의 기본 실행기는 Q1·Q2를 감싼 FutureTask를 회수하지만 회수 자체가 그 Future의 cancel 호출은 아닙니다. 실행기가 종료돼도 두 Future가 미완료로 남을 수 있어 무제한 get()은 계속 기다립니다. 애플리케이션은 회수한 작업의 ID·원본 입력·Future 기록을 대조하고, 더 이상 이 실행에서 수행하지 않을 Future의 취소와 재처리 정책을 명시해야 합니다. Future를 취소해도 큐에 있던 업무가 성공했다는 뜻은 아닙니다. 이 예의 NamedTask 캐스트 해법을 submit에 그대로 적용하면 안 됩니다.
조건 2. 수신 중단은 이미 완료됐고, 정상 대기에 120ms, 강제 요청 뒤 대기에 80ms를 배정했습니다. 두 대기가 모두 false이고 회수 목록은 비어 있습니다. 이 사실로 정확히 200ms에 프로세스가 끝났다고 보고해도 될까요?
풀이. 안 됩니다. 합계 200ms는 두 대기의 명목 예산입니다. 종료 요청·회수·로그 등의 시간이 추가될 수 있고 두 번째 false는 실행기 종료가 확인되지 않았다는 뜻입니다. 빈 목록은 미시작 작업을 회수하지 않았음을 뜻할 뿐입니다. 플랫폼 제한과 맞추려면 수신 중단·후처리 예산도 함께 확보하고 실제 종료 상태를 별도 관측해야 합니다.
조건 3. 같은 실행기에서 완료 작업 수를 3초 간격으로 읽어 500과 518을 얻었습니다. 중간에 실행기를 교체하거나 카운터를 초기화하지 않았습니다. 이 측정으로 계산하는 관측 완료율과 알 수 없는 것을 설명하세요.
풀이. 관측 증가량은 518 - 500 = 18, 평균 완료율 추정은 18 / 3 = 6작업/초입니다. getter가 근사 계측이라는 한계가 있고 이 수치로 성공률·개별 지연·거부 수를 알 수는 없습니다. 완료 수에는 업무 실패도 포함될 수 있으므로 18건 성공이라고 보고하면 안 됩니다. 한 순간의 active·queued와 합산해 정확한 전체 제출 수를 복원할 수도 없습니다.