파이썬 subprocess timeout 처리 순서: TimeoutExpired 뒤 kill과 communicate()

파이썬 subprocess가 제한 시간을 넘겼다면, run(timeout=)은 자식 프로세스를 정리한 뒤 예외를 다시 발생시키지만 Popen.communicate(timeout=)은 프로세스를 자동으로 종료하지 않습니다. 따라서 Popen을 직접 사용할 때는 TimeoutExpired를 잡고 프로세스를 종료한 다음 communicate()를 한 번 더 호출해 출력과 종료 상태를 회수해야 합니다.

자동화에서 이 순서를 놓치면 명령은 멈춘 것처럼 남고, 강제 종료 뒤에도 로그와 returncode를 잃기 쉽습니다. 운영 관점에서는 run()으로 충분한 작업인지, 출력이나 실행 상태를 세밀하게 다뤄야 해서 Popen이 필요한지부터 나누어 보는 것이 좋습니다.

먼저 선택할 것: run()인가 Popen인가

단순히 외부 명령을 실행하고 일정 시간 안에 끝나지 않으면 실패로 처리하면 subprocess.run()이 출발점입니다. run(timeout=초)는 시간 초과 때 자식 프로세스를 kill하고 기다린 뒤 TimeoutExpired를 발생시킵니다. 호출부에서는 예외를 잡아 시간 초과라는 결과를 기록하면 됩니다.

import subprocess

try:
    result = subprocess.run(
        ["python", "worker.py"], capture_output=True, text=True,
        timeout=30, check=False,
    )
except subprocess.TimeoutExpired as exc:
    print(f"시간 초과: {exc}")
else:
    print(f"returncode={result.returncode}")
    print(result.stdout)

반대로 실행 중 상태를 확인하거나, 종료 방식과 출력 회수 순서를 직접 제어해야 한다면 Popen이 맞습니다. Popen은 유연한 만큼 timeout 이후 정리 책임도 호출부에 있습니다.

Popen에서 TimeoutExpired가 난 뒤의 처리 순서

communicate(timeout=)은 지정 시간이 지나면 TimeoutExpired를 발생시키지만 프로세스를 자동 종료하지 않습니다. 다음 순서를 한 덩어리로 기억해 두면 됩니다.

  1. communicate(timeout=...)을 호출합니다.
  2. TimeoutExpired를 잡습니다.
  3. proc.kill() 또는 상황에 맞는 proc.terminate()를 호출합니다.
  4. proc.communicate()를 timeout 없이 다시 호출해 남은 출력을 회수합니다.
  5. proc.returncode를 확인하고 시간 초과 결과를 기록합니다.
import subprocess

proc = subprocess.Popen(
    ["python", "worker.py"],
    stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)

timed_out = False
try:
    stdout, stderr = proc.communicate(timeout=30)
except subprocess.TimeoutExpired:
    timed_out = True
    proc.kill()
    stdout, stderr = proc.communicate()

if timed_out:
    print("시간 초과로 프로세스를 종료했습니다.")
print(f"returncode={proc.returncode}")
print(f"stdout={stdout}")
print(f"stderr={stderr}")

여기서 두 번째 communicate()는 중복 호출이 아닙니다. 첫 번째 호출이 시간 초과로 끝났을 때 남아 있는 stdout과 stderr를 읽고, 프로세스의 종료 상태가 채워질 때까지 기다리는 단계입니다. 공식 문서도 TimeoutExpired 뒤에는 wait() 대신 추가 communicate()를 사용하도록 안내합니다.

kill과 terminate는 어떻게 고를까

즉시 중단이 목적이고 더 실행되면 안 되는 작업이라면 위 예제처럼 kill()을 사용할 수 있습니다. POSIX에서 terminate()는 SIGTERM, kill()은 SIGKILL을 보냅니다. Windows에서는 kill()이 terminate()의 별칭입니다.

다만 공식 문서는 두 메서드의 동작은 정의하지만 모든 애플리케이션에 통하는 선택 기준을 정해 주지는 않습니다. 정리 작업을 수행할 기회를 주어야 하는 프로그램이라면 terminate()를 검토하고, 반드시 멈춰야 하는 작업이라면 kill()을 고려하되, 어느 쪽이든 마지막에는 communicate()로 출력과 종료 상태를 회수하는 흐름을 유지하는 편이 안전합니다.

stdout=PIPE에서 직접 읽으면 멈출 수 있는 이유

stdout=PIPE나 stderr=PIPE를 지정하고 프로세스가 많은 출력을 내보내는 동안 한쪽 파이프만 직접 읽으면, 운영체제 파이프 버퍼가 차서 자식 프로세스가 쓰기에서 막힐 수 있습니다. 부모도 다른 파이프를 읽지 못하면 양쪽이 기다리는 deadlock으로 이어질 수 있습니다.

그래서 두 스트림을 파이프로 받을 때는 직접 read()와 wait()를 조합하기보다 communicate()로 입출력을 함께 처리하는 것이 기본입니다. 출력량이 매우 크거나 무제한인 작업은 메모리에 버퍼링되는 communicate()의 특성까지 따로 검토해야 합니다.

운영 코드에서 확인할 항목

저라면 장애가 발생했을 때 다음 네 가지를 한 줄씩 남기는지 먼저 확인합니다.

  • 정상 종료인지 timeout인지: 예외 흐름과 timed_out 같은 상태를 구분합니다.
  • 종료 코드: 정상 경로와 timeout 경로 모두 returncode를 기록합니다.
  • 표준 출력과 오류 출력: 두 번째 communicate() 뒤의 값을 로그에 남깁니다.
  • 파이프 사용 방식: PIPE를 썼다면 직접 읽기와 wait()를 섞어 deadlock을 만들지 않습니다.

로그 파일 자체가 생성되지 않는 문제라면 프로세스 timeout과는 다른 점검이 필요합니다. 그런 경우에는 파이썬 로그 파일이 안 생기는 이유와 logging 설정 점검법을 이어서 확인할 수 있습니다.

정리

subprocess.run(timeout=)은 단순 실행에 적합하고 timeout 시 자식 프로세스를 정리한 뒤 예외를 알립니다. Popen.communicate(timeout=)은 더 세밀한 제어를 주지만 timeout 뒤에 프로세스를 직접 종료해야 합니다.

핵심 순서는 TimeoutExpired 처리, kill() 또는 terminate(), 추가 communicate(), returncode 확인입니다. 특히 stdout과 stderr를 PIPE로 받을 때는 출력 회수를 생략하지 않아야 자동화가 멈춘 원인과 종료 결과를 함께 남길 수 있습니다.

출처

Leave a Comment