argparse 사용법 — 파이썬 스크립트에 명령줄 옵션 붙이기, 직접 걸려 본 함정 4가지 (실측)

처음 만든 스크립트는 보통 sys.argv[1]로 값을 받습니다. 혼자 쓸 때는 괜찮지만, 며칠 지나 다시 실행하거나 다른 사람에게 넘기면 “인자 순서가 뭐였더라”부터 막힙니다. 이 글에서는 파이썬 표준 라이브러리인 argparse 사용법을 오래된 파일을 보관 폴더로 옮기는 실용 스크립트에 붙여 보며 정리합니다. 이 노트북(윈도우 11, Python 3.12.10)에서 직접 돌려 보니, 기본값 그대로 둔 argparse가 --move가 아니라 --mov라고만 쳐도 그대로 받아들여 테스트 파일 6개를 실제로 옮겼습니다. 이런 함정까지 포함해 실행한 순서대로 적습니다.

출발점: sys.argv로 받는 스크립트

테스트용으로 logs 폴더에 수정 시각이 1~120일 전인 파일 40개(.log, .tmp, .csv)를 만들고, 며칠 지난 파일이 몇 개인지 세는 가장 단순한 스크립트부터 짰습니다.

import sys
import time
from pathlib import Path

folder = Path(sys.argv[1])
days = int(sys.argv[2])

cutoff = time.time() - days * 86400
old = [f for f in folder.iterdir() if f.is_file() and f.stat().st_mtime < cutoff]
print(f"{days}일 지난 파일 {len(old)}개")

python clean_v1.py logs 30은 “30일 지난 파일 30개”를 정상 출력합니다. 문제는 사람이 흔히 하는 실수 세 가지를 넣었을 때입니다.

$ python clean_v1.py
    folder = Path(sys.argv[1])
IndexError: list index out of range

$ python clean_v1.py logs 30일
    days = int(sys.argv[2])
ValueError: invalid literal for int() with base 10: '30일'

$ python clean_v1.py 30 logs
ValueError: invalid literal for int() with base 10: 'logs'

인자를 빠뜨리면 IndexError, “30일”처럼 단위를 붙이면 ValueError, 순서를 바꿔도 ValueError입니다. 세 경우 모두 트레이스백만 나오고 어떻게 실행해야 하는지는 알려 주지 않습니다. 스크립트를 짠 본인도 몇 주 뒤에는 코드를 열어 봐야 사용법을 알 수 있습니다.

argparse로 바꾼 완성 코드

같은 일을 하되, 옵션을 몇 개 더 붙였습니다. 폴더는 필수 위치 인자, 나머지는 선택 옵션입니다. 파일을 옮기는 동작은 실수로 실행되면 곤란하므로 기본은 미리보기이고 --move를 줬을 때만 옮깁니다.

"""오래된 파일을 찾아 보여주고, --move를 주면 보관 폴더로 옮깁니다."""
import argparse
import shutil
import sys
import time
from pathlib import Path


def positive_int(text):
    try:
        value = int(text)
    except ValueError:
        raise argparse.ArgumentTypeError(f"정수가 아닙니다: {text!r}")
    if value < 1:
        raise argparse.ArgumentTypeError(f"1 이상이어야 합니다: {value}")
    return value


def existing_dir(text):
    path = Path(text)
    if not path.is_dir():
        raise argparse.ArgumentTypeError(f"폴더가 없습니다: {text}")
    return path


