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

자동화 작업에서 외부 명령이 끝나지 않는다면 subprocess.run(timeout=...) 또는 Popen.communicate(timeout=...)으로 대기 시간을 제한할 수 있습니다. TimeoutExpired가 발생한 뒤에는 예외만 기록하고 끝내지 말고, 실행 중인 프로세스를 종료한 다음 communicate()를 다시 호출해 파이프와 남은 출력을 정리해야 합니다. 이 순서를 지켜야 timeout을 작업 중단으로 기록할지 재시도할지 판단할 정보도 남습니다.

먼저 run과 Popen 중 필요한 쪽을 고르기

결과만 받으면 되는 단순한 명령은 run()이 읽기 쉽습니다.

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"timeout: {exc}")
else:
    print(result.returncode)
    print(result.stdout)

여기서 timeout은 프로세스가 종료될 때까지 기다리는 시간의 제한입니다. 제한 시간이 지나면 TimeoutExpired가 발생하지만, run()이 timeout을 이유로 자식 프로세스를 자동 종료해 주는 것은 아닙니다. 따라서 단순한 예외 처리만으로는 실행 중인 작업이 남을 수 있습니다.

프로세스를 종료하는 시점과 출력 회수 순서를 직접 제어해야 한다면 Popen을 사용합니다. 특히 stdout=PIPE 또는 stderr=PIPE를 열어 둔 상태라면 wait()나 직접 read()를 섞기보다 communicate()로 입출력을 함께 처리하는 편이 안전합니다.

TimeoutExpired 뒤의 정리 순서

communicate(timeout=...)가 제한 시간을 넘긴 경우에는 다음 흐름으로 처리합니다.

  1. TimeoutExpired를 기록합니다.
  2. 필요하다면 terminate()로 정상 종료를 먼저 요청하거나, 즉시 중단해야 한다면 kill()을 호출합니다.
  3. 다시 communicate()를 호출해 파이프에 남은 출력과 종료 상태를 회수합니다.
import subprocess

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

try:
    stdout, stderr = proc.communicate(timeout=30)
except subprocess.TimeoutExpired as exc:
    print(f"external command timed out: {exc}")
    proc.kill()
    stdout, stderr = proc.communicate()
    print(f"returncode={proc.returncode}")
    print(stdout)
    print(stderr)
else:
    print(f"returncode={proc.returncode}")

공식 문서의 핵심은 예외가 발생한 뒤에도 추가 communicate()에서 출력이 유실되지 않는다는 점입니다. 종료 호출만 하고 communicate()를 생략하면 PIPE에 남은 데이터를 회수하지 못하고, 종료 여부나 returncode를 확인하는 시점도 불분명해집니다. returncode가 아직 None이라면 종료가 확인되지 않은 상태이므로, 정리 뒤에 값을 확인해야 합니다.

terminate와 kill은 어떻게 선택할까

terminate()는 프로세스에 종료를 요청하는 수단이고, kill()은 즉시 중단이 필요한 경우에 쓰는 수단입니다. 운영 정책상 정리할 기회를 주어야 한다면 terminate()를 먼저 고려할 수 있지만, 명령이 다시 응답하지 않거나 작업이 남아 있으면 kill()로 마무리해야 합니다. 이 글의 범위를 넘어서는 자식 프로세스 전체 트리 종료는 운영체제별 별도 설계가 필요합니다.

어떤 방법을 택했는지도 로그에 남기는 것이 좋습니다. timeout, 종료 방식, returncode, 회수한 표준 출력과 표준 오류를 함께 기록하면 다음 실행을 재시도할지, 입력이나 외부 시스템을 먼저 점검할지 결정하기 쉽습니다. timeout 자체가 곧 재시도 가능성을 뜻하는 것은 아니므로, 외부 명령이 일부 작업을 끝냈을 가능성과 중복 실행 위험을 먼저 확인해야 합니다.

stdout과 stderr PIPE에서 멈추는 이유

자식 프로세스가 많은 출력을 쓰는데 부모가 읽지 않으면 PIPE 버퍼가 가득 찰 수 있습니다. 그러면 자식은 출력 쓰기에서 대기하고, 부모는 자식 종료를 기다리면서 서로 진행하지 못하는 교착이 생길 수 있습니다. stdout=PIPE와 stderr=PIPE를 함께 사용하는 경우에도 communicate()로 두 스트림을 처리해야 하는 이유가 여기에 있습니다.

출력이 매우 크거나 끝을 알 수 없다면 전부 메모리에 모으는 communicate() 방식이 적합한지 따져야 합니다. 이때는 로그 파일이나 스트리밍 설계를 별도로 검토하되, 단순히 wait()로 바꾸어 PIPE 교착을 피하려고 해서는 안 됩니다.

실무 점검 순서

먼저 결과만 필요하면 run(timeout=...)으로 충분한지 확인합니다. timeout 뒤에 프로세스를 직접 종료하거나 표준 출력을 회수해야 한다면 Popen과 communicate(timeout=...) 조합으로 바꿉니다. 예외가 발생하면 종료 호출 후 추가 communicate()를 실행하고, 마지막으로 returncode·stdout·stderr를 기록합니다.

이 기록을 바탕으로 timeout을 일시적인 외부 지연으로 볼지, 입력 오류나 반복 실행 위험이 있는 실패로 볼지 운영 정책을 정합니다. 파이프를 사용하지 않는 단순 명령이라도 종료와 결과 확인을 생략하지 않는 습관이 예약 작업을 오래 멈추게 만드는 문제를 줄여 줍니다.

참고 자료

Leave a Comment