폴더 감시 자동화 — 파일이 들어오면 알아서 분류하는 스크립트 만들기

다운로드 폴더를 열어 보면 설치 파일, 거래명세서 PDF, 캡처 이미지, 압축 파일이 순서 없이 500개쯤 쌓여 있습니다. 필요한 파일 하나를 찾느라 정렬 기준을 바꿔 가며 훑는 데 매번 1~2분씩 쓰고, 분기에 한 번 날을 잡아 정리하면 한 시간이 그냥 사라집니다. 폴더 감시 자동화는 이 문제를 접근 방향부터 바꿉니다. 쌓인 뒤에 치우는 게 아니라, 파일이 들어오는 순간 규칙에 따라 제자리로 보내는 겁니다.

이 글에서는 파이썬 watchdog 라이브러리로 폴더를 실시간 감시하는 스크립트를 만들고, 실제로 돌려 보면 반드시 부딪히는 세 가지 함정(다운로드 중인 파일, 이름 충돌, 네트워크 드라이브)까지 처리해서 상시 돌려 둘 수 있는 형태로 완성합니다.

일회성 정리 스크립트와 무엇이 다른가

예전 글에서 다룬 파일 정리 스크립트는 실행할 때마다 폴더 전체를 훑어 조건에 맞는 파일을 옮기는 방식이었습니다. 간단하고 확실하지만, 누군가 실행해 줘야 합니다. 작업 스케줄러에 걸어 한 시간마다 돌리면 자동이 되긴 하는데, 파일이 들어온 지 59분 뒤에 정리되는 일이 생기고 파일이 하나도 없는 시간대에도 계속 폴더를 훑습니다.

감시 방식은 운영체제가 제공하는 파일 시스템 알림을 받습니다. 윈도우는 ReadDirectoryChangesW, 맥은 FSEvents, 리눅스는 inotify를 쓰고, watchdog이 이걸 하나의 인터페이스로 감싸 줍니다. 평소에는 아무 일도 하지 않고 대기하다가 변화가 생긴 순간에만 깨어나므로, 반응은 즉시이면서 CPU 사용량은 거의 0입니다.

비교 항목주기 실행 (스케줄러)실시간 감시 (watchdog)
반응 시간실행 주기만큼 지연수 밀리초 이내
평소 자원 사용실행 때마다 폴더 전체 스캔대기 중에는 거의 없음
구현 난이도낮음중간 (예외 처리 필요)
중간에 놓친 파일다음 실행 때 처리됨스크립트가 꺼져 있으면 영영 놓침

마지막 행이 중요합니다. 감시 방식은 스크립트가 떠 있는 동안에만 동작합니다. 그래서 실무에서는 감시 스크립트를 상시 띄워 두고, 하루 한 번 도는 일회성 정리 스크립트를 보조로 함께 두는 구성이 안전합니다.

가장 단순한 감시 스크립트

먼저 뼈대부터 봅니다. pip install watchdog으로 설치하고 아래를 실행한 뒤, 감시 대상 폴더에 아무 파일이나 하나 넣어 보세요.

# pip install watchdog
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

class Handler(FileSystemEventHandler):
    def on_created(self, event):
        if event.is_directory:
            return
        print("새 파일:", event.src_path)

observer = Observer()
observer.schedule(Handler(), path="C:/Users/user/Downloads", recursive=False)
observer.start()

try:
    while True:
        time.sleep(1)
except KeyboardInterrupt:
    observer.stop()
observer.join()

FileSystemEventHandler를 상속해서 필요한 이벤트만 재정의하는 구조입니다. on_created 외에 on_modified, on_deleted, on_moved가 있습니다. recursive=True로 주면 하위 폴더까지 함께 감시합니다.

여기까지는 어느 튜토리얼에나 나오는 내용이고, 실제로 파일을 옮기는 코드를 넣는 순간부터 문제가 시작됩니다.

함정 1: 다운로드가 끝나기 전에 옮겨 버린다

브라우저로 300MB 파일을 받으면 on_created는 다운로드가 시작되는 시점에 발생합니다. 이때 곧바로 shutil.move()를 호출하면 받다 만 파일을 옮겨 버리고, 브라우저는 원래 자리에 쓰려다 실패합니다. 결과물은 깨진 파일입니다.

크롬은 다운로드 중인 파일에 .crdownload 확장자를 붙이므로 이런 임시 확장자를 걸러내는 것이 1차 방어입니다. 하지만 파일 복사나 네트워크 전송에는 임시 확장자가 붙지 않으므로, 파일 크기가 더 이상 변하지 않을 때까지 기다리는 확인을 한 겹 더 둡니다.

