ExecutorService graceful shutdown의 정석은?

최근 저는 Armeria의 서버가 종료할 때 이에 맞춰 백그라운드 작업을 처리하는 ExecutorService를 graceful하게 종료하는 로직을 개발하고 있습니다. (graceful 단어의 번역이 애매해서 그냥 쓰겠습니다.) 개발하면서 가장 고민되는 부분은 ExecutorService를 사용하는 방식이 다양할텐데 모든 상황에 적용할 수 있는 graceful한 종료가 구체적으로 무엇인지 알아내는 일이었습니다. 그 과정에서 배운 것들을 글로 정리해봤습니다. 결론이 궁금하신 분은 바로 마지막 절로 가셔도 됩니다!

shutdown vs shutdownNow

구글에 ExecutorService를 graceful하게 종료하는 방법에 대해 검색하면 미묘하게 답변이 다르지만 공통적으로 눈에 띄는 메소드가 있습니다. 바로 shutdownshutdownNow 메소드입니다. 검색을 통해 나오는 정보가 맞는지 판단하기 위해 먼저 두 가지 메소드가 정확히 어떻게 다른지 알아봤습니다.

먼저 shutdown의 javadoc을 보면 다음과 같이 쓰여있습니다. Initiates an orderly shutdown in which previously submitted tasks are executed, but no new tasks will be accepted. 간단히 정리하면 이미 제출(submit)한 작업이 실행 완료된 후 종료를 진행하며, 더 이상 새로운 작업을 추가하지 않는다고 쓰여있습니다. 설명을 통해 우리가 원하는 graceful shutdown에 가까운 기능을 가진 것 같습니다. 이미 제출되어 실행 중이거나 실행 대기 중인 작업을 억지로 종료시키지 않고 자연스럽게 종료할 수 있습니다.

하지만 제출된 작업이 종료하기까지 너무 오래걸린다면 어떻게 할까요? 뒤에 javadoc에는 추가로 이렇게 쓰여있습니다. This method does not wait for previously submitted tasks to complete execution. Use awaitTermination to do that. shutdown은 미리 제출된 작업의 종료를 기다리지 않기 때문에 awaitTermination 메소드를 이용해 현재 쓰레드를 블록하여 기다릴 수 있습니다. 이어서 awaitTermination 메소드의 시그니처를 살펴봅시다.

boolean awaitTermination(long timeout, TimeUnit unit) ⇒ 메소드 시그니처를 보면 타임아웃 안에 shutdown이 완료되면 true를 반환하는 메소드임을 짐작할 수 있습니다. 따라서 이미 제출된 작업을 한없이 기다릴 필요는 없겠네요. 그런데 만약 타임아웃 안에 끝나지 않아 강제로 종료시키기로 마음먹었다면 어떻게 할까요? 그럴 때 바로 shutdownNow를 사용합니다.

shutdownNow의 javadoc도 살펴보면 다음과 같습니다. Attempts to stop all actively executing tasks, halts the processing of waiting tasks, and returns a list of the tasks that were awaiting execution. 현재 실행 중인 작업을 종료하고 대기 중인 작업들은 취소(halt)한 후 반환한다고 쓰여있습니다. 그럼 이 메소드를 사용하면 바로 강제 종료에 성공하는 걸까요? javadoc을 더 읽어보면 이런 말이 있습니다. There are no guarantees beyond best-effort attempts to stop processing actively executing tasks. For example, typical implementations will cancel via Thread#interrupt, so any task that fails to respond to interrupts may never terminate. 정리하면 강제로 종료시키기 위해 노력은 하지만 반드시 성공한다고 보장할 수 없다. 예를 들어 Thread#interrupt를 이용해 강제 종료를 구현한 경우 실행 중인 작업이 인터럽트에 대응하도록 설계하지 않았다면 종료되지 않을 수 있다고 말합니다. 무슨 뜻일까요? 일단 반드시 강제 종료되지 않을 수 있다는 것은 이해할 수 있었습니다. 그런데 Thread#interrupt를 통해 종료되지 않는다니 저는 인터럽트가 쓰레드 실행을 무조건 막는다고 생각했는데 여기서 좀 더 정보가 필요했습니다.

