파이썬 ThreadPoolExecutor 종료 안 될 때: Ctrl+C와 graceful shutdown 점검법

Python ThreadPoolExecutor 작업이 끝나지 않거나 Ctrl+C로 종료되지 않을 때는 작업 함수의 종료 조건, Future 대기, shutdown 옵션을 차례로 확인해야 안전하게 원인을 좁힐 수 있습니다. 특히 shutdown(wait=False)만 호출한다고 이미 실행 중인 작업이 즉시 멈추지는 않으며, 작업 함수가 종료 신호를 협력적으로 확인하도록 만드는 것이 핵심입니다.

장기 실행 fetcher나 반복 작업을 스레드 풀에 넣으면 화면에는 Ctrl+C가 먹지 않는 것처럼 보일 수 있습니다. 이때 daemon 스레드 설정이나 강제 프로세스 종료부터 시도하기보다, 메인 스레드가 받은 SIGINT와 작업자 스레드가 실제로 멈추는 조건을 분리해서 보시는 편이 좋습니다.

Ctrl+C가 바로 종료를 보장하지 않는 이유

Python의 기본 SIGINT 처리는 KeyboardInterrupt로 바뀌며 Python 신호 처리기는 메인 스레드에서 실행됩니다. 따라서 Ctrl+C가 들어왔다는 사실만으로 작업자 스레드의 while 루프나 네트워크 대기가 자동으로 끝나는 것은 아닙니다.

운영 관점에서는 다음 세 가지를 따로 확인합니다.

  1. 메인 스레드가 KeyboardInterrupt를 받았는가
  2. 작업 함수가 종료 신호를 확인하는가
  3. 메인 스레드가 Future 결과를 무기한 기다리고 있지 않은가

신호 처리기에서 공유 상태를 바꾸고, 작업 함수는 그 상태를 확인하는 구조가 기본입니다. 스레드 간 종료 신호에는 threading.Event가 알맞습니다.

작업 함수에 협력적 종료 조건 넣기

Event는 여러 스레드가 공유하는 플래그입니다. 메인 스레드가 set()을 호출하면 작업자는 is_set()으로 종료 요청을 확인할 수 있습니다. 단순히 sleep()을 길게 두면 종료 요청을 확인하는 시점도 늦어지므로, 반복 대기에는 Event.wait(timeout)을 사용하는 편이 낫습니다.

from concurrent.futures import ThreadPoolExecutor
from threading import Event
import time


def fetch_loop(stop_event: Event, name: str) -> str:
    while not stop_event.is_set():
        # 실제 업무에서는 이 구간의 I/O에도 유한한 timeout을 둡니다.
        print(f"{name}: 한 번 처리")
        if stop_event.wait(timeout=1.0):
            break
    return f"{name}: 종료"


stop_event = Event()
executor = ThreadPoolExecutor(max_workers=2)
futures = [
    executor.submit(fetch_loop, stop_event, "worker-1"),
    executor.submit(fetch_loop, stop_event, "worker-2"),
]

try:
    for future in futures:
        print(future.result())
except KeyboardInterrupt:
    stop_event.set()
    executor.shutdown(wait=True, cancel_futures=True)
else:
    executor.shutdown(wait=True)

Event.wait()는 이벤트가 설정되거나 지정한 대기 시간이 지나면 돌아옵니다. 그래서 반복 작업을 중단할 때 종료 신호 확인과 주기적인 깨어남을 한 곳에서 처리할 수 있습니다. 다만 실제 HTTP 요청이나 파일 작업이 별도의 무기한 대기에 빠져 있다면 Event만으로는 충분하지 않으므로 해당 I/O에도 유한한 timeout이 필요합니다. 이 글의 공식 근거는 Event의 동작이며, 특정 네트워크 라이브러리의 timeout 방식까지 보장하는 내용은 아닙니다.

Future 대기가 종료를 붙잡고 있는지 확인하기

Future.result()의 기본 timeout은 제한이 없고, as_completed()도 timeout 없이 호출하면 완료되지 않은 Future를 계속 기다릴 수 있습니다. 작업 함수는 끝날 조건이 있는데 메인 스레드가 결과 수집에서 멈춰 있는지, 반대로 작업 함수 자체가 끝날 조건이 없는지를 구분해야 합니다.

점검 순서는 다음과 같습니다.

  • result()를 호출하는 작업이 실제로 종료 신호를 확인하는지 봅니다.
  • as_completed()를 사용한다면 전체 결과 수집에 timeout이 필요한지 판단합니다.
  • 예외를 삼키는 코드가 없는지 확인하고, 각 Future의 결과를 읽을 때 예외를 기록합니다.
  • 무기한 I/O가 있다면 그 I/O의 timeout과 재시도 정책을 별도로 확인합니다.