def build_parser():
    parser = argparse.ArgumentParser(
        description="지정한 날짜보다 오래된 파일을 찾아 보관 폴더로 옮깁니다.",
        allow_abbrev=False,
    )
    parser.add_argument("folder", type=existing_dir, help="검사할 폴더")
    parser.add_argument("-d", "--days", type=positive_int, default=30,
                        help="이 일수보다 오래된 파일이 대상 (기본: %(default)s)")
    parser.add_argument("-e", "--ext", action="append",
                        help="대상 확장자 (여러 번 지정 가능, 예: -e .log -e .tmp). 없으면 전체")
    parser.add_argument("--archive", type=Path, default=None,
                        help="보관 폴더 (기본: <folder>/archive)")
    parser.add_argument("--move", action="store_true",
                        help="실제로 옮깁니다. 빼면 목록만 보여 줍니다")
    parser.add_argument("-v", "--verbose", action="store_true", help="파일별 목록 출력")
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    exts = {e.lower() if e.startswith(".") else "." + e.lower() for e in args.ext or []}
    archive = args.archive or args.folder / "archive"
    cutoff = time.time() - args.days * 86400

    targets = []
    for f in sorted(args.folder.iterdir()):
        if not f.is_file():
            continue
        if exts and f.suffix.lower() not in exts:
            continue
        if f.stat().st_mtime < cutoff:
            targets.append(f)

    total = sum(f.stat().st_size for f in targets)
    label = "옮김" if args.move else "미리보기"
    print(f"[{label}] {args.days}일 지난 파일 {len(targets)}개, {total / 1024:.1f}KB"
          + (f" (확장자 {', '.join(sorted(exts))})" if exts else ""))
    if args.verbose:
        for f in targets:
            age = (time.time() - f.stat().st_mtime) / 86400
            print(f"  {f.name:<14} {age:5.0f}일 전")

    if args.move and targets:
        archive.mkdir(parents=True, exist_ok=True)
        for f in targets:
            shutil.move(str(f), archive / f.name)
        print(f"-> {archive}")
    elif not args.move and targets:
        print("실제로 옮기려면 --move 를 붙여 다시 실행하세요.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

핵심만 짚으면 이렇습니다.

  • add_argument("folder")처럼 이름에 -가 없으면 필수 위치 인자, "-d", "--days"처럼 쓰면 선택 옵션입니다. 짧은 이름과 긴 이름을 함께 줄 수 있습니다.
  • type=에는 int 같은 내장 함수뿐 아니라 직접 만든 함수를 넣을 수 있습니다. 함수 안에서 argparse.ArgumentTypeError를 던지면 그 메시지가 그대로 오류로 나갑니다. 위 코드의 positive_int, existing_dir가 그 예입니다.
  • action="store_true"는 값 없이 켜고 끄는 스위치입니다. 옵션을 주면 True, 안 주면 False입니다.
  • main(argv=None)으로 감싸 두면 뒤에서 보듯 명령줄 없이도 파서를 시험할 수 있습니다.

실행 결과: 도움말과 오류 메시지가 공짜로 생깁니다

-h만 붙이면 아래 도움말이 자동으로 만들어집니다. 따로 사용법 문서를 쓰지 않아도 됩니다.

$ python clean.py -h
usage: clean.py [-h] [-d DAYS] [-e EXT] [--archive ARCHIVE] [--move] [-v]
                folder

지정한 날짜보다 오래된 파일을 찾아 보관 폴더로 옮깁니다.

positional arguments:
  folder                검사할 폴더

options:
  -h, --help            show this help message and exit
  -d DAYS, --days DAYS  이 일수보다 오래된 파일이 대상 (기본: 30)
  -e EXT, --ext EXT     대상 확장자 (여러 번 지정 가능, 예: -e .log -e .tmp). 없으면 전체
  --archive ARCHIVE     보관 폴더 (기본: <folder>/archive)
  --move                실제로 옮깁니다. 빼면 목록만 보여 줍니다
  -v, --verbose         파일별 목록 출력

앞에서 트레이스백을 냈던 실수들을 그대로 다시 넣어 봤습니다. 실제 출력에는 각 오류 앞에 위 usage 두 줄이 함께 나오는데, 여기서는 오류 줄만 옮겼습니다.

$ python clean.py
clean.py: error: the following arguments are required: folder

$ python clean.py logs -d 30일
clean.py: error: argument -d/--days: 정수가 아닙니다: '30일'

$ python clean.py logs -d 0
clean.py: error: argument -d/--days: 1 이상이어야 합니다: 0

$ python clean.py 30 logs
clean.py: error: argument folder: 폴더가 없습니다: 30

모두 한 줄짜리 오류와 함께 종료 코드 2로 끝났습니다. 사람이 읽기 쉬울 뿐 아니라, 배치 파일이나 스케줄러에서 종료 코드로 실패를 구분할 수도 있습니다. 순서를 바꾼 30 logs는 existing_dir 검사 덕분에 “폴더가 없습니다: 30″이라고 원인을 짚어 줍니다. 정상 실행 결과는 이렇습니다.

$ python clean.py logs
[미리보기] 30일 지난 파일 30개, 621.8KB
실제로 옮기려면 --move 를 붙여 다시 실행하세요.

$ python clean.py logs -d 90 -e .log -e tmp -v
[미리보기] 90일 지난 파일 7개, 131.8KB (확장자 .log, .tmp)
  job-14.log       104일 전
  job-16.log       112일 전
  job-21.log       116일 전
  job-24.log        98일 전
  job-30.log        96일 전
  job-33.tmp       113일 전
  job-38.log        97일 전
실제로 옮기려면 --move 를 붙여 다시 실행하세요.

-e tmp처럼 점 없이 줘도 코드에서 점을 붙여 .tmp로 처리했습니다. 실행 시간은 매번 0.1초 남짓이었고, python -X importtime으로 재 보니 import argparse에 드는 시간은 약 11.9ms였습니다. 같은 조건에서 sys.argv 버전과 10회씩 번갈아 돌려 봤는데, 노트북 상태에 따라 0.1초에서 1.1초까지 출렁이는 폭이 훨씬 커서 둘의 차이는 구분되지 않았습니다.

함정 1: ArgumentDefaultsHelpFormatter의 (default: None)

기본값을 도움말에 보여 주려고 처음엔 formatter_class=argparse.ArgumentDefaultsHelpFormatter를 넣었습니다. 그런데 기본값이 의미 없는 옵션에도 전부 꼬리표가 붙었습니다.

# ArgumentDefaultsHelpFormatter를 썼던 첫 버전의 도움말 일부
  -e EXT, --ext EXT     대상 확장자 (여러 번 지정 가능, 예: -e .log -e .tmp). 없으면 전체
                        (default: None)
  --archive ARCHIVE     보관 폴더 (기본: <folder>/archive) (default: None)
  --move                실제로 옮깁니다. 빼면 목록만 보여 줍니다 (default: False)
  -v, --verbose         파일별 목록 출력 (default: False)

(default: None), (default: False)는 읽는 사람에게 정보가 아니라 잡음입니다. 최종본에서는 이 포매터를 빼고, 기본값이 의미 있는 --days에만 help 문자열 안에 %(default)s를 직접 적었습니다. 그러면 (기본: 30)처럼 원하는 문구로 들어갑니다.

함정 2: nargs=”+”가 뒤의 폴더 이름까지 먹습니다

확장자를 여러 개 받는 방법으로 nargs="+"(값 하나 이상)도 흔히 쓰입니다. 비교용으로 이 방식의 사본을 만들어 돌려 봤습니다.

# --ext를 nargs="+"로 바꾼 버전
$ python clean_nargs.py -e .log .tmp logs
clean_nargs.py: error: the following arguments are required: folder

$ python clean_nargs.py logs -e .log .tmp
[미리보기] 30일 지난 파일 22개, 455.5KB (확장자 .log, .tmp)

-e .log .tmp logs라고 쓰면 argparse가 logs까지 확장자로 가져가 버려서, 필수 인자인 폴더가 없다는 오류가 납니다. 폴더를 앞에 쓰면 되긴 하지만, 사용자가 그 규칙을 알아야 합니다. 최종본은 action="append"로 바꿔 -e .log -e .tmp처럼 옵션을 매번 붙이게 했습니다. 타이핑은 조금 늘지만 위치 인자와 섞여도 헷갈릴 일이 없습니다.

함정 3: 옵션 약어가 기본으로 허용됩니다

도입부에서 말한 사고입니다. argparse는 기본값(allow_abbrev=True)에서 다른 옵션과 겹치지 않는 한 긴 옵션의 앞부분만 써도 받아 줍니다. --da 100이 --days 100으로 처리되는 식인데, 파일을 실제로 옮기는 --move도 예외가 아니었습니다.

# allow_abbrev를 기본값(True)으로 둔 버전
$ python clean.py logs --mov -d 100 -v
[옮김] 100일 지난 파일 6개, 133.4KB
  job-14.log       104일 전
  job-16.log       112일 전
  job-21.log       116일 전
  job-33.tmp       113일 전
  job-34.csv       104일 전
  job-36.csv       115일 전
-> logs\archive

# allow_abbrev=False 를 넣은 최종본
$ python clean.py logs --mov -d 90
clean.py: error: unrecognized arguments: --mov

약어가 어디까지 통하는지 보려고 --mov를 넣었더니 경고 하나 없이 실제 이동이 일어났고, 확인해 보니 logs에는 34개가 남고 archive에 6개가 들어가 있었습니다. 지금은 편하지만, 나중에 --mode 같은 옵션을 추가하면 같은 약어가 갑자기 다른 의미가 되거나 오류가 됩니다. 삭제·이동·전송처럼 되돌리기 어려운 동작이 있는 스크립트라면 ArgumentParser(allow_abbrev=False)를 넣어 두는 편이 안전합니다. 최종본에서는 같은 명령이 “unrecognized arguments”로 거부됐습니다.

함정 4: 윈도우에서 “경로\” 로 끝나는 따옴표

이건 argparse가 아니라 윈도우가 명령줄을 인자로 나누는 규칙 때문에 생깁니다. 공백이 있는 경로를 따옴표로 감싸면서 끝에 역슬래시를 붙이면 어떻게 되는지, 명령줄 문자열을 그대로 넘기는 테스트 스크립트로 확인했습니다(경로 앞부분은 ...로 줄였습니다).

$ python winquote.py
명령줄: "...\logs" --days 100
  argv : ['...\\logs', '--days', '100']
  결과 : exit 0 | [미리보기] 100일 지난 파일 0개, 0.0KB
명령줄: "...\logs\" --days 100
  argv : ['...\\logs" --days 100']
  결과 : exit 2 | clean.py: error: argument folder: 폴더가 없습니다: ...\logs" --days 100
명령줄: "...\logs\\" --days 100
  argv : ['...\\logs\\', '--days', '100']
  결과 : exit 0 | [미리보기] 100일 지난 파일 0개, 0.0KB

두 번째 경우, 끝의 \"가 “따옴표 글자 하나”로 해석되면서 따옴표가 닫히지 않아 뒤의 --days 100까지 폴더 이름 하나로 합쳐졌습니다. 그래서 파서 입장에선 --days가 아예 없었습니다. 탐색기 주소창에서 경로를 복사해 끝에 역슬래시를 붙이는 습관이 있다면 겪기 쉬운 일입니다. 끝 역슬래시를 빼거나 두 번(\\) 쓰면 정상 동작합니다. 위 결과가 0개인 건 함정 3에서 100일 지난 파일을 이미 옮겨 뒀기 때문입니다.

명령줄 없이 파서 시험하기

parse_args()에 리스트를 넘기면 실제 명령줄 대신 그 값으로 해석합니다. 옵션을 바꿀 때마다 터미널에서 여러 번 실행해 보는 대신, 아래처럼 한 번에 확인할 수 있습니다.

"""parse_args에 리스트를 넘겨 명령줄 없이 파서를 시험"""
from clean import build_parser

parser = build_parser()
cases = [
    ["logs"],
    ["logs", "-d", "90", "-e", ".log", "-e", "tmp", "--move"],
    ["logs", "--days=7", "-v"],
]
for argv in cases:
    a = parser.parse_args(argv)
    print(argv, "->", f"days={a.days} ext={a.ext} move={a.move} verbose={a.verbose}")

for argv in (["logs", "-d", "x"], ["logs", "--mov"]):
    try:
        parser.parse_args(argv)
    except SystemExit as e:
        print(argv, "-> SystemExit", e.code)
$ python test_parser.py
['logs'] -> days=30 ext=None move=False verbose=False
['logs', '-d', '90', '-e', '.log', '-e', 'tmp', '--move'] -> days=90 ext=['.log', 'tmp'] move=True verbose=False
['logs', '--days=7', '-v'] -> days=7 ext=None move=False verbose=True
['logs', '-d', 'x'] -> SystemExit 2
['logs', '--mov'] -> SystemExit 2

잘못된 입력은 SystemExit(코드 2)로 끝나므로 try/except SystemExit로 잡아서 확인합니다. pytest를 쓴다면 같은 리스트를 테스트 함수 안에 넣으면 됩니다.

방식실수했을 때도움말주의할 점
sys.argv 직접트레이스백(IndexError, ValueError)없음인자 순서를 코드로만 알 수 있음
argparse 기본값usage + 한 줄 오류, 종료 코드 2-h로 자동 생성옵션 약어 허용(–mov도 실행됨)
argparse + allow_abbrev=False같음같음약어 거부, 정확한 이름만

마무리

명령줄 옵션은 남을 위한 기능이 아니라 몇 주 뒤의 나를 위한 사용 설명서입니다. 처음부터 argparse로 받아 두면 -h 한 번으로 사용법이 나옵니다.

가장 작은 첫걸음은 지금 sys.argv로 값을 받는 스크립트 하나를 골라 아래 세 가지만 바꿔 보는 것입니다.

  1. sys.argv[n]을 add_argument로 옮기고, 숫자는 type=int(또는 직접 만든 검사 함수)로 받습니다.
  2. 삭제·이동처럼 위험한 동작은 --move 같은 store_true 스위치를 줬을 때만 실행되게 하고, 기본은 미리보기로 둡니다.
  3. ArgumentParser(allow_abbrev=False)를 넣어 약어 실행을 막습니다.

다음 글에서는 AI가 짜 준 파이썬 코드를 실행하기 전에 파일 삭제나 외부 명령 실행 같은 위험한 줄을 자동으로 찾아내는 점검 스크립트를 직접 만들어 보겠습니다.

댓글 남기기