import os, time

def wait_until_stable(path, checks=3, interval=1.0):
    """파일 크기가 연속 checks번 같으면 복사가 끝난 것으로 본다."""
    last = -1
    same = 0
    for _ in range(60):  # 최대 60초
        try:
            size = os.path.getsize(path)
        except OSError:
            time.sleep(interval)
            continue
        if size == last:
            same += 1
            if same >= checks:
                return True
        else:
            same = 0
            last = size
        time.sleep(interval)
    return False

크기가 연속 세 번 같으면 쓰기가 끝난 것으로 판단합니다. 최대 60초까지만 기다리고 그 뒤에는 포기하는데, 무한 대기에 빠지면 그 뒤에 들어온 파일까지 줄줄이 밀리기 때문입니다.

함정 2: 같은 이름의 파일이 이미 있다

shutil.move()는 대상 위치에 같은 이름이 있으면 경고 없이 덮어씁니다. 매달 같은 이름으로 받는 명세서.pdf 같은 파일이라면 지난달 자료가 소리 없이 사라집니다. 자동화 스크립트에서 가장 위험한 종류의 버그입니다 — 에러가 나지 않아서 한참 뒤에야 알아차립니다.

아래 완성본의 unique_path()가 이 문제를 처리합니다. 이름이 겹치면 명세서 (1).pdf처럼 번호를 붙여 비켜 갑니다.

완성본: 확장자별 자동 분류 스크립트

앞의 두 가지 방어와 로깅을 모두 넣은 최종 형태입니다. WATCH_DIR과 RULES만 본인 환경에 맞게 고치면 그대로 돌아갑니다. 앞서 만든 wait_until_stable() 함수를 같은 파일에 함께 넣어 주세요.

import os, shutil, time, logging
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

WATCH_DIR = r"C:\Users\user\Downloads"

RULES = {
    ".pdf":  "문서",
    ".docx": "문서",
    ".xlsx": "문서",
    ".png":  "이미지",
    ".jpg":  "이미지",
    ".jpeg": "이미지",
    ".zip":  "압축",
    ".7z":   "압축",
}

logging.basicConfig(
    filename=os.path.join(WATCH_DIR, "_sorter.log"),
    level=logging.INFO,
    format="%(asctime)s %(message)s",
    encoding="utf-8",
)

def unique_path(dest):
    """같은 이름이 있으면 name (1).ext 형태로 비켜 간다."""
    if not os.path.exists(dest):
        return dest
    base, ext = os.path.splitext(dest)
    n = 1
    while os.path.exists("%s (%d)%s" % (base, n, ext)):
        n += 1
    return "%s (%d)%s" % (base, n, ext)

class Sorter(FileSystemEventHandler):
    def on_created(self, event):
        if event.is_directory:
            return
        src = event.src_path
        name = os.path.basename(src)

        # 브라우저 임시 파일은 무시
        if name.endswith((".crdownload", ".part", ".tmp")) or name.startswith("~$"):
            return

        ext = os.path.splitext(name)[1].lower()
        folder = RULES.get(ext)
        if not folder:
            return

        if not wait_until_stable(src):
            logging.warning("복사 안 끝남, 건너뜀: %s", name)
            return

        target_dir = os.path.join(WATCH_DIR, folder)
        os.makedirs(target_dir, exist_ok=True)
        dest = unique_path(os.path.join(target_dir, name))

        try:
            shutil.move(src, dest)
            logging.info("이동: %s -> %s", name, folder)
        except Exception as e:
            logging.error("실패 %s: %s", name, e)

if __name__ == "__main__":
    observer = Observer()
    observer.schedule(Sorter(), path=WATCH_DIR, recursive=False)
    observer.start()
    logging.info("감시 시작: %s", WATCH_DIR)
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()

로그 파일을 감시 대상 폴더 안에 두는 점을 눈여겨보세요. RULES에 .log가 없으므로 스크립트가 자기 로그 파일을 다시 처리하는 일은 생기지 않습니다. 감시 폴더 안에 결과물을 만드는 스크립트를 짤 때는 항상 이 되먹임 고리를 먼저 확인해야 합니다.

logging의 encoding="utf-8"은 파이썬 3.9 이상에서 지원합니다. 이게 없으면 윈도우에서 한글 파일명이 로그에 깨져 남습니다.

함정 3: 네트워크 드라이브에서는 알림이 오지 않는다

