Skip to content
주제
Python
Python Argparse: 명령줄 인터페이스를 제대로 만드는 법

Python Argparse: 명령줄 인터페이스를 제대로 만드는 법

업데이트

당신은 필요한 방식대로 데이터를 처리하는 Python 스크립트를 작성했습니다. 그런데 동료가 그 스크립트를 사용하고 싶다고 요청합니다. "14번째 줄의 파일 이름과 37번째 줄의 임계값만 바꾸면 돼,"라고 당신은 말합니다. 그런데 동료는 다른 줄을 바꿉니다. 스크립트는 깨집니다. 당신은 자신의 정상 동작하던 코드에 남의 수정 사항을 디버깅하느라 20분을 씁니다. 이런 일은 누군가 매번 매개변수를 조금씩 조정해야 할 때 반복되며, 스크립트가 커질수록 더 심해집니다. 스크립트 안에 하드코딩된 값은 확장성이 없습니다 -- 팀 작업에도, 자동화에도, 운영 환경에도 적합하지 않습니다.

Python의 argparse 모듈은 어떤 스크립트든 제대로 된 명령줄 도구로 바꿔줍니다. 사용자는 스크립트를 실행할 때 인자를 전달합니다. 이 모듈은 파싱, 타입 변환, 검증, 도움말 메시지 생성을 처리합니다. 표준 라이브러리에 포함되어 있으므로 설치할 것이 없습니다. 이 가이드는 기본 위치 인자부터 서브커맨드와 실전 CLI 설계 패턴까지, argparse의 모든 핵심 기능을 다룹니다.

argparse가 하는 일과 sys.argv보다 나은 이유

모든 Python 스크립트는 명령줄에서 들어온 문자열의 원시 목록인 sys.argv에 접근할 수 있습니다. 이를 수동으로 파싱할 수도 있습니다:

import sys
 
filename = sys.argv[1]
threshold = float(sys.argv[2])
verbose = "--verbose" in sys.argv

이 방법은 일회성 스크립트에는 통하지만 금방 한계가 드러납니다. 도움말 메시지가 없습니다. 타입 검증도 없습니다. 사용자가 인자를 빼먹었을 때의 오류 메시지도 없습니다. 선택적 플래그를 제대로 처리할 방법도 없습니다. 파라미터를 하나 추가하거나 제거하는 순간 인덱싱은 깨집니다.

argparse는 선언형 API로 이 모든 문제를 해결합니다. 스크립트가 어떤 인자를 받는지 정의하면, 나머지는 argparse가 처리합니다:

import argparse
 
parser = argparse.ArgumentParser(description="Process data files")
parser.add_argument("filename", help="Input file to process")
parser.add_argument("--threshold", type=float, default=0.5, help="Detection threshold")
parser.add_argument("--verbose", action="store_true", help="Enable detailed output")
args = parser.parse_args()
 
print(f"Processing {args.filename} with threshold {args.threshold}")

무료로 얻는 것들:

  • 자동 --help: python script.py --help를 실행하면 사용자는 모든 인자, 타입, 기본값을 볼 수 있습니다.
  • 타입 변환: --threshold 0.8은 자동으로 float가 됩니다. 누군가 --threshold abc를 넘기면 argparse가 명확한 오류를 출력합니다.
  • 검증: 필수 인자를 빼먹었을 때 IndexError traceback 대신 친절한 오류 메시지가 나옵니다.
  • 유연한 순서: 선택적 인자는 어떤 순서로든 올 수 있습니다. --verbose --threshold 0.8--threshold 0.8 --verbose는 동일하게 동작합니다.

도움말 출력은 다음처럼 보입니다:

$ python script.py --help
usage: script.py [-h] [--threshold THRESHOLD] [--verbose] filename
 
Process data files
 
positional arguments:
  filename              Input file to process
 
options:
  -h, --help            show this help message and exit
  --threshold THRESHOLD Detection threshold
  --verbose             Enable detailed output

첫 CLI 스크립트 만들기

처음부터 완전한 동작 예제를 만들어 봅시다. 이 도구는 사용자 이름으로 인사를 하며, 인사를 몇 번 반복할지 선택적으로 지정할 수 있습니다.

#!/usr/bin/env python3
"""greet.py -- A simple greeting CLI tool."""
 
import argparse
 
def main():
    parser = argparse.ArgumentParser(
        prog="greet",
        description="Greet someone by name",
    )
    parser.add_argument("name", help="The person to greet")
    parser.add_argument(
        "-c", "--count",
        type=int,
        default=1,
        help="Number of times to greet (default: 1)",
    )
    parser.add_argument(
        "-u", "--uppercase",
        action="store_true",
        help="Print greeting in uppercase",
    )
 
    args = parser.parse_args()
 
    greeting = f"Hello, {args.name}!"
    if args.uppercase:
        greeting = greeting.upper()
 
    for _ in range(args.count):
        print(greeting)
 
if __name__ == "__main__":
    main()

터미널에서 테스트해 보세요:

$ python greet.py Alice
Hello, Alice!
 
$ python greet.py Bob --count 3
Hello, Bob!
Hello, Bob!
Hello, Bob!
 
$ python greet.py Charlie -c 2 -u
HELLO, CHARLIE!
HELLO, CHARLIE!
 
$ python greet.py
usage: greet [-h] [-c COUNT] [-u] name
greet: error: the following arguments are required: name

이 스크립트에서 세 가지를 주목하세요:

  1. 파싱 로직은 모듈 최상위가 아니라 main() 안에 있습니다. 이렇게 해야 인자 파싱을 실행하지 않고도 import할 수 있습니다.
  2. if __name__ == "__main__" 가드는 스크립트가 직접 실행될 때만 main()이 실행되도록 보장합니다. import할 때는 실행되지 않습니다.
  3. 짧은/긴 플래그(-c--count)는 사용자가 간결함과 명확성 중 선택할 수 있게 합니다.

이 세 가지 패턴은 잘 작성된 모든 argparse 스크립트에 등장합니다. 처음부터 지키세요.