Future 대기에 timeout을 넣는 것만으로 작업자 스레드가 중단되지는 않습니다. 그것은 메인 스레드가 다음 판단을 할 기회를 주는 장치이고, 작업자 종료는 작업 함수의 조건과 정리 방식이 담당합니다.

shutdown(wait)와 cancel_futures를 구분하기

shutdown(wait=True)는 실행 중인 작업과 대기 중인 작업이 끝날 때까지 반환하지 않습니다. 정상적인 정리를 우선할 때 적합하지만, 종료 신호를 무시하는 장기 작업이 있다면 호출부도 계속 기다리게 됩니다.

shutdown(wait=False)는 호출부가 기다리지 않고 진행하게 하지만, 실행 중인 Future가 끝나기 전에는 Python 프로그램이 종료되지 않습니다. 따라서 이것을 즉시 강제 종료 옵션으로 이해하면 안 됩니다.

cancel_futures=True는 아직 실행되지 않은 Future를 취소합니다. 이미 실행 중인 작업은 취소하지 못합니다. 실행 중인 작업까지 끝내려면 작업 함수가 종료 이벤트를 확인하고 스스로 반환하도록 설계해야 합니다.

이 차이를 기준으로 선택하면 판단이 단순해집니다.

상황 먼저 볼 선택 이유
실행 중 작업을 정상적으로 마무리 shutdown(wait=True) 정리 완료까지 기다림
대기열의 미시작 작업을 버림 shutdown(cancel_futures=True) 아직 시작하지 않은 작업만 취소
종료 신호를 받은 장기 반복 작업 Event.set() 후 작업 함수 반환 실행 중 작업은 협력적으로 끝내야 함
호출부가 정리 절차를 이어가야 함 wait=False 검토 호출부의 대기를 줄일 뿐 작업을 죽이지 않음

with 문을 쓸 때 놓치기 쉬운 점

with ThreadPoolExecutor(...)는 블록을 빠져나갈 때 wait=True 방식의 종료 정리를 수행합니다. 코드가 간결하다는 장점은 있지만, 블록을 빠져나가는 순간 장기 작업이 모두 끝나기를 기다리는 구조가 될 수 있습니다.

따라서 짧은 작업 묶음에는 with가 자연스럽지만, 중단 신호와 종료 순서를 직접 제어해야 하는 장기 실행 fetcher라면 executor의 생명주기를 명시적으로 관리하는 편이 판단하기 쉽습니다. 어느 방식을 택하든 작업 함수가 종료 이벤트를 확인하고, I/O 대기에도 끝나는 조건을 두어야 합니다.

Python 공식 문서는 인터프리터 종료 전에 풀의 작업자 스레드를 join하며 장기 실행 작업에 ThreadPoolExecutor를 사용하지 않도록 권고합니다. 작업이 계속 살아 있어야 하는 서비스라면 스레드 풀의 종료 의미뿐 아니라 작업의 생명주기와 서비스 관리 방식을 함께 검토해야 합니다.

실무 점검 순서

저라면 다음 순서로 확인합니다.

  1. Ctrl+C가 발생한 뒤 메인 스레드가 KeyboardInterrupt를 받는지 확인합니다.
  2. 작업 함수의 반복문에 Event.is_set() 또는 Event.wait() 기반 종료 조건이 있는지 봅니다.
  3. 작업 내부의 네트워크·파일 I/O가 무기한 대기하지 않는지 확인합니다.
  4. result()와 as_completed()가 timeout 없이 전체 작업을 붙잡고 있는지 확인합니다.
  5. 미시작 Future만 버리면 되는지, 실행 중 작업의 협력적 종료가 필요한지 나눕니다.
  6. 마지막으로 shutdown(wait=True/False, cancel_futures=True) 조합이 의도한 종료 순서와 맞는지 검토합니다.

이 순서를 거치면 “Ctrl+C가 안 먹는다”는 하나의 증상을 메인 스레드의 신호 처리, Future 대기, 작업 함수의 종료 조건, executor 정리 문제로 나눌 수 있습니다. 실행 중 작업이 반드시 끝나야 하는지, 중단해도 되는지 결정한 뒤에야 안전한 shutdown 선택이 가능합니다.

마무리

ThreadPoolExecutor가 종료되지 않을 때의 핵심은 강제 종료 옵션을 찾는 것이 아니라 작업 함수가 끝날 조건을 만들고, 메인 스레드의 대기와 executor 정리를 분리해서 점검하는 데 있습니다. 작업 중인 Future는 cancel_futures로 멈출 수 없으므로, 장기 작업에는 종료 이벤트와 유한한 I/O 대기를 함께 설계해야 합니다.

공식 자료

Leave a Comment