우아한 종료란?
서버 애플리케이션을 운영하다 보면 업데이트나 유지보수를 위해 서버를 재시작해야 하는 상황이 발생한다. 이 때 처리 중인 작업을 중단 없이 완료한 후 종료하는 것이 중요하다
문제 상황 예시
- 고객 주문 처리 중 서버 재시작 → 주문 데이터 손실
- 진행 중인 트랜잭션 중단 → 데이터 불일치
이상적인 종료 프로세스
- 새로운 요청 차단
- 진행 중인 작업 완료
- 대기 중인 작업 완료
- 안전한 종료
이러한 종료 방식을 우아한 종료 (Graceful Shutdown)라고 한다
ExecutorService 종료 메서드
ExecutorService는 다양한 종료 메서드를 제공한다
shutdown()
void shutdown()
동작 방식
- 새로운 작업 제출 거부 (
RejectedExecutionException발생) - 실행 중인 작업 완료 대기
- 큐에 대기 중인 작업 모두 실행
- 논블로킹 메서드 (호출 스레드는 즉시 반환)
shutdownNow()
List<Runnable> shutdownNow()
동작 방식
- 새로운 작업 제출 거부
- 실행 중인 작업에 인터럽트 발생
- 큐의 대기 작업을
List<Runnable>로 반환 (실행하지 않음) - 논블로킹 메서드
awaitTermination()
boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException
동작 방식
- 모든 작업이 완료될 때까지 대기
- 지정된 시간까지만 대기
- 블로킹 메서드
- 반환값: 시간 내 종료 완료 시
true, 타임아웃 시false
상태 확인 메서드
boolean isShutdown() // 종료 명령 호출 여부 boolean isTerminated() // 모든 작업 완료 여부
close() (Java 19+)
void close()
동작 방식
shutdown()호출- 작업 완료 또는 인터럽트 발생까지 무한 대기
- 호출 스레드에 인터럽트 발생 시
shutdownNow()호출
종료 시나리오
처리 중인 작업이 없는 경우
shutdown() 호출 → 새 요청 거부 → 스레드 풀 정리 → 종료
처리 중인 작업이 있는 경우
shutdown() 호출 → 새 요청 거부 → 실행 중인 작업 완료 (taskA, taskB), → 큐의 대기 작업 완료 (taskC, taskD) → 스레드 풀 정리 → 종료
shutdownNow() 호출
shutdownNow()호출 → 새 요청 거부 → 큐의 작업 반환 (taskC, taskD) → 실행 중인 작업에 인터럽트 (taskA, taskB) → 스레드 풀 정리 → 종료
우아한 종료 구현
실무에서는 무한정 대기할 수 없으므로 타임아웃을 설정한다
구현 전략
shutdown()으로 우아한 종료 시도- 일정 시간(예: 60초) 대기
- 타임아웃 시
shutdownNow()로 강제 종료 - 강제 종료 후에도 일정 시간 대기
- 최종 실패 시 로그 기록
public class ExecutorShutdownMain {
public static void main(String[] args) {
ExecutorService es = Executors.newFixedThreadPool(2);
es.execute(new RunnableTask("taskA"));
es.execute(new RunnableTask("taskB"));
es.execute(new RunnableTask("taskC"));
es.execute(new RunnableTask("longTask", 100_000)); // 100초 대기
log("== shutdown 시작 ==");
shutdownAndAwaitTermination(es);
log("== shutdown 완료 ==");
}
static void shutdownAndAwaitTermination(ExecutorService es) {
// 1단계: 우아한 종료 시도
es.shutdown();
try {
log("서비스 정상 종료 시도");
// 2단계: 10초간 대기
if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
// 3단계: 정상 종료 실패 → 강제 종료
log("서비스 정상 종료 실패 -> 강제 종료 시도");
es.shutdownNow();
// 4단계: 강제 종료 후 추가 대기
if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
log("서비스가 종료되지 않았습니다.");
}
}
} catch (InterruptedException ex) {
// awaitTermination() 대기 중 인터럽트 발생
es.shutdownNow();
}
}
}
실행 결과 분석
11:03:58.883 [pool=2, active=2, queuedTasks=2, completedTasks=0] 11:03:58.883 == shutdown 시작 == 11:03:58.983 서비스 정상 종료 시도 // taskA, taskB, taskC 완료 (각 1초) 11:03:59.913 taskB 완료 11:03:59.913 taskA 완료 11:03:59.923 taskC 완료 // longTask 진행 중 (100초 소요) 11:03:59.923 longTask 시작 // 10초 타임아웃 → 강제 종료 11:04:09.923 서비스 정상 종료 실패 -> 강제 종료 시도 11:04:09.924 인터럽트 발생, sleep interrupted 11:04:09.925 == shutdown 완료 ==
중요 포인트
강제 종료 후 추가 대기가 필요한 이유
shutdownNow() 호출 후에도 추가 대기가 필요한 이유
- 인터럽트 처리 시간: 인터럽트 발생 후 예외 처리 및 자원 정리 시간 필요
- finally 블록 실행: 리소스 해제, 로그 기록 등
- 인터럽트 불가능 코드: 극단적인 경우 대비
// 인터럽트를 받을 수 없는 코드 예시
while (true) {
// 인터럽트 체크 지점이 없음
// Thread.sleep(), wait() 등이 없으면 인터럽트 불가
}
이런 스레드는 Java 프로세스를 강제 종료해야만 제거 가능하다
예외 처리
catch (InterruptedException ex) {
// awaitTermination() 대기 중 현재 스레드가 인터럽트된 경우
es.shutdownNow(); // 즉시 강제 종료
}
대기 중인 메인 스레드가 인터럽트되면 즉시 강제 종료로 전환한다
정리
ExecutorService 종료 시 고려사항
- 기본은 우아한 종료:
shutdown()+awaitTermination() - 타임아웃 설정 필수: 무한 대기 방지
- 강제 종료 대비: shutdownNow() + 추가 대기
- 실패 로깅: 개발자가 문제 인지할 수 있도록
- 인터럽트 처리: 예외 상황 대비
서비스 종료는 생각보다 복잡하며, 다단계 전략이 필요하다. 우아한 종료를 우선하되, 무한 대기를 방지하고 최악의 경우를 대비한 안전장치를 마련해야 한다