위치 인자

위치 인자는 대시 없이 정의합니다. 기본적으로 필수이며, 명령줄에서의 위치로 매칭됩니다.

import argparse
 
parser = argparse.ArgumentParser(description="Copy files")
parser.add_argument("source", help="Source file path")
parser.add_argument("destination", help="Destination file path")
args = parser.parse_args()
 
print(f"Copying {args.source} -> {args.destination}")
$ python copy.py report.csv /backup/report.csv
Copying report.csv -> /backup/report.csv

nargs로 여러 위치 값 받기

nargs 매개변수는 인자가 몇 개의 값을 소비할지 제어합니다:

import argparse
 
parser = argparse.ArgumentParser(description="Merge files")
 
# One or more files (at least one required)
parser.add_argument("files", nargs="+", help="Files to merge")
 
# Exactly two values
parser.add_argument("--range", nargs=2, type=int, metavar=("START", "END"),
                    help="Row range to extract")
 
args = parser.parse_args()
print(f"Merging: {args.files}")
if args.range:
    print(f"Range: {args.range[0]} to {args.range[1]}")
$ python merge.py data1.csv data2.csv data3.csv --range 10 500
Merging: ['data1.csv', 'data2.csv', 'data3.csv']
Range: 10 to 500

nargs 전체 참조는 다음과 같습니다:

nargs valueMeaningResult typeExample
(omitted)Exactly one valueSingle value"input.csv"
N (integer)Exactly N valuesList of N items[10, 100]
"?"Zero or one valueSingle value or default"config.yml" or None
"*"Zero or more valuesList (possibly empty)[] or ["a", "b"]
"+"One or more valuesList (error if empty)["a"] or ["a", "b"]
argparse.REMAINDERAll remaining argsListEverything after this arg

위치 인자에 대한 타입 변환

위치 인자는 기본적으로 문자열입니다. type을 추가하면 변환할 수 있습니다:

import argparse
 
parser = argparse.ArgumentParser(description="Calculate rectangle area")
parser.add_argument("width", type=float, help="Rectangle width")
parser.add_argument("height", type=float, help="Rectangle height")
args = parser.parse_args()
 
area = args.width * args.height
print(f"Area: {area:.2f}")
$ python area.py 3.5 7.2
Area: 25.20
 
$ python area.py three 7.2
usage: area.py [-h] width height
area.py: error: argument width: invalid float value: 'three'

오류 메시지는 자동으로 생성되며, 무엇이 잘못됐는지 정확히 알려줍니다.

선택적 인자

선택적 인자는 -(짧은 형식) 또는 --(긴 형식)으로 시작합니다. 기본적으로 선택 사항이며, 어떤 순서로든 올 수 있습니다.

import argparse
 
parser = argparse.ArgumentParser(description="Data exporter")
parser.add_argument("-o", "--output", default="output.csv", help="Output file path")
parser.add_argument("-d", "--delimiter", default=",", help="Field delimiter")
parser.add_argument("-v", "--verbose", action="store_true", help="Show detailed progress")
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress all output")
 
args = parser.parse_args()
print(f"Output: {args.output}")
print(f"Delimiter: {repr(args.delimiter)}")
print(f"Verbose: {args.verbose}")
$ python export.py --output results.tsv --delimiter "\t" --verbose
Output: results.tsv
Delimiter: '\\t'
Verbose: True
 
$ python export.py -o results.json -v
Output: results.json
Delimiter: ','
Verbose: True

기본값

모든 선택적 인자는 기본값을 가집니다. 명시적으로 설정하지 않으면 기본값은 None입니다:

import argparse
 
parser = argparse.ArgumentParser()
parser.add_argument("--name", default="World")      # Default: "World"
parser.add_argument("--port", type=int, default=8080) # Default: 8080
parser.add_argument("--log-file")                     # Default: None
 
args = parser.parse_args()
print(f"Name: {args.name}, Port: {args.port}, Log: {args.log_file}")

참고: 인자 이름에 대시(--log-file)가 있으면, argparse는 속성 이름에서는 밑줄로 바꿉니다: args.log_file.

store_true와 store_false 액션

불리언 플래그는 값을 받지 않습니다. 존재하거나 존재하지 않거나 둘 중 하나입니다:

import argparse
 
parser = argparse.ArgumentParser()
parser.add_argument("--verbose", action="store_true", help="Enable verbose mode")
parser.add_argument("--no-cache", action="store_true", help="Disable caching")
parser.add_argument("--dry-run", action="store_true", help="Preview without executing")
 
args = parser.parse_args()
# --verbose present -> args.verbose is True
# --verbose absent  -> args.verbose is False

verbosity 레벨을 위한 count 액션

일부 도구는 반복 플래그를 사용해 verbosity 수준을 표현합니다(-v, -vv, -vvv):

import argparse
 
parser = argparse.ArgumentParser()
parser.add_argument("-v", "--verbose", action="count", default=0,
                    help="Increase verbosity (-v, -vv, -vvv)")
args = parser.parse_args()
 
if args.verbose >= 3:
    print("TRACE level: showing everything")
elif args.verbose >= 2:
    print("DEBUG level: detailed output")
elif args.verbose >= 1:
    print("INFO level: progress updates")
else:
    print("Default: errors only")
$ python tool.py -vvv
TRACE level: showing everything

반복 인자를 위한 append 액션

사용자가 같은 플래그를 여러 번 지정할 수 있어야 한다면 action="append"를 사용하세요:

import argparse
 
parser = argparse.ArgumentParser()
parser.add_argument("-t", "--tag", action="append", default=[],
                    help="Add a tag (can be repeated)")
args = parser.parse_args()
print(f"Tags: {args.tag}")
$ python tool.py -t bug -t urgent -t backend
Tags: ['bug', 'urgent', 'backend']

타입과 선택값

내장 타입 변환

type 매개변수는 문자열을 받아 값을 반환하는 어떤 호출 가능 객체든 받을 수 있습니다. 내장 타입, 파일 경로에 대한 pathlib.Path, 혹은 사용자 정의 함수를 사용할 수 있습니다:

import argparse
from pathlib import Path
 
parser = argparse.ArgumentParser()
parser.add_argument("--count", type=int, help="Number of items")
parser.add_argument("--rate", type=float, help="Processing rate")
parser.add_argument("--config", type=Path, help="Config file path")
args = parser.parse_args()

choices로 값 제한하기

choices를 사용하면 인자를 특정 허용 값으로 제한할 수 있습니다:

import argparse
 
parser = argparse.ArgumentParser(description="Deploy application")
parser.add_argument("environment", choices=["dev", "staging", "production"],
                    help="Target environment")
parser.add_argument("--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"],
                    default="INFO", help="Logging level")
parser.add_argument("--replicas", type=int, choices=range(1, 11),
                    default=1, help="Number of replicas (1-10)")
args = parser.parse_args()
$ python deploy.py testing
usage: deploy.py [-h] {dev,staging,production}
deploy.py: error: argument environment: invalid choice: 'testing'
  (choose from 'dev', 'staging', 'production')

사용자 정의 타입 함수

사용자 정의 타입 함수는 argparse의 가장 강력한 기능 중 하나입니다. 파싱 시점에 입력을 검증하고 변환할 수 있습니다:

import argparse
from datetime import datetime
 
def positive_int(value):
    """Accept only positive integers."""
    ivalue = int(value)
    if ivalue <= 0:
        raise argparse.ArgumentTypeError(f"{value} is not a positive integer")
    return ivalue
 
def date_type(value):
    """Parse YYYY-MM-DD date strings."""
    try:
        return datetime.strptime(value, "%Y-%m-%d").date()
    except ValueError:
        raise argparse.ArgumentTypeError(
            f"Invalid date: '{value}'. Expected format: YYYY-MM-DD"
        )
 
def percentage(value):
    """Accept floats between 0 and 100."""
    fvalue = float(value)
    if not 0 <= fvalue <= 100:
        raise argparse.ArgumentTypeError(
            f"{value} is not a valid percentage (0-100)"
        )
    return fvalue
 
parser = argparse.ArgumentParser()
parser.add_argument("--workers", type=positive_int, default=4)
parser.add_argument("--since", type=date_type, help="Start date (YYYY-MM-DD)")
parser.add_argument("--sample", type=percentage, default=100.0,
                    help="Sample percentage (0-100)")
args = parser.parse_args()
$ python tool.py --workers -3
error: argument --workers: -3 is not a positive integer
 
$ python tool.py --since 2026-13-45
error: argument --since: Invalid date: '2026-13-45'. Expected format: YYYY-MM-DD
 
$ python tool.py --sample 150
error: argument --sample: 150 is not a valid percentage (0-100)

모든 검증 오류는 main 코드에서 if/else 로직을 직접 작성하지 않아도 명확하고 실행 가능한 메시지를 출력합니다.

필수 선택적 인자

선택적 인자를 필수로 만들 수도 있습니다:

import argparse
 
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True, help="Path to config file")
parser.add_argument("--output", required=True, help="Output directory")
parser.add_argument("--format", default="json", help="Output format")
args = parser.parse_args()
$ python tool.py --format csv
usage: tool.py [-h] --config CONFIG --output OUTPUT [--format FORMAT]
tool.py: error: the following arguments are required: --config, --output

이런 경우가 때때로 코드 냄새인 이유: "선택적" 인자가 모두 필수라면, 사실은 위치 인자가 더 적합할 가능성이 큽니다. required optionals는 플래그 이름이 명확성을 높일 때(--config가 단순한 경로보다 더 명확할 때)나, 필수 매개변수가 많아서 순서 실수를 막고 싶을 때 유용합니다.

정말로 반드시 필요한 입력에는 보통 위치 인자가 더 적절합니다:

# Instead of --config (required optional)
parser.add_argument("config", help="Path to config file")
 
# Use required optionals when the name matters
parser.add_argument("--api-key", required=True, help="API authentication key")

상호 배타 그룹

때로는 인자끼리 충돌합니다. 예를 들어 스크립트가 JSON 또는 CSV 중 하나로 출력할 수는 있지만 둘 다 동시에 출력할 수는 없습니다. add_mutually_exclusive_group()은 이런 제약을 강제합니다:

import argparse
 
parser = argparse.ArgumentParser(description="Export data")
 
# Output format: pick exactly one
format_group = parser.add_mutually_exclusive_group(required=True)
format_group.add_argument("--json", action="store_true", help="Export as JSON")
format_group.add_argument("--csv", action="store_true", help="Export as CSV")
format_group.add_argument("--parquet", action="store_true", help="Export as Parquet")
 
# Verbosity: pick at most one
verbosity = parser.add_mutually_exclusive_group()
verbosity.add_argument("-v", "--verbose", action="store_true", help="Detailed output")
verbosity.add_argument("-q", "--quiet", action="store_true", help="Minimal output")
 
parser.add_argument("input_file", help="Input data file")
args = parser.parse_args()
 
if args.json:
    print(f"Exporting {args.input_file} as JSON")
elif args.csv:
    print(f"Exporting {args.input_file} as CSV")
elif args.parquet:
    print(f"Exporting {args.input_file} as Parquet")
$ python export.py data.csv --json --csv
error: argument --csv: not allowed with argument --json
 
$ python export.py data.csv
error: one of the arguments --json --csv --parquet is required
 
$ python export.py data.csv --parquet -v
Exporting data.csv as Parquet

형식 선택을 위해 별도 플래그 대신 choices를 쓰는 실용적인 대안도 있습니다:

import argparse
 
parser = argparse.ArgumentParser()
parser.add_argument("--format", choices=["json", "csv", "parquet"],
                    required=True, help="Output format")
args = parser.parse_args()

두 방식 모두 유효합니다. 상호 배타 그룹은 각 옵션이 추가 인자를 가질 때 더 좋습니다. choices 방식은 옵션이 단순한 문자열일 때 더 좋습니다.

