Python multiprocessing으로 작업을 나누면 각 프로세스가 남기는 로그를 한 파일에서 보고 싶어집니다. 이때 여러 프로세스가 FileHandler를 직접 공유하는 방식은 피하고, 각 worker가 QueueHandler로 로그 레코드를 queue에 보낸 뒤 하나의 QueueListener가 FileHandler로 기록하게 구성하는 편이 적합합니다. 핵심은 작업 프로세스를 먼저 끝내고, 그 다음 listener를 종료하는 순서입니다.
여러 프로세스가 같은 파일을 직접 쓰면 안 되는 이유
한 프로세스 안의 여러 스레드는 같은 파일에 기록할 수 있지만, 여러 프로세스가 하나의 파일을 직접 쓰는 방식은 표준적인 직렬화 방법이 없습니다. 로그 줄이 섞이거나 종료 시점에 일부가 처리되지 않는 상황을 피하려면 파일을 실제로 여는 기록 지점을 하나로 두는 것이 안전합니다.
구조는 다음처럼 단순화할 수 있습니다.
worker 프로세스 → QueueHandler → multiprocessing.Queue → QueueListener → FileHandler
worker에는 파일 handler를 붙이지 않습니다. worker는 로그 메시지를 queue에 넣고, listener 쪽만 출력 파일을 엽니다.
QueueHandler와 QueueListener의 역할
두 클래스의 역할을 섞어 생각하면 종료 처리가 어려워집니다.
QueueHandler: 로그 레코드를 queue로 보냅니다. 각 worker의 로거에 연결합니다.QueueListener: queue에서 레코드를 꺼내 하나 이상의 handler로 전달합니다. 파일 기록은 이쪽에 연결합니다.multiprocessing.Queue: 프로세스 사이에서 레코드를 전달할 통로입니다.FileHandler: 실제 로그 파일을 여는 유일한 기록 handler입니다.
multiprocessing에서는 SimpleQueue가 아니라 multiprocessing.Queue를 사용해야 합니다. 또 multiprocessing 내부 logger가 같은 queue를 사용하도록 설정되면 deadlock이나 무한 재귀가 생길 수 있으므로, queue 자체의 내부 디버그 로그까지 같은 경로로 보내지 않도록 주의해야 합니다.
동작하는 기본 구성
다음 예제는 multiprocessing.Process로 worker를 만들고, 부모 프로세스에서 QueueListener를 실행합니다. 예제의 로그 파일명과 작업 내용은 구조를 보여주기 위한 값입니다.
import logging
import logging.handlers
import multiprocessing as mp
def configure_worker(log_queue: mp.Queue) -> None:
"""worker의 로그를 파일이 아닌 queue로 보낸다."""
root = logging.getLogger()
root.handlers.clear()
root.setLevel(logging.INFO)
root.addHandler(logging.handlers.QueueHandler(log_queue))
def worker(log_queue: mp.Queue, task_id: int) -> None:
configure_worker(log_queue)
logger = logging.getLogger(__name__)
logger.info("task=%s 시작", task_id)
logger.info("task=%s 완료", task_id)
def main() -> None:
log_queue = mp.Queue()
file_handler = logging.FileHandler("multiprocess.log", encoding="utf-8")
formatter = logging.Formatter(
"%(asctime)s %(processName)s[%(process)d] %(levelname)s %(message)s"
)
file_handler.setFormatter(formatter)
listener = logging.handlers.QueueListener(log_queue, file_handler)
listener.start()
workers = [
mp.Process(target=worker, args=(log_queue, task_id))
for task_id in ("import", "transform", "export")
]
for process in workers:
process.start()
for process in workers:
process.join()
# 모든 worker가 끝난 뒤 listener를 멈춘다.
listener.stop()
file_handler.close()
if __name__ == "__main__":
main()
여기서 processName과 process를 formatter에 넣은 이유는 한 파일에 모인 뒤에도 어느 worker가 남긴 기록인지 구분하기 위해서입니다. 파일을 하나로 합치는 것과 원인을 추적할 수 있게 만드는 것은 별개의 문제이므로, 프로세스 이름이나 PID처럼 필요한 식별자를 포맷에 포함하는 편이 좋습니다.
종료 순서가 로그 유실을 좌우합니다
listener.start()는 worker를 시작하기 전에 호출합니다. 그 다음 worker를 실행하고 모두 join()한 뒤 listener를 멈춥니다. worker가 아직 로그를 queue에 넣는 중인데 listener부터 멈추면 마지막 기록을 처리하지 못할 수 있습니다.
공식 Logging Cookbook의 별도 listener 프로세스 예제도 worker를 먼저 join()하고 queue에 종료 신호인 sentinel을 넣은 뒤 listener의 종료를 기다리는 순서를 사용합니다. QueueListener를 직접 쓰는 위 예제에서는 listener.stop()이 종료 단계의 역할을 담당합니다. 파일 handler도 listener가 멈춘 뒤 닫아야 합니다.
운영 코드에서는 worker의 예외와 종료 코드도 함께 확인하는 것이 좋습니다. 로그가 남았다는 사실만으로 작업이 성공했다는 뜻은 아니므로, join() 뒤 각 프로세스의 종료 상태를 별도로 확인해야 합니다.
ProcessPoolExecutor를 쓸 때
ProcessPoolExecutor로 작업을 분배할 때는 queue를 worker 함수에 전달하는 방식이 달라질 수 있습니다. Python 공식 Logging Cookbook의 예제는 이 경우 multiprocessing.Manager().Queue()를 사용합니다. 따라서 multiprocessing.Process 예제의 queue를 그대로 바꾸어 넣기보다, 사용 중인 executor의 프로세스 전달 방식에 맞춰 queue 생성 방법을 확인해야 합니다.
하나의 파일과 프로세스별 파일 중 선택하기
다음 조건이면 queue를 거쳐 하나의 파일로 모으는 구성이 잘 맞습니다.
- 전체 작업 흐름을 시간순으로 한 번에 확인해야 할 때
- 여러 worker의 상태를 같은 운영 로그에서 비교해야 할 때
- 파일을 실제로 여는 handler를 한 곳에서 관리하고 싶을 때
반대로 프로세스별 독립 보관과 개별 수집이 더 중요한 시스템이라면 파일을 나누는 설계도 검토할 수 있습니다. 중요한 것은 같은 파일에 여러 프로세스가 직접 쓰게 해 놓고 기록 순서가 보장되기를 기대하지 않는 것입니다.
적용 전 점검 목록
- worker에는
FileHandler대신QueueHandler만 연결했는가? - queue는 프로세스 간 전달에 맞는
multiprocessing.Queue인가? QueueListener가 worker보다 먼저 시작되는가?- 모든 worker를
join()한 뒤 listener를 멈추는가? - 로그 포맷에 프로세스 이름이나 PID가 필요한 만큼 포함되어 있는가?
ProcessPoolExecutor라면Manager().Queue()가 필요한 구조인지 확인했는가?
정리하면, 파이썬 멀티프로세스 로그 파일을 안전하게 하나로 모으는 핵심은 worker가 파일을 직접 쓰지 않게 하는 것입니다. QueueHandler로 기록을 전달하고 QueueListener의 단일 FileHandler가 파일을 담당하게 한 뒤, worker 종료와 listener 종료 순서를 지키면 구조와 운영 점검 지점을 분명하게 가져갈 수 있습니다.
참고 자료: