ExecutorService 우아한 종료

우아한 종료란?

서버 애플리케이션을 운영하다 보면 업데이트나 유지보수를 위해 서버를 재시작해야 하는 상황이 발생한다. 이 때 처리 중인 작업을 중단 없이 완료한 후 종료하는 것이 중요하다

문제 상황 예시

  • 고객 주문 처리 중 서버 재시작 → 주문 데이터 손실
  • 진행 중인 트랜잭션 중단 → 데이터 불일치

이상적인 종료 프로세스

  • 새로운 요청 차단
  • 진행 중인 작업 완료
  • 대기 중인 작업 완료
  • 안전한 종료

이러한 종료 방식을 우아한 종료 (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() + 추가 대기
  • 실패 로깅: 개발자가 문제 인지할 수 있도록
  • 인터럽트 처리: 예외 상황 대비

서비스 종료는 생각보다 복잡하며, 다단계 전략이 필요하다. 우아한 종료를 우선하되, 무한 대기를 방지하고 최악의 경우를 대비한 안전장치를 마련해야 한다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 1편, 멀티스레드와 동시성