add_subparsers로 서브커맨드 만들기

실전 CLI 도구는 서브커맨드를 사용합니다. git commit, docker build, pip install을 떠올려 보세요. 각 서브커맨드는 자신만의 인자 집합, 자신만의 도움말, 자신만의 핸들러 함수를 가집니다. argparse는 add_subparsers()를 통해 이 패턴을 지원합니다:

#!/usr/bin/env python3
"""project.py -- A project management CLI tool."""
 
import argparse
 
def handle_init(args):
    """Initialize a new project."""
    print(f"Creating project '{args.name}'")
    print(f"  Template: {args.template}")
    print(f"  Directory: {args.name}/")
 
def handle_build(args):
    """Build the project."""
    mode = "release" if args.optimize else "debug"
    print(f"Building in {mode} mode")
    if args.target:
        print(f"  Target: {args.target}")
 
def handle_test(args):
    """Run tests."""
    print(f"Running tests (coverage={'on' if args.coverage else 'off'})")
    if args.pattern:
        print(f"  Pattern: {args.pattern}")
 
def handle_deploy(args):
    """Deploy the project."""
    print(f"Deploying to {args.environment}")
    if args.dry_run:
        print("  (DRY RUN -- no actual deployment)")
 
def main():
    # Top-level parser
    parser = argparse.ArgumentParser(
        prog="project",
        description="Manage your project lifecycle",
    )
    parser.add_argument("--version", action="version", version="project 1.0.0")
 
    subparsers = parser.add_subparsers(dest="command", required=True,
                                        help="Available commands")
 
    # init subcommand
    init_cmd = subparsers.add_parser("init", help="Create a new project")
    init_cmd.add_argument("name", help="Project name")
    init_cmd.add_argument("--template", default="basic",
                          choices=["basic", "web", "api", "ml"],
                          help="Project template (default: basic)")
    init_cmd.set_defaults(func=handle_init)
 
    # build subcommand
    build_cmd = subparsers.add_parser("build", help="Build the project")
    build_cmd.add_argument("--optimize", action="store_true",
                           help="Enable release optimizations")
    build_cmd.add_argument("--target", help="Build target platform")
    build_cmd.set_defaults(func=handle_build)
 
    # test subcommand
    test_cmd = subparsers.add_parser("test", help="Run tests")
    test_cmd.add_argument("--coverage", action="store_true",
                          help="Generate coverage report")
    test_cmd.add_argument("--pattern", "-p", help="Test name pattern to match")
    test_cmd.set_defaults(func=handle_test)
 
    # deploy subcommand
    deploy_cmd = subparsers.add_parser("deploy", help="Deploy the project")
    deploy_cmd.add_argument("environment",
                            choices=["staging", "production"],
                            help="Target environment")
    deploy_cmd.add_argument("--dry-run", action="store_true",
                            help="Preview without deploying")
    deploy_cmd.set_defaults(func=handle_deploy)
 
    args = parser.parse_args()
    args.func(args)
 
if __name__ == "__main__":
    main()
$ python project.py init myapp --template web
Creating project 'myapp'
  Template: web
  Directory: myapp/
 
$ python project.py build --optimize
Building in release mode
 
$ python project.py test --coverage -p "test_api*"
Running tests (coverage=on)
  Pattern: test_api*
 
$ python project.py deploy production --dry-run
Deploying to production
  (DRY RUN -- no actual deployment)
 
$ python project.py --help
usage: project [-h] [--version] {init,build,test,deploy} ...
 
Manage your project lifecycle
 
positional arguments:
  {init,build,test,deploy}
                        Available commands
    init                Create a new project
    build               Build the project
    test                Run tests
    deploy              Deploy the project
 
$ python project.py deploy --help
usage: project deploy [-h] [--dry-run] {staging,production}
 
positional arguments:
  {staging,production}  Target environment
 
options:
  -h, --help            show this help message and exit
  --dry-run             Preview without deploying

여기서 핵심 패턴은 set_defaults(func=handler)입니다. 각 서브커맨드는 자신의 핸들러 함수를 파싱된 namespace에 저장하고, main 함수는 args.func(args)로 이를 호출합니다. 이것이 프로덕션 CLI 도구에서 표준적으로 쓰이는 방식입니다.

더 나은 도움말을 위한 Argument Group

도구에 인자가 많아지면 논리적인 제목 아래로 묶을 수 있습니다:

import argparse
 
parser = argparse.ArgumentParser(description="Data pipeline tool")
 
input_group = parser.add_argument_group("Input options")
input_group.add_argument("--source", required=True, help="Data source path")
input_group.add_argument("--format", choices=["csv", "json", "parquet"], default="csv")
input_group.add_argument("--encoding", default="utf-8", help="File encoding")
 
transform_group = parser.add_argument_group("Transform options")
transform_group.add_argument("--filter", help="Filter expression")
transform_group.add_argument("--sort-by", help="Column to sort by")
transform_group.add_argument("--limit", type=int, help="Max rows to process")
 
output_group = parser.add_argument_group("Output options")
output_group.add_argument("-o", "--output", required=True, help="Output path")
output_group.add_argument("--compress", action="store_true", help="Compress output")
 
args = parser.parse_args()

--help 출력은 인자들을 제목별로 그룹화하여, 긴 인자 목록을 한눈에 보기 쉽게 만듭니다. 이것은 순전히 표시용 기능이며, 인자 파싱 방식 자체는 바꾸지 않습니다.

커스텀 도움말과 포맷팅

formatter_class 매개변수

import argparse
 
parser = argparse.ArgumentParser(
    prog="analyzer",
    description="Analyze datasets and generate reports.\n\n"
                "Supports CSV, JSON, and Parquet input formats.\n"
                "Output can be filtered, sorted, and aggregated.",
    epilog="Examples:\n"
           "  analyzer data.csv --format json --top 10\n"
           "  analyzer data.csv --columns name age --sort-by age\n"
           "  analyzer data.csv --filter 'age > 30' --output results.csv",
    formatter_class=argparse.RawDescriptionHelpFormatter,
)
FormatterWhat it does
HelpFormatterDefault. Wraps text to fit terminal width.
RawDescriptionHelpFormatterPreserves newlines in description and epilog.
RawTextHelpFormatterPreserves newlines everywhere, including argument help.
ArgumentDefaultsHelpFormatterAppends (default: X) to every argument's help text.

metavar와 %(default)s

사용 메시지에 표시되는 내용을 제어할 수 있습니다:

import argparse
 
parser = argparse.ArgumentParser()
parser.add_argument("--top", type=int, default=10, metavar="N",
                    help="Show top N results (default: %(default)s)")
parser.add_argument("--format", default="table", metavar="FMT",
                    help="Output format (default: %(default)s)")

이렇게 하면 usage 텍스트에 --top TOP 대신 --top N이 표시되고, 도움말에는 실제 기본값이 나타납니다.

argparse vs sys.argv vs click vs typer

Python에는 명령줄 인자 파싱을 위한 여러 방법이 있습니다. 비교는 다음과 같습니다:

Featuresys.argvargparseclicktyper
Part of stdlibYesYesNo (pip install)No (pip install)
Type conversionManualBuilt-inBuilt-inAutomatic (type hints)
Help generationNoneAutomaticAutomaticAutomatic
SubcommandsManualadd_subparsers()@group.command()app.command()
ValidationManualchoices, custom typeclick.Choice, callbacksValidators
BoilerplateHighMediumLowVery low
API styleImperativeImperativeDecoratorsDecorators + type hints
Tab completionNoneNoneVia pluginBuilt-in
Prompting / colorsManualNoneBuilt-inBuilt-in
TestingManualparse_args([...])CliRunnerCliRunner
Best forThrowaway scriptsStdlib-only projectsComplex CLIsModern Python 3.7+

같은 도구를 각 방식으로 구현하면 다음과 같습니다:

# --- sys.argv: raw, fragile, no help ---
import sys
 
name = sys.argv[1] if len(sys.argv) > 1 else "World"
count = int(sys.argv[2]) if len(sys.argv) > 2 else 1
for _ in range(count):
    print(f"Hello, {name}!")
# --- argparse: stdlib, explicit argument definitions ---
import argparse
 
parser = argparse.ArgumentParser(description="Greet someone")
parser.add_argument("name", nargs="?", default="World", help="Name to greet")
parser.add_argument("-c", "--count", type=int, default=1, help="Repetitions")
args = parser.parse_args()
for _ in range(args.count):
    print(f"Hello, {args.name}!")
# --- click: decorators, pip install click ---
import click
 
@click.command()
@click.argument("name", default="World")
@click.option("-c", "--count", default=1, type=int, help="Repetitions")
def greet(name, count):
    """Greet someone."""
    for _ in range(count):
        click.echo(f"Hello, {name}!")
 
greet()
# --- typer: type hints, pip install typer ---
import typer
 
def greet(name: str = "World", count: int = 1):
    """Greet someone."""
    for _ in range(count):
        print(f"Hello, {name}!")
 
typer.run(greet)

각각을 언제 사용할까:

  • sys.argv -- 하나의 값만 가져와서 검증도 크게 필요 없는 일회성 스크립트.
  • argparse -- 진짜 CLI가 필요하지만 외부 의존성을 추가할 수 없을 때. 대부분의 프로젝트에 적합한 기본 선택입니다.
  • click -- 중첩된 명령 그룹, 대화형 프롬프트, 컬러 출력, 플러그인 시스템이 있는 복잡한 CLI를 만들 때.
  • typer -- 보일러플레이트를 최소화하고 싶고 Python 3.7+를 사용할 때. type hints를 기반으로 인자를 추론하며 click 위에서 동작합니다.

실전 프로젝트: CSV 데이터 처리 CLI

여기서는 CSV 파일을 읽고, 데이터를 필터링하고, 집계를 수행하고, 여러 형식으로 결과를 출력하는 생산용 스타일의 CLI 도구를 보여드립니다. 이 예제는 argparse 패턴이 실제 프로젝트에서 어떻게 결합되는지 보여줍니다.

#!/usr/bin/env python3
"""csv_processor.py -- A CLI tool for analyzing CSV data."""
 
import argparse
import csv
import json
import sys
from collections import defaultdict
from pathlib import Path
 
 
def read_csv(filepath, encoding="utf-8"):
    """Read a CSV file and return a list of dictionaries."""
    with open(filepath, "r", encoding=encoding) as f:
        reader = csv.DictReader(f)
        return list(reader)
 
 
def cmd_info(args):
    """Display information about a CSV file."""
    rows = read_csv(args.file, args.encoding)
    columns = list(rows[0].keys()) if rows else []
 
    print(f"File:    {args.file}")
    print(f"Rows:    {len(rows)}")
    print(f"Columns: {len(columns)}")
 
    if args.verbose:
        print("\nColumn details:")
        for col in columns:
            non_empty = sum(1 for r in rows if r[col].strip())
            unique = len(set(r[col] for r in rows))
            print(f"  {col:30s}  non-empty: {non_empty:>6d}  unique: {unique:>6d}")
 
 
def cmd_filter(args):
    """Filter rows where a column matches a value."""
    rows = read_csv(args.file, args.encoding)
 
    if args.column not in rows[0]:
        print(f"Error: column '{args.column}' not found.", file=sys.stderr)
        print(f"Available columns: {', '.join(rows[0].keys())}", file=sys.stderr)
        sys.exit(1)
 
    if args.match == "exact":
        filtered = [r for r in rows if r[args.column] == args.value]
    elif args.match == "contains":
        filtered = [r for r in rows if args.value.lower() in r[args.column].lower()]
    elif args.match == "startswith":
        filtered = [r for r in rows if r[args.column].startswith(args.value)]
    elif args.match == "gt":
        threshold = float(args.value)
        filtered = [r for r in rows if _safe_float(r[args.column], float("-inf")) > threshold]
    elif args.match == "lt":
        threshold = float(args.value)
        filtered = [r for r in rows if _safe_float(r[args.column], float("inf")) < threshold]
 
    print(f"Matched {len(filtered)} of {len(rows)} rows", file=sys.stderr)
    _output_rows(filtered, args)
 
 
def cmd_aggregate(args):
    """Group by a column and compute aggregations."""
    rows = read_csv(args.file, args.encoding)
 
    if args.group_by not in rows[0]:
        print(f"Error: column '{args.group_by}' not found.", file=sys.stderr)
        sys.exit(1)
 
    groups = defaultdict(list)
    for row in rows:
        key = row[args.group_by]
        groups[key].append(row)
 
    results = []
    for key, group_rows in sorted(groups.items()):
        result = {args.group_by: key, "count": len(group_rows)}
 
        if args.sum_column:
            total = sum(_safe_float(r[args.sum_column], 0) for r in group_rows)
            result[f"sum_{args.sum_column}"] = round(total, 2)
 
        if args.avg_column:
            values = [_safe_float(r[args.avg_column], None) for r in group_rows]
            values = [v for v in values if v is not None]
            if values:
                result[f"avg_{args.avg_column}"] = round(sum(values) / len(values), 2)
 
        results.append(result)
 
    if args.sort_by == "count":
        results.sort(key=lambda r: r["count"], reverse=True)
 
    if args.top:
        results = results[:args.top]
 
    _output_rows(results, args)
 
 
def cmd_convert(args):
    """Convert CSV to another format."""
    rows = read_csv(args.file, args.encoding)
 
    if args.columns:
        rows = [{k: r.get(k, "") for k in args.columns} for r in rows]
 
    if args.limit:
        rows = rows[:args.limit]
 
    _output_rows(rows, args)
    count = min(args.limit, len(rows)) if args.limit else len(rows)
    print(f"Converted {count} rows", file=sys.stderr)
 
 
def _safe_float(value, default):
    """Try to convert a string to float, return default on failure."""
    try:
        return float(value)
    except (ValueError, TypeError):
        return default
 
 
def _output_rows(rows, args):
    """Write rows to the specified output in the specified format."""
    if not rows:
        return
 
    output_file = open(args.output, "w", newline="") if args.output else sys.stdout
 
    if args.format == "json":
        json.dump(rows, output_file, indent=2)
        output_file.write("\n")
    elif args.format == "csv":
        writer = csv.DictWriter(output_file, fieldnames=rows[0].keys())
        writer.writeheader()
        writer.writerows(rows)
    elif args.format == "tsv":
        writer = csv.DictWriter(output_file, fieldnames=rows[0].keys(),
                                delimiter="\t")
        writer.writeheader()
        writer.writerows(rows)
    elif args.format == "table":
        _print_table(rows, output_file)
 
    if args.output:
        output_file.close()
 
 
def _print_table(rows, out):
    """Print rows as an aligned text table."""
    if not rows:
        return
 
    headers = list(rows[0].keys())
    widths = {h: len(h) for h in headers}
    for row in rows:
        for h in headers:
            widths[h] = max(widths[h], len(str(row.get(h, ""))))
 
    header_line = "  ".join(h.ljust(widths[h]) for h in headers)
    separator = "  ".join("-" * widths[h] for h in headers)
    out.write(header_line + "\n")
    out.write(separator + "\n")
    for row in rows:
        line = "  ".join(str(row.get(h, "")).ljust(widths[h]) for h in headers)
        out.write(line + "\n")
 
 