인터럽트에 어떻게 대응할까?

자바에서 어떤 Thread가 인터럽트 신호를 받으면 어떤 일이 일어날까요? 정말 친절하게도 Thread#interrupt 메소드의 javadoc에 잘 설명되어있습니다. 좀 길어서 우리가 관심있는 부분만 발췌하였습니다.

  1. 만약 쓰레드가 Object 클래스의 wait(), wait(long), wait(long, int) 메소드 또는 Thread 클래스의 join(), join(long), join(long, int), sleep(long), sleep(long, int) 메소드를 호출하여 블록된 경우 해당 쓰레드의 인터럽트 상태가 초기화되고 InterruptedException이 발생합니다.
  2. 다른 조건 생략
  3. 위 조건 중 어느 하나에도 해당되지 않는 경우 쓰레드의 인터럽트 상태가 설정됩니다.
  • 참고: 쓰레드는 자신의 인터럽트 상태(interrupt status)를 보고 인터럽트되었는지 판단합니다.

1번 조건이 좀 긴데 쉽게 말해 쓰레드를 블록하여 뭔가를 기다리는 메소드 실행 중 인터럽트되면 InterruptedException을 발생시키고 인터럽트 상태를 초기화한다는 의미입니다. 만약 1번에 해당하는 메소드를 사용하지 않는다면 3번에 의거하여 쓰레드의 인터럽트 상태가 설정되어 자신이 인터럽트되었다는 사실을 알 수 있습니다. (다른 조건에 해당하지 않는다고 가정합니다.)

따라서 다음과 같은 무한 루프 while 문을 실행하는 작업(Runnable)은 인터럽트로 종료시킬 수 없습니다.

인터럽트 상태가 설정되어도 아무런 대응을 하지 않기 때문에 무한 루프 작업이 계속됩니다. 인터럽트를 통해 작업을 종료하려면 다음과 같이 코드를 변경해야 합니다.

Thread#isInterrupted 메소드를 이용해 현재 인터럽스 상태를 확인하고 인터럽트 된 경우 while 문을 빠져나와 작업을 종료합니다. 그럼 1번 조건에 해당하는 메소드를 사용한다면 어떻게 될까요? 예를 들어 Thread#sleep도 현재 쓰레드를 일정 시간 블록하기 때문에 1번 조건에 해당하는 메소드인데요. 이 경우 다음과 같이 코드를 수정해야 합니다.

1번 조건을 다시 읽어보면 쓰레드를 블록하는 도중 인터럽트가 발생하면 InterruptedException을 발생시키고 인터럽트 상태를 “초기화”하므로 다시 Thread#interrupt 메소드를 사용해 인터럽트 상태를 설정해 while 문을 빠져나올 수 있게 만듭니다.

이처럼 현재 작업에서 쓰레드를 블록하는 메소드를 쓰냐 안쓰냐에 따라 다양한 대응이 필요합니다. 위에서 생략했지만 다른 조건들도 고려하면 다양한 상황에서 인터럽트를 처리하는 코드를 작성해야 작업이 잘 종료될 수 있습니다.

이 점을 이해하면 이제 shutdownNow가 왜 종료를 보장할 수 없는지 알 수 있습니다. while (true) { ... }를 사용하는 작업처럼 인터럽트에 올바른 대응이 없으면 ExecutorService는 실행 중인 작업을 강제로 종료할 수 없습니다.

그래서 graceful shutdown의 정석은 뭔데?

