[파이썬 100강] 56강. subprocess로 외부 명령 안전하게 실행하기

[파이썬 100강] 56강. subprocess로 외부 명령 안전하게 실행하기

파이썬 자동화를 하다 보면 결국 외부 명령을 실행해야 하는 순간이 옵니다. Git 상태를 확인하거나, ffmpeg로 인코딩하거나, 시스템 유틸리티를 호출하는 식이죠. 이때 os.system()으로 대충 붙이면 에러를 놓치고 보안 이슈까지 만들기 쉽습니다. 이번 강의에서는 subprocess를 사용해 외부 명령을 예측 가능하고 안전하게 실행하는 방법을 익힙니다. 서론은 여기까지, 바로 예제로 들어가겠습니다.


핵심 개념

  • subprocess.run()은 외부 프로세스 실행의 기본 진입점이며, 결과(returncode, stdout, stderr)를 구조적으로 다룰 수 있습니다.
  • 명령은 가능하면 문자열 한 줄이 아니라 리스트 인자로 전달하고, shell=True는 꼭 필요한 경우에만 사용해야 합니다.
  • 운영 코드에서는 check=True, timeout, capture_output, text=True를 조합해 실패를 빠르게 감지하고 로그를 남겨야 합니다.

파이썬에서 외부 명령 호출은 단순히 “명령 한 번 실행”이 아닙니다. 사실상 파이썬 세계와 운영체제 세계를 연결하는 경계면입니다. 이 경계면이 느슨하면 장애가 조용히 지나갑니다. 예를 들어 명령이 실패했는데도 코드는 계속 진행되고, 나중 단계에서 엉뚱한 오류가 터지는 패턴이 매우 흔합니다. 반대로 경계를 단단히 만들면, 실패 시점이 명확해지고 복구도 쉬워집니다. subprocess의 핵심은 옵션이 많다는 데 있지 않습니다. “어떤 실패를 어디서 끊을지”를 코드로 표현할 수 있다는 점이 진짜 가치입니다.

기본 사용

예제 1) 가장 기본 실행과 결과 확인

>>> import subprocess
>>> result = subprocess.run(["python", "-c", "print('hello subprocess')"], capture_output=True, text=True)
>>> result.returncode
0
>>> result.stdout.strip()
'hello subprocess'
>>> result.stderr
''

해설:

  • capture_output=True를 쓰면 표준출력/표준에러를 결과 객체로 받을 수 있어 후처리와 로깅이 쉬워집니다.
  • text=True를 쓰면 바이트가 아닌 문자열로 받아 디버깅이 훨씬 편해집니다.

예제 2) 실패를 즉시 예외로 올리기

>>> import subprocess
>>> try:
...     subprocess.run(["python", "-c", "import sys; sys.exit(2)"], check=True)
... except subprocess.CalledProcessError as e:
...     print("failed", e.returncode)
failed 2

해설:

  • 기본값(check=False)에서는 실패해도 예외가 안 납니다. 운영 코드에서 이 기본값을 그대로 쓰면 실패가 묻힙니다.
  • check=True를 습관화하면 실패 지점이 선명해지고, 장애 전파를 조기에 차단할 수 있습니다.

예제 3) 타임아웃으로 무한 대기 차단

>>> import subprocess
>>> try:
...     subprocess.run(["python", "-c", "import time; time.sleep(5)"], timeout=1)
... except subprocess.TimeoutExpired as e:
...     print(type(e).__name__)
TimeoutExpired

해설:

  • 외부 명령은 언제든 멈출 수 있습니다. 네트워크/파일락/도구 버그로 인해 영원히 기다리는 상황이 생깁니다.
  • timeout은 품질 옵션이 아니라 서비스 생존 옵션에 가깝습니다.

예제 4) 표준입력 전달과 파이프라인 단순화

>>> import subprocess
>>> data = "a\nb\nc\n"
>>> r = subprocess.run(["python", "-c", "import sys; print(len(sys.stdin.read().splitlines()))"],
...                    input=data, text=True, capture_output=True, check=True)
>>> r.stdout.strip()
'3'

해설:

  • 쉘 파이프를 억지로 조합하기보다 input=으로 데이터를 직접 넘기면 재현성과 이식성이 좋아집니다.
  • 테스트에서도 고정 입력을 넣어 결과를 비교하기 좋아 자동화 품질이 올라갑니다.

자주 하는 실수

실수 1) shell=True + 사용자 입력 문자열 합치기

>>> import subprocess
>>> user_arg = "report.txt; echo HACKED"
>>> # 나쁜 예 (실행 금지):
>>> # subprocess.run(f"cat {user_arg}", shell=True)
>>> print("위 코드는 명령 주입 위험이 있습니다.")
위 코드는 명령 주입 위험이 있습니다.

원인:

  • 쉘 문자열 조합은 공백, 따옴표, 메타문자(;, &&, |) 해석을 쉘에 맡깁니다.
  • 사용자 입력이 섞이면 명령 주입(command injection)으로 바로 이어질 수 있습니다.

해결:

>>> import subprocess
>>> safe_arg = "report.txt"
>>> # 리스트 인자로 분리하면 쉘 해석이 최소화됩니다.
>>> cmd = ["python", "-c", "import sys; print(sys.argv[1])", safe_arg]
>>> out = subprocess.run(cmd, capture_output=True, text=True, check=True)
>>> out.stdout.strip()
'report.txt'