def main():
    parser = argparse.ArgumentParser(
        prog="csv_processor",
        description="Analyze, filter, aggregate, and convert CSV files.",
        epilog="Examples:\n"
               "  csv_processor info sales.csv --verbose\n"
               "  csv_processor filter sales.csv --column region --value East\n"
               "  csv_processor aggregate sales.csv --group-by region --sum revenue\n"
               "  csv_processor convert sales.csv --format json -o sales.json\n",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--encoding", default="utf-8", help="File encoding")
 
    subparsers = parser.add_subparsers(dest="command", required=True,
                                        help="Available commands")
 
    # --- info ---
    info_p = subparsers.add_parser("info", help="Show CSV file information")
    info_p.add_argument("file", help="CSV file to inspect")
    info_p.add_argument("-v", "--verbose", action="store_true",
                        help="Show per-column statistics")
    info_p.set_defaults(func=cmd_info, format="table", output=None)
 
    # --- filter ---
    filter_p = subparsers.add_parser("filter", help="Filter rows by column value")
    filter_p.add_argument("file", help="Input CSV file")
    filter_p.add_argument("--column", "-c", required=True, help="Column to filter on")
    filter_p.add_argument("--value", "-V", required=True, help="Value to match against")
    filter_p.add_argument("--match", "-m",
                          choices=["exact", "contains", "startswith", "gt", "lt"],
                          default="exact", help="Match mode (default: exact)")
    filter_p.add_argument("--format", "-f",
                          choices=["csv", "json", "tsv", "table"],
                          default="table", help="Output format")
    filter_p.add_argument("-o", "--output", help="Output file (default: stdout)")
    filter_p.set_defaults(func=cmd_filter)
 
    # --- aggregate ---
    agg_p = subparsers.add_parser("aggregate", help="Group and aggregate data")
    agg_p.add_argument("file", help="Input CSV file")
    agg_p.add_argument("--group-by", "-g", required=True, help="Column to group by")
    agg_p.add_argument("--sum", dest="sum_column", help="Column to sum")
    agg_p.add_argument("--avg", dest="avg_column", help="Column to average")
    agg_p.add_argument("--sort-by", choices=["name", "count"], default="name",
                        help="Sort results by name or count")
    agg_p.add_argument("--top", type=int, help="Show only top N groups")
    agg_p.add_argument("--format", "-f",
                        choices=["csv", "json", "tsv", "table"],
                        default="table", help="Output format")
    agg_p.add_argument("-o", "--output", help="Output file (default: stdout)")
    agg_p.set_defaults(func=cmd_aggregate)
 
    # --- convert ---
    conv_p = subparsers.add_parser("convert", help="Convert CSV to another format")
    conv_p.add_argument("file", help="Input CSV file")
    conv_p.add_argument("--format", "-f", required=True,
                        choices=["json", "tsv", "csv", "table"],
                        help="Target format")
    conv_p.add_argument("--columns", nargs="+", metavar="COL",
                        help="Include only these columns")
    conv_p.add_argument("--limit", type=int, help="Max rows to convert")
    conv_p.add_argument("-o", "--output", help="Output file (default: stdout)")
    conv_p.set_defaults(func=cmd_convert)
 
    args = parser.parse_args()
    args.func(args)
 
 
if __name__ == "__main__":
    main()

이 도구는 서브커맨드, 커스텀 타입 처리, 여러 출력 형식, 인자 그룹, 적절한 오류 보고를 결합합니다. 각 서브커맨드는 자신만의 집중된 인자 집합을 가집니다. 사용자는 각 서브커맨드에 대한 전체 --help를 볼 수 있습니다.

결과 시각화하기: 이런 CLI 도구로 CSV 데이터를 필터링하거나 집계한 뒤에는 보통 결과를 시각적으로 살펴보고 싶습니다. PyGWalker (opens in a new tab)는 pandas DataFrame을 바로 대화형 Tableau 스타일 시각화 인터페이스로 바꿔줍니다 -- Jupyter 노트북이나 스크립트에서 직접 사용할 수 있습니다. CLI 출력 결과를 DataFrame으로 넘기고 PyGWalker를 사용하면, 직접 플로팅 코드를 작성하지 않고도 차트를 만들 수 있습니다:

import pandas as pd
import pygwalker as pyg
 
# Load the CSV processor output
df = pd.read_csv("aggregated_results.csv")
 
# Launch interactive visual explorer
walker = pyg.walk(df)

이런 CLI 도구를 대화형으로 개발할 때는 RunCell (opens in a new tab)을 사용하면 AI 지원과 함께 Jupyter 안에서 CLI 도구를 만들고 테스트할 수 있습니다. 노트북 셀에서 인자 파싱 로직을 프로토타입하고, 다양한 인자 조합을 시험한 뒤, 단독 스크립트로 내보낼 수 있습니다.

흔한 오류와 해결법

1. error: unrecognized arguments

정의하지 않은 인자를 넘길 때 발생합니다:

$ python tool.py --output results.csv --compress
error: unrecognized arguments: --compress

해결: 파서에 해당 인자를 추가하거나, 의도적으로 알 수 없는 인자를 무시하려면 parse_known_args()를 사용하세요:

args, unknown = parser.parse_known_args()
# args contains recognized arguments
# unknown is a list of unrecognized strings

2. error: argument --count: invalid int value

$ python tool.py --count three
error: argument --count: invalid int value: 'three'

해결: 이는 argparse가 올바르게 동작하는 것입니다. 사용자가 type=int 인자에 정수가 아닌 문자열을 넘겼기 때문입니다. 사용자 정의 타입 함수는 ArgumentTypeError를 명확한 메시지와 함께 발생시켜야 합니다.

3. 대시가 있는 속성 이름

parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
 
# This is a syntax error:
# print(args.log-level)  # Python interprets this as args.log minus level
 
# Correct:
print(args.log_level)  # Dashes become underscores

4. 서브커맨드가 아무 동작도 하지 않음

# Problem: no error when no subcommand is given
subparsers = parser.add_subparsers(dest="command")
# Solution: add required=True
subparsers = parser.add_subparsers(dest="command", required=True)

required=True가 없으면, 스크립트를 서브커맨드 없이 실행했을 때 아무 일도 일어나지 않고 조용히 끝납니다. Python 3.7+에서는 subparsers에 항상 required=True를 설정하세요.

5. 전파로 인한 중복 도움말

부모 파서와 서브파서가 같은 인자를 둘 다 정의하면 사용자가 혼란스러워합니다:

# BAD: --verbose defined on both parent and child
parser.add_argument("--verbose", action="store_true")
sub = subparsers.add_parser("run")
sub.add_argument("--verbose", action="store_true")  # Shadows parent's --verbose
 
# GOOD: put shared arguments on the parent only
parser.add_argument("--verbose", action="store_true")

6. import 시 parse_args() 실행됨

# BAD: parse_args() fires when another module imports this file
parser = argparse.ArgumentParser()
parser.add_argument("--name")
args = parser.parse_args()  # Crashes if imported without CLI args
 
# GOOD: wrap everything in main()
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--name")
    args = parser.parse_args()
    return args
 
if __name__ == "__main__":
    main()

7. FileType이 파일을 너무 일찍 연다

# Risky: file is opened at parse time, before your code validates other args
parser.add_argument("output", type=argparse.FileType("w"))
 
# Safer: accept a path string, open the file yourself with a context manager
parser.add_argument("output", help="Output file path")
args = parser.parse_args()
with open(args.output, "w") as f:
    f.write("data")

고급 패턴

공유 인자를 위한 Parent Parser

여러 서브커맨드가 같은 인자 집합을 공유할 때는 parent parser를 사용해 중복을 줄이세요:

import argparse
 
# Shared arguments
parent = argparse.ArgumentParser(add_help=False)
parent.add_argument("--verbose", "-v", action="store_true")
parent.add_argument("--output", "-o", default="output.csv")
 
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
 
# Both subcommands inherit --verbose and --output
cmd_a = subparsers.add_parser("analyze", parents=[parent])
cmd_a.add_argument("file", help="File to analyze")
 
cmd_b = subparsers.add_parser("compare", parents=[parent])
cmd_b.add_argument("file_a", help="First file")
cmd_b.add_argument("file_b", help="Second file")

환경 변수 기본값

배포 유연성을 위해 환경 변수에서 기본값을 가져올 수 있습니다:

import argparse
import os
 
parser = argparse.ArgumentParser()
parser.add_argument("--api-key",
                    default=os.environ.get("API_KEY"),
                    help="API key (or set API_KEY env var)")
parser.add_argument("--port", type=int,
                    default=int(os.environ.get("PORT", "8080")),
                    help="Server port (default: $PORT or 8080)")
args = parser.parse_args()
 
if not args.api_key:
    parser.error("--api-key is required (or set API_KEY environment variable)")

argparse 스크립트 테스트하기

unittest나 pytest 테스트에 argparse를 통합할 수 있습니다. parse_args()에 인자 목록을 넘겨 CLI 도구를 테스트하세요:

import argparse
 
def create_parser():
    parser = argparse.ArgumentParser()
    parser.add_argument("name")
    parser.add_argument("--count", type=int, default=1)
    return parser
 
# In your test file
def test_parser_defaults():
    parser = create_parser()
    args = parser.parse_args(["Alice"])
    assert args.name == "Alice"
    assert args.count == 1
 
def test_parser_with_options():
    parser = create_parser()
    args = parser.parse_args(["Bob", "--count", "5"])
    assert args.name == "Bob"
    assert args.count == 5

create_parser()main()과 분리하면, 전체 스크립트를 실행하지 않고도 인자 정의를 테스트할 수 있습니다.

FAQ

Python argparse는 무엇에 사용하나요?

Python argparse는 명령줄 인자를 파싱하기 위한 표준 라이브러리 모듈입니다. sys.argv의 문자열을 읽어 타입이 있는 Python 객체로 변환하고, 입력을 검증하며, --help 출력을 생성하고, 사용자가 잘못된 인자를 제공했을 때 오류를 보고합니다. 스크립트를 소스 코드를 수정하지 않고도 설정 가능한 값을 받는 제대로 된 CLI 도구로 바꿀 때 사용합니다.

argparse에서 위치 인자와 선택적 인자의 차이는 무엇인가요?

위치 인자는 이름에 대시가 없습니다(예: parser.add_argument("filename")). 기본적으로 필수이며 위치로 매칭됩니다. 선택적 인자는 대시로 시작합니다(예: parser.add_argument("--verbose")). 기본적으로 선택 사항이며 어떤 순서로든 올 수 있습니다. required=True를 사용하면 선택적 인자를 필수로 만들 수 있습니다.

git commit이나 docker build 같은 서브커맨드는 어떻게 만드나요?

parser.add_subparsers()를 사용해 서브커맨드 그룹을 만듭니다. 각 서브커맨드마다 subparsers.add_parser("name")를 호출하세요. 각 서브파서는 자신만의 인자 집합을 가집니다. set_defaults(func=handler_function)으로 각 서브커맨드와 핸들러를 연결한 뒤, args.func(args)로 분기합니다.

Python CLI에서 argparse와 click 중 무엇을 써야 하나요?

외부 의존성이 전혀 필요 없고 CLI가 단순하다면 argparse를 사용하세요. 대화형 프롬프트, 컬러 터미널 출력, 진행률 표시줄, 깊게 중첩된 명령 그룹 같은 고급 기능이 필요하면 click을 사용하세요. Typer는 Python 타입 힌트를 사용하며 click보다 더 적은 보일러플레이트로 작업할 수 있는 또 다른 선택지입니다.

argparse에서 사용자 정의 입력 타입을 어떻게 검증하나요?

문자열을 받아 변환된 값을 반환하는 함수를 작성하세요. 입력이 유효하지 않으면 설명적인 메시지와 함께 argparse.ArgumentTypeError를 발생시키면 됩니다. 이 함수를 type 매개변수로 전달하세요: parser.add_argument("--date", type=my_date_parser). argparse가 이 함수를 자동으로 호출하고, 검증에 실패하면 오류 메시지를 표시합니다.

argparse를 환경 변수와 함께 사용할 수 있나요?

네. default 매개변수를 os.environ에서 읽도록 설정하세요: parser.add_argument("--api-key", default=os.environ.get("API_KEY")). 이렇게 하면 사용자가 명령줄 플래그를 통해 값을 덮어쓸 수 있으면서도 환경 변수로 설정할 수 있습니다.

결론

Python argparse는 스크립트를 취약한 "소스 코드를 직접 수정하는 도구"에서 제대로 된 명령줄 프로그램으로 바꿔줍니다. 이 모듈은 타입 변환, 검증, 도움말 생성, 오류 메시지 처리를 맡아주므로, 당신은 입력을 받는 방법이 아니라 스크립트가 하는 일에 집중할 수 있습니다.

기억해야 할 패턴:

  • 필수 입력에는 위치 인자를, 선택적 인자에는 대시가 붙은 인자를 사용하세요.
  • 자동 변환에는 type, 제한된 값에는 choices, 여러 값에는 nargs를 사용하세요.
  • 불리언 플래그에는 action="store_true", verbosity 레벨에는 action="count"를 사용하세요.
  • 복잡한 도구는 add_subparsers()set_defaults(func=handler) 디스패치 패턴으로 만드세요.
  • 인자끼리 충돌할 때는 상호 배타 그룹을 사용하세요.
  • 파싱은 main() 안에 넣고 if __name__ == "__main__"로 감싸세요.
  • 도메인별 검증에는 사용자 정의 타입 함수를 작성하세요.

argparse는 Python에서 가장 최신의 인자 파싱 라이브러리는 아닙니다. 하지만 모든 Python 설치에 포함되어 있고, 대부분의 CLI 요구를 처리하며, 사용자가 기대하는 방식으로 동작하는 도구를 만들어 줍니다. 대부분의 Python 개발자에게는 이것이 올바른 선택입니다. CLI 도구가 외부 명령을 실행해야 한다면, subprocess 모듈과 argparse를 함께 사용해 완전한 명령줄 자동화 솔루션을 구성할 수 있습니다.

관련 가이드

📚