IT/python

[python3] 동기 코드에서 “비동기 작업자”를 별도 프로세스로 돌리기 — subprocess vs multiprocessing 선택 가이드

심량 2025. 10. 16. 09:36

동기 방식 메인 코드에서 파일 업로드를 하는 비동기 모듈을 만들어서 쓰려고 할 때 고민한 내용을 정리한 글입니다.

 

의도(현 구현 방향)

  • 목표: 동기 코드(메인 앱)에서 비동기 작업자(업로더 등)를 완전 별도 프로세스로 실행하고, 빠른 취소/종료, 환경변수 주입, 로그 분리, 장애 격리를 쉽게 달성한다.
  • 우선순위: 운영 단순성(격리·배포·재시작) > 세밀한 IPC > 최적화 미세튜닝.
  • 가정: 작업자는 자체 이벤트 루프/네트워크 스택을 갖고, 성공/실패를 자체적으로 처리/보고할 수 있다.

언제 subprocess가 맞는가?

다음 조건을 2개 이상 만족하면 **subprocess.Popen**이 보통 더 깔끔합니다.

  • 작업자가 독립 실행 스크립트 형태다.
  • 환경변수/CLI 인자만으로 설정 전달이 충분하다.
  • 실패해도 부모 프로세스를 오염시키지 않고 재기동이 쉬워야 한다.
  • 로그 파일로 표준 출력을 남기고 외부 툴(systemd/supervisor)로 관리하고 싶다.
  • 신호(SIGTERM/SIGUSR1) 로 빠르게 취소·종료를 걸고 싶다.
  • 나중에 배포/롤백/핫스왑을 단순하게 가져가고 싶다(venv/스크립트 교체만으로).

장점 요약

  • 완전 격리(새 파이썬 인터프리터): 이벤트 루프/소켓/상태 충돌 리스크 낮음
  • 운영 친화: env/cwd/stdout 한 줄로 설정, 프로세스 그룹 분리(start_new_session=True)
  • 장애 격리/재시작 용이, systemd 연동 쉬움

단점 요약

  • 파이썬 객체 단위의 풍부한 IPC가 번거롭다(주로 stdout/파일/소켓/REST로 해결)
  • 프로세스 기동 오버헤드(보통 수백 ms)는 짧고 잦은 호출에선 느껴질 수 있음

언제 multiprocessing이 맞는가?

다음 조건을 2개 이상 만족하면 **multiprocessing.Process**가 더 자연스럽습니다.

  • 파이썬 함수 자체를 병렬로 실행하고 싶다.
  • Queue/Pipe/Manager파이썬 객체를 풍부하게 주고받아야 한다.
  • 워커를 오래 띄워두고 많은 소작업을 저지연으로 처리해야 한다(풀링).
  • 이벤트·진행률·중간결과를 빈번하게 주고받아야 한다.

장점 요약

  • 파이썬 객체 직렬화 기반의 쉽고 풍부한 IPC
  • 동일 코드베이스 안에서 함수 단위 병렬화가 간편

단점 요약

  • 시작 방식(fork/spawn)과 플랫폼 차이에 따른 초기화 규율 필요
    (특히 spawn에서 if __name__ == "__main__": 가드, import 순서, 전역실행 주의)
  • fork 기반에선 부모의 핸들/루프 상태 상속으로 미묘한 버그 가능 → 보수적으로는 spawn 권장(오버헤드↑)
  • 외부 서비스처럼 운영/감시/재시작하기엔 subprocess보다 절차가 복잡

결정 체크리스트

  • 격리와 운영 단순성이 최우선 → subprocess ✅
  • 실시간·빈번한 객체 교환이 핵심 → multiprocessing ✅
  • 빠른 취소 반응성은 둘 다 가능(신호 or Event/Queue). 다만 안전한 정리까지 고려하면 subprocess가 더 단순.
  • 로그/배포/롤백을 OS 수준에서 다루고 싶다 → subprocess ✅

실전 패턴 모음

1) subprocess 비블로킹 실행(환경변수·로그·신호)

 
import os, subprocess, signal
from pathlib import Path

APP_DIR = Path("/path/to/app")
PY      = "/path/to/venv/bin/python3"
SCRIPT  = str(APP_DIR / "worker.py")

env = os.environ.copy()
env["WORKER_PROGRESS"] = "1"
env["WORKER_INTERVAL"] = "0.5"

log = open(str(APP_DIR/"runlogs/worker.log"), "a+", buffering=1)
p = subprocess.Popen(
    [PY, SCRIPT, "--archive", "tar.gz"],
    cwd=str(APP_DIR), env=env,
    stdout=log, stderr=subprocess.STDOUT,
    start_new_session=True  # 프로세스 그룹 분리 → 신호 제어/정리 용이
)
# … 부모는 즉시 다음 일 수행
# os.kill(p.pid, signal.SIGUSR1)  # 취소(사용자)
# p.terminate(); p.wait(timeout=10)  # 그레이스풀 종료 → 필요시 SIGKILL
 

결과 전달:

  • 간단: 종료코드(0/비0)
  • 중간: stdout 한 줄 JSON / 지정된 파일에 결과 기록
  • 고급: 작업자가 서버에 직접 보고(부모는 상태만 확인)

2) multiprocessing으로 파이썬 콜러블 병렬화

 
from multiprocessing import Process, Queue
import time

def worker(cfg, q: Queue):
    # … 작업 …
    q.put({"result": "ok", "took": 1.23})

q = Queue()
p = Process(target=worker, args=({"archive": "tar.gz"}, q))
p.start()            # 비블로킹
msg = q.get()        # 필요 시 결과 수신(블로킹/타임아웃 조절)
p.join()
 

: 네트워크/이벤트루프 초기화는 자식 프로세스 내부에서 새로 열기(특히 fork 환경 주의).


흔한 함정 & 예방책

  • 데드락: 파이프(stdout=PIPE)를 열었다면 **communicate()**로 비우거나 파일로 리다이렉트.
  • 신호 전파: 리눅스에선 start_new_session=True 또는 os.setsid()로 그룹 분리 후 원하는 신호만 명확히 전송.
  • spawn 규율(multiprocessing): 전역 실행 금지, __main__ 가드, import 순서, 전역 상태 초기화 유의.
  • 깨끗한 종료: SIGTERM/취소 플래그를 받은 후 파일/소켓/세션을 확실히 정리(특히 비동기 루프).

최종 한 줄

  • 독립 실행체(서비스처럼)로 돌리고, 운영·배포·격리를 단순화하려면 subprocess.
  • 파이썬 객체를 촘촘히 교환하며 함수 단위 병렬화가 핵심이면 multiprocessing.

당장 깔끔하게 가려면 subprocess로 시작하고, 정말 밀도 높은 상호작용이 필요해지는 지점에서만 multiprocessing으로 전환을 검토하는 전략이 안전합니다.