실수 2) 실패 코드 무시하고 다음 단계 진행

  • 증상: 중간 단계가 실패했는데도 최종 산출물만 보고 “성공처럼” 보입니다.
  • 원인: check=False 기본값으로 실행하고 returncode 검사를 생략했습니다.
  • 해결: check=True를 기본으로 두고, 실패 예외에서 stderr를 함께 기록하세요.

실수 3) 출력이 커지는데 캡처 전략 없이 무작정 실행

>>> # 나쁜 방향의 습관 예시:
>>> # subprocess.run(cmd, capture_output=True, text=True)
>>> # (출력이 수백 MB면 메모리 압박)
>>> print("대용량 출력은 파일 리다이렉트/스트리밍 설계를 고려하세요.")
대용량 출력은 파일 리다이렉트/스트리밍 설계를 고려하세요.

원인:

  • capture_output=True는 편하지만, 매우 큰 출력에서는 메모리를 급격히 사용합니다.

해결:

  • 로그 파일 핸들로 stdout/stderr를 직접 연결하거나,
  • subprocess.Popen으로 스트리밍 처리하며 줄 단위 소비 패턴을 씁니다.

실무 패턴

  • 입력 검증 규칙: 외부 명령 인자로 들어가는 값은 “허용 리스트 기반”으로 제한합니다. 예: 환경 이름은 dev|staging|prod 중 하나만 허용.
  • 로그/예외 처리 규칙: 실패 시 returncode, 요약된 stderr, 실행한 명령(민감정보 마스킹)을 함께 남깁니다.
  • 재사용 함수/구조화 팁: 팀 공용 run_cmd() 래퍼를 만들어 check=True, text=True, timeout 기본값을 통일합니다.
  • 성능/메모리 체크 포인트: 출력이 큰 명령은 캡처 대신 파일/스트림으로 전환하고, 타임아웃과 재시도 횟수를 명시적으로 분리합니다.

실무에서는 “명령 실행 성공/실패”만 보는 수준에서 한 단계 더 가야 합니다. 어떤 명령은 일시적 실패(네트워크 지연)이고, 어떤 실패는 즉시 중단해야 하는 치명적 오류입니다. 그래서 래퍼 함수에 retryable 여부를 분리해 설계하면 운영 안정성이 크게 올라갑니다. 또한 배치 시스템에서는 명령 실행 전후로 타임스탬프와 실행 ID를 기록해 두면 병목 구간을 잡아내기 쉽습니다. 핵심은 subprocess를 단순 호출 API가 아니라 운영 규칙을 코드화하는 레이어로 다루는 것입니다.

오늘의 결론

한 줄 요약: subprocess는 “명령 실행” 도구가 아니라, 외부 의존성을 통제 가능한 실패 모델로 바꾸는 도구입니다.

기억할 것:

  • 기본값은 check=True + timeout으로 시작하세요.
  • 문자열 한 줄보다 리스트 인자를 우선하고, shell=True는 정말 필요한 경우에만 사용하세요.
  • 실패 로그에는 returncodestderr 요약을 반드시 남기세요.

연습문제

  1. run_cmd(cmd: list[str]) -> str 함수를 만들어 check=True, text=True, timeout=5를 기본 적용하고, 성공 시 stdout을 반환하도록 작성하세요.
  2. 의도적으로 실패하는 명령을 실행해 CalledProcessError에서 returncodestderr를 출력하는 예외 처리 코드를 작성하세요.
  3. 10초 대기 명령에 timeout=1을 적용해 TimeoutExpired를 처리하고, “재시도할지 즉시 중단할지” 분기하는 로직을 구현하세요.

이전 강의 정답

  1. 임시 디렉터리 파이프라인 생성 후 자동 삭제 확인
>>> import tempfile
>>> from pathlib import Path
>>> with tempfile.TemporaryDirectory() as d:
...     root = Path(d)
...     (root / "raw.csv").write_text("name,score\nkim,90\n", encoding="utf-8")
...     (root / "cleaned.csv").write_text("name,score\nkim,90\n", encoding="utf-8")
...     (root / "report.txt").write_text("ok", encoding="utf-8")
...     print((root / "report.txt").exists())
True
  1. flush() 유무 비교
>>> import tempfile
>>> with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") as f:
...     _ = f.write("hello")
...     f.seek(0)
...     print(f.read())
hello
>>> with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") as f:
...     _ = f.write("world")
...     f.flush()
...     f.seek(0)
...     print(f.read())
world
  1. 임시 작성 후 replace로 안전 교체
>>> import tempfile
>>> from pathlib import Path
>>> target = Path("result.txt")
>>> target.write_text("old", encoding="utf-8")
3
>>> with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8") as tf:
...     _ = tf.write("new")
...     tmp = Path(tf.name)
>>> tmp.replace(target)
PosixPath('result.txt')
>>> target.read_text(encoding="utf-8")
'new'

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: subprocess, pathlib, tempfile
  • 재현 절차:
    1. 기본 사용 예제 1~4를 순서대로 실행해 returncode/stdout/stderr, 타임아웃 동작을 확인합니다.
    2. 실수 1 예시를 읽고 문자열 결합 대신 리스트 인자로 명령을 바꿔봅니다.
    3. 연습문제 1~3을 구현해 공용 run_cmd 래퍼를 만든 뒤, 실패 로그 형식을 통일합니다.
  • 검증 포인트: 실패 즉시 감지 여부, 타임아웃으로 무한 대기 차단 여부, 명령 인자 안전성, 로그 재현성