NAS 공유 폴더나 도커 마운트 볼륨을 감시하면 스크립트는 멀쩡히 돌아가는데 이벤트가 하나도 잡히지 않는 일이 있습니다. 운영체제의 파일 시스템 알림은 로컬 디스크 기준이라, 다른 컴퓨터가 원격으로 쓴 변경은 알려 주지 않기 때문입니다.

이때는 알림 대신 주기적으로 폴더를 훑는 PollingObserver로 교체합니다. 코드에서 옵저버 클래스 한 줄만 바꾸면 되고 핸들러는 그대로 씁니다.

from watchdog.observers.polling import PollingObserver

# 네트워크 드라이브(\\NAS\share)나 도커 마운트 볼륨에서는
# 운영체제 알림이 오지 않으므로 주기적으로 훑는 방식으로 바꾼다.
observer = PollingObserver(timeout=5)
observer.schedule(Sorter(), path=r"\\NAS\share\inbox", recursive=False)
observer.start()

반응이 최대 timeout초만큼 늦어지고 그 주기마다 폴더를 스캔하지만, 아예 동작하지 않는 것보다는 낫습니다. 파일이 수만 개인 폴더라면 timeout을 넉넉히 잡으세요.

백그라운드로 상시 실행하기

터미널 창을 띄워 두는 방식은 실수로 창을 닫으면 그대로 멈춥니다. 컴퓨터를 켜면 자동으로 뜨도록 등록해 두는 편이 낫습니다.

  1. 스크립트를 .pyw 확장자로 저장합니다. pythonw.exe가 콘솔 창 없이 실행합니다.
  2. Win + R에 shell:startup을 입력해 시작프로그램 폴더를 엽니다.
  3. 그 폴더에 .pyw 파일의 바로가기를 만들어 둡니다.
  4. 재부팅한 뒤 작업 관리자에서 pythonw.exe가 떠 있는지, 로그 파일에 “감시 시작”이 찍혔는지 확인합니다.

맥이나 리눅스라면 launchd, systemd user 서비스로 등록하면 같은 효과를 냅니다. 어느 쪽이든 로그 파일을 먼저 확인하는 습관이 중요합니다. 콘솔 창이 없으면 스크립트가 죽어도 티가 나지 않아서, 로그가 유일한 단서입니다.

주의할 점

  • 처음부터 실제 폴더에 걸지 마세요. 테스트용 폴더를 하나 만들어 며칠 돌려 보고, 로그에 이상한 기록이 없는지 확인한 뒤 옮기는 순서가 안전합니다.
  • shutil.move()는 드라이브가 다르면 복사 후 삭제로 동작합니다. C드라이브에서 D드라이브로 대용량 파일을 옮기면 그동안 스크립트가 멈춰 있으므로, 그사이 들어온 파일은 처리가 밀립니다.
  • 확장자만 보고 분류하면 보고서.pdf.exe 같은 위장 파일도 문서 폴더로 들어갑니다. 자동 분류는 정리 용도이지 보안 검사가 아닙니다.
  • 한 파일에 이벤트가 여러 번 발생할 수 있습니다. 위 코드는 옮기고 나면 원본이 사라져 자연히 중복이 막히지만, 복사 방식으로 바꾼다면 처리한 파일 목록을 따로 기억해 둬야 합니다.
  • 클라우드 동기화 폴더(구글 드라이브, 원드라이브)를 감시할 때는 동기화 중인 파일도 이벤트를 발생시킵니다. 크기 안정 확인이 여기서 특히 중요합니다.

업무 문서가 들어오는 폴더에 적용할 계획이라면, 처음 2주는 shutil.move() 대신 shutil.copy2()로 두고 로그만 쌓아 보세요. 원본이 그대로 남으므로 규칙이 잘못돼도 복구할 것이 없습니다. 로그가 의도대로 찍히는 걸 확인한 뒤에 move로 바꾸면 됩니다.

마무리

가장 작은 첫걸음은 다운로드 폴더에서 .pdf 하나만 분류하는 것입니다. RULES에 확장자 한 줄만 남기고 하루 돌려 보면, 규칙을 늘려도 되겠다는 판단이 금방 섭니다. 확장자 대신 파일명 패턴으로 나누고 싶다면 RULES.get(ext) 부분을 정규식 매칭으로 바꾸면 됩니다.

다음 글에서는 이렇게 만든 스크립트가 조용히 죽어 있는 상황을 막는 방법 — 실행 상태를 주기적으로 점검하고 이상이 생기면 알림을 받는 구성을 다루겠습니다.

댓글 남기기