구글에 검색하고 열심히 javadoc을 읽다가 우연히 ExecutorService 인터페이스의 javadoc에서 graceful shutdown 예시 코드를 발견했습니다. 검색해서 나온 정보들도 유용했지만 직접 자바 개발자가 쓴 코드만큼 신빙성 있는 정보도 없을 것입니다.

  1. 먼저 shutdown을 시도합니다. 위에서 설명한 대로 추가적인 작업 제출을 막고 이미 제출되어 실행 중인 작업과 대기 중인 작업이 끝나길 기다립니다.
  2. awaitTermination으로 앞에서 실행한 shutdown 과정의 종료를 기다립니다. 예시에서는 60초를 기다리지만 상황에 맞게 설정하면 됩니다.
  3. 만약 설정한 타임아웃안에 종료가 일어나지 않은 경우 shutdownNow를 호출해 강제 종료를 시도합니다. ExecutorService 구현체에 따라 그 방법이 다르겠지만 일반적으로 Thread.interrupt를 사용할 것입니다.
  4. 2번 과정과 마찬가지로 shutdownNow 이후 작업들이 종료되길 기다립니다.
  5. 이전 절에서 언급한대로 shutdownNow는 종료를 보장하지 않기 때문에 타임아웃 안에 종료가 되지 않은 경우 콘솔에 에러 메시지를 출력해 사용자에게 알립니다.
  6. awaitTermination 메소드 역시 쓰레드를 블록하여 기다리기 때문에 외부에서 현재 쓰레드를 인터럽트하는 경우 InterruptedException이 발생하고 인터럽트 상태를 초기화합니다. (이전 절에서 설명한 1번 조건)
  7. 외부에서 현재 쓰레드를 인터럽트 한 경우 강제 종료 신호로 받아들여 shutdownNow를 호출합니다. InterruptedException에 대응하는 방식은 요구사항에 따라 달라질 수 있습니다.
  8. 인터럽스 상태를 다시 설정해 해당 쓰레드에서 인터럽트에 대응할 수 있게 만듭니다. 만약 추가적인 인터럽트 대응 로직이 있다면 이 상태를 감지하고 작동할 것입니다.

정리하면 shutdownawaitTerminationshutdownNowawaitTermination → 사용자에게 종료 실패 알림의 흐름이 됩니다. 추가로 awaitTermination 도중 외부에서 들어오는 인터럽트에 대응하는 로직인 try-catch 문도 구현했습니다. javadoc에 쓰여있는 코드지만 “정석”은 아니고 이 단어는 제 입에 감겨서 쓴 표현입니다. 각자 개발하는 환경이나 요구사항에 맞춰 1~8 단계에서 대응이나 타임아웃이 달라질 것입니다.

결론 - javadoc을 잘 읽자

처음 Armeria 이슈를 해결하기 위해 graceful shutdown 과정을 검색했을 때 나온 결과를 보고 막막한 기분이 들었습니다. 나름 학교다닐 때 OS 공부를 열심히 해서 개념을 잘 알고 있다고 생각했는데 모르는 코드가 나오니 당황하게 되더군요. 다행히 메소드를 하나씩 살펴볼 때 같이 붙어있는 javadoc을 천천히 읽다보니 많은 정보를 알게되었고 최종적인 graceful shutdown 코드도 완전히 이해할 수 있었습니다. 항상 검색에만 의존했는데 IDE에서 쉽게 볼 수 있는 javadoc으로 거의 모든 해답을 얻는 경험은 상당히 새로웠습니다.

과거 스칼라 개발할 때 가장 열심히 썼던 ZIO 역시 내부적으로 많은 동시성 처리 로직이 있었습니다. 하지만 당시에는 함수형 프로그래밍에 더 초점을 맞췄던지라 그 분야에 관심이 없었는데요. (아마 ZIO에서 세부사항을 잘 추상화하여 API를 제공했기 때문인 것 같습니다.) 최근 Armeria에 관심을 갖고 개발하다보니 하나씩 자바에서 동시성을 다루는 방법에 대해 알아가고 있습니다. 유명한 책인 Java Concurrency in Practice도 읽는 중인데 언제 다 읽을지 모르겠네요. 😂 이 글을 읽으시는 분들도 동시성 처리에서 겪었던 이슈나 배움을 공유해주시면 자바 커뮤니티에 정말 많은 도움이 될 것 같습니다. 감사합니다~!

comments powered by Disqus

Related