Python CLI 도구 — argparse·click·typer

관리 스크립트·배포 스크립트·일회성 작업을 명령행 도구로 만드는 세 가지 길

Python에서 명령행 도구를 만드는 세 가지 주요 방법(argparse·click·typer)을 비교한다. 표준 라이브러리(argparse)부터 decorator 기반(click), type hints 기반(typer)까지 각각의 강점과 적합한 상황, 서브커맨드·환경변수·테스트 패턴을 정리한다. MINERVA scripts/manage.py 같은 운영 도구가 정확히 이 토대 위에 있다.

Engineering
저자

Kwangmin Kim

공개

2026년 05월 06일

1 왜 Python CLI를 알아야 하는가

데이터 과학자·엔지니어에게 CLI는 빠르게 잊히는 영역이다 — Jupyter·VSCode 인터페이스만 쓰면 충분하기 때문이다. 그러나 실제 프로젝트가 운영 단계에 들어가면 CLI 도구가 폭발적으로 늘어난다.

MINERVA 운영 도구 역할
python -m scripts.manage user create ... 사용자 관리
python -m scripts.manage docs ingest <path> 문서 벡터 인덱싱
python -m scripts.manage agent eval --suite gold A/B 평가 실행
alembic upgrade head DB 마이그레이션
pytest tests/ -k "snapshot" 테스트 필터 실행
pre-commit run --all-files 린트·포맷

운영 자동화·배포 스크립트·일회성 데이터 작업이 모두 CLI 도구다. 본 글은 Python에서 CLI를 만드는 세 가지 길과 선택 기준, 그리고 MINERVA 11-0 환경변수 운영·12-0 테스트 같은 운영 도구가 의존하는 패턴을 정리한다.

2 세 가지 라이브러리 비교

라이브러리 특징 적합한 경우
argparse (표준) Python 내장, 외부 의존 없음 단순한 스크립트, 외부 패키지 추가 부담
click Decorator 기반, 풍부한 기능 서브커맨드 많은 도구 (git 같은)
typer Type hints 기반, FastAPI 스타일 현대적 type-driven 코드, 빠른 prototype

대부분의 경우 typer 권장 — type hints만 쓰면 자동 처리, FastAPI와 일관된 모양. 외부 의존 회피·표준 라이브러리 고집이 강하면 argparse.

3 argparse — 표준 라이브러리

# scripts/simple_cli.py
import argparse


def main():
    parser = argparse.ArgumentParser(description="문서 인덱싱 스크립트")
    parser.add_argument("path", help="인덱싱할 디렉토리")
    parser.add_argument("--collection", default="default", help="대상 컬렉션 이름")
    parser.add_argument("--batch-size", type=int, default=32, help="임베딩 배치 크기")
    parser.add_argument("-v", "--verbose", action="store_true", help="상세 로그")
    args = parser.parse_args()

    print(f"Indexing {args.path}{args.collection} (batch={args.batch_size})")
    if args.verbose:
        print("verbose mode")


if __name__ == "__main__":
    main()
python scripts/simple_cli.py ./docs --collection minerva --batch-size 64 -v

강점: - 표준 라이브러리 — 외부 의존 없음 - 거의 모든 Python 환경에서 동작 (도커 슬림 이미지·구버전 Python 포함) - 타입 변환 (type=int)·choices·required 모두 표준

약점: - 서브커맨드 (git처럼 git add·git commit)는 boilerplate가 많음 - 도움말 디자인이 단조로움 - 자동완성 지원 직접 구현 필요

3.1 argparse 서브커맨드

import argparse


def cmd_user_create(args):
    print(f"Creating user: {args.email}")


def cmd_docs_ingest(args):
    print(f"Ingesting: {args.path}")


def main():
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", required=True)

    # user 그룹
    user = subparsers.add_parser("user")
    user_sub = user.add_subparsers(dest="action", required=True)

    user_create = user_sub.add_parser("create")
    user_create.add_argument("--email", required=True)
    user_create.set_defaults(func=cmd_user_create)

    # docs 그룹
    docs = subparsers.add_parser("docs")
    docs_sub = docs.add_subparsers(dest="action", required=True)

    docs_ingest = docs_sub.add_parser("ingest")
    docs_ingest.add_argument("path")
    docs_ingest.set_defaults(func=cmd_docs_ingest)

    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
python scripts/manage.py user create --email user@example.com
python scripts/manage.py docs ingest ./data

서브커맨드 깊이가 깊어질수록 click·typer로 전환할 가치가 커진다.

4 click — Decorator 기반

pip install click
# scripts/manage.py (click 버전)
import click


@click.group()
def cli():
    """MINERVA 운영 도구."""
    pass


@cli.group()
def user():
    """사용자 관리."""
    pass


@user.command("create")
@click.option("--email", required=True, help="사용자 이메일")
@click.option("--role", type=click.Choice(["admin", "user", "guest"]), default="user")
def user_create(email: str, role: str):
    """새 사용자 생성."""
    click.echo(f"Creating: {email} ({role})")


@cli.group()
def docs():
    """문서 인덱싱·관리."""
    pass


@docs.command("ingest")
@click.argument("path", type=click.Path(exists=True))
@click.option("--collection", default="default")
@click.option("--batch-size", default=32, type=int)
def docs_ingest(path: str, collection: str, batch_size: int):
    """디렉토리를 벡터 컬렉션에 인덱싱."""
    click.echo(f"Ingesting {path}{collection} (batch={batch_size})")


if __name__ == "__main__":
    cli()
python -m scripts.manage user create --email u@x.com
python -m scripts.manage docs ingest ./data --batch-size 64
python -m scripts.manage --help

강점: - 서브커맨드가 자연 — @cli.group() + @user.command() 패턴 - 풍부한 helper — click.echo·click.confirm·click.progressbar·click.prompt - 자동완성 — pip install click-completion으로 bash·zsh·fish 지원 - 타입 시스템 — click.Path(exists=True)·click.DateTime()·click.Choice()

약점: - decorator 기반이라 type hints만으로 자동 처리는 안 됨 — 명시적 @click.option - 함수 시그니처와 decorator 정의가 중복

4.1 click 진행률·확인 prompt

import click
import time


@click.command()
@click.argument("path")
@click.option("--force", is_flag=True, help="확인 없이 진행")
def ingest(path: str, force: bool):
    if not force:
        if not click.confirm(f"{path}를 인덱싱할까요?"):
            click.echo("취소")
            return

    items = list(range(100))
    with click.progressbar(items, label="Indexing") as bar:
        for item in bar:
            time.sleep(0.05)

    click.secho("완료", fg="green", bold=True)


if __name__ == "__main__":
    ingest()

click.secho는 색·스타일 지원, terminal에서만 색상 출력하고 redirect 시 자동 평문.

5 typer — Type Hints 기반

pip install typer
# scripts/manage.py (typer 버전)
import typer
from enum import Enum
from pathlib import Path
from typing import Annotated

app = typer.Typer(help="MINERVA 운영 도구")
user_app = typer.Typer(help="사용자 관리")
docs_app = typer.Typer(help="문서 인덱싱·관리")
app.add_typer(user_app, name="user")
app.add_typer(docs_app, name="docs")


class Role(str, Enum):
    admin = "admin"
    user = "user"
    guest = "guest"


@user_app.command("create")
def user_create(
    email: Annotated[str, typer.Option(help="사용자 이메일")],
    role: Annotated[Role, typer.Option()] = Role.user,
):
    """새 사용자 생성."""
    typer.echo(f"Creating: {email} ({role.value})")


@docs_app.command("ingest")
def docs_ingest(
    path: Annotated[Path, typer.Argument(exists=True)],
    collection: Annotated[str, typer.Option()] = "default",
    batch_size: Annotated[int, typer.Option()] = 32,
):
    """디렉토리를 벡터 컬렉션에 인덱싱."""
    typer.echo(f"Ingesting {path}{collection} (batch={batch_size})")


if __name__ == "__main__":
    app()
python -m scripts.manage user create --email u@x.com --role admin
python -m scripts.manage docs ingest ./data --batch-size 64

강점: - Type hints 자체가 schema — 타입 변환·검증·도움말 모두 자동 - FastAPI와 동일한 패턴 — Pydantic·BaseModel 학습이 그대로 전이 - decorator 1개 + 함수 시그니처만으로 정의 - Rich 통합 — 컬러 도움말·테이블·트레이스백 자동 - click 위에 빌드 — click의 모든 기능 사용 가능

약점: - 외부 의존 (click + typer) - Annotated 패턴이 Python 3.9+ 필요 (3.8 호환성) - type hints에 익숙하지 않은 팀원 적응 필요

5.1 typer + Rich 통합

import typer
from rich.console import Console
from rich.table import Table
from rich.progress import track

app = typer.Typer()
console = Console()


@app.command()
def list_users():
    table = Table(title="Users")
    table.add_column("ID", style="cyan")
    table.add_column("Email", style="green")
    table.add_column("Role")

    for row in [("1", "alice@x.com", "admin"), ("2", "bob@x.com", "user")]:
        table.add_row(*row)

    console.print(table)


@app.command()
def reindex():
    for _ in track(range(100), description="Reindexing..."):
        time.sleep(0.05)
    console.print("[bold green]done[/bold green]")

Rich가 자동으로 색상·진행률·테이블을 처리. typer는 Rich와 자연스럽게 결합.

6 환경변수·설정 파일 통합

CLI 옵션과 환경변수를 같이 받는 패턴 — --db-url 또는 DB_URL 환경변수.

6.1 click

@click.command()
@click.option(
    "--db-url",
    envvar="DB_URL",                    # 환경변수에서 자동 읽음
    required=True,
    help="DB connection string",
)
def migrate(db_url: str):
    click.echo(f"Migrating: {db_url}")

6.2 typer

@app.command()
def migrate(
    db_url: Annotated[str, typer.Option(envvar="DB_URL")],
):
    typer.echo(f"Migrating: {db_url}")

6.3 Pydantic Settings 통합 (운영 권장)

환경변수·dotenv 글에서 다룬 Pydantic Settings를 CLI에서 그대로 사용:

# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    db_url: str
    openai_api_key: str
    azure_blob_url: str

    model_config = SettingsConfigDict(env_file=".env", env_prefix="MINERVA_")


# scripts/manage.py
import typer
from config import Settings

app = typer.Typer()


def get_settings():
    return Settings()       # 환경변수 + .env 자동


@app.command()
def migrate():
    settings = get_settings()
    typer.echo(f"Migrating: {settings.db_url}")

CLI는 명시적 옵션을 받는 데 집중하고, 인프라 구성(DB·API key)은 Pydantic Settings에 위임 — 이 분리가 12-factor app 패턴.

7 에러 처리와 exit code

CLI는 정상 종료 시 exit 0, 오류는 비-0 — bash·CI가 이걸로 분기한다.

# argparse
import sys

def main():
    try:
        ...
    except FileNotFoundError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    except KeyboardInterrupt:
        sys.exit(130)       # SIGINT 표준
# click
import click

@click.command()
def cmd():
    if not Path("config.yaml").exists():
        raise click.ClickException("config.yaml not found")  # exit 1, stderr 출력
    if missing_data:
        click.echo("data missing", err=True)
        raise click.exceptions.Exit(2)
# typer
import typer

@app.command()
def cmd():
    if not Path("config.yaml").exists():
        typer.echo("config.yaml not found", err=True)
        raise typer.Exit(code=1)

bash에서 활용:

python -m scripts.manage docs ingest ./data
if [ $? -ne 0 ]; then
    echo "ingest failed"
    exit 1
fi

# 또는 chain
python -m scripts.manage docs ingest ./data && python -m scripts.manage agent eval

shell scripting 글에서 다룬 set -e·exit code chain이 정확히 이 토대 위.

8 테스트 — CliRunner

CLI 도구를 함수처럼 테스트한다.

8.1 click

from click.testing import CliRunner
from scripts.manage import cli


def test_user_create():
    runner = CliRunner()
    result = runner.invoke(cli, ["user", "create", "--email", "a@x.com"])
    assert result.exit_code == 0
    assert "Creating: a@x.com" in result.output


def test_docs_ingest_missing_path():
    runner = CliRunner()
    result = runner.invoke(cli, ["docs", "ingest", "/nonexistent"])
    assert result.exit_code != 0

8.2 typer

from typer.testing import CliRunner
from scripts.manage import app


def test_user_create():
    runner = CliRunner()
    result = runner.invoke(app, ["user", "create", "--email", "a@x.com"])
    assert result.exit_code == 0
    assert "Creating: a@x.com" in result.stdout

pytest 글의 fixture·parametrize와 결합하면 CLI 회귀 테스트가 자연스럽다.

9 MINERVA scripts/manage.py 패턴

MINERVA 11-0편 운영 도구가 사용하는 패턴 (typer 기반).

# scripts/manage.py (개요)
import typer
from app.config import settings           # Pydantic Settings
from app.db import session_factory
from app.indexing import vectorize_directory

app = typer.Typer()
user_app = typer.Typer()
docs_app = typer.Typer()
agent_app = typer.Typer()
app.add_typer(user_app, name="user")
app.add_typer(docs_app, name="docs")
app.add_typer(agent_app, name="agent")


@docs_app.command("ingest")
def docs_ingest(
    path: Path,
    collection: str = "minerva",
    batch_size: int = 32,
):
    """디렉토리 → 벡터 컬렉션."""
    with session_factory() as session:
        result = vectorize_directory(
            session, path, collection=collection, batch_size=batch_size
        )
    typer.echo(f"Indexed {result.count} docs")


@agent_app.command("eval")
def agent_eval(
    suite: str = typer.Option("gold", help="평가 suite 이름"),
    out: Path = typer.Option("eval_results.json"),
):
    """A/B 평가 실행."""
    from app.evaluation import run_eval
    summary = run_eval(suite=suite)
    out.write_text(summary.model_dump_json(indent=2))
    typer.echo(f"Saved: {out}")


if __name__ == "__main__":
    app()

이 도구가 운영에서 하는 역할: - 새 환경 셋업 (사용자·DB 마이그레이션·초기 인덱싱) - 운영 중 데이터 보강 (docs ingest) - 평가 회귀 (agent eval --suite gold) - 디버깅 (agent debug-trace --run-id ...)

CLI 진입점이 pyproject.toml에 등록되면 pip install -e .minerva docs ingest ...로 실행:

# pyproject.toml
[project.scripts]
minerva = "scripts.manage:app"

10 자주 발생하는 오류 패턴

WRONG:

parser.add_argument("--verbose", type=bool, default=False)
# ↑ argparse는 type=bool을 다르게 해석한다
# --verbose=False도 True로 평가됨 (비어있지 않은 string)

CORRECT:

parser.add_argument("--verbose", action="store_true")
# --verbose 있으면 True, 없으면 False

argparse에서 boolean 플래그는 action="store_true". type=bool은 함정.

WRONG:

@app.command()
def run(name: str = typer.Option("default", help="...")):
    ...
# ↑ typer 0.9 이전 패턴, deprecated

CORRECT:

@app.command()
def run(name: Annotated[str, typer.Option(help="...")] = "default"):
    ...

typer 0.9+에서는 Annotated 패턴이 권장. 기본값은 시그니처에 그대로, 메타데이터는 Annotated 안에.

WRONG:

@app.command()
def migrate():
    if not check_db():
        typer.echo("DB unreachable")
        return                          # exit code 0 — bash가 성공으로 판단

CORRECT:

@app.command()
def migrate():
    if not check_db():
        typer.echo("DB unreachable", err=True)
        raise typer.Exit(code=1)        # exit 1로 명확히 실패 표시

CI·bash 스크립트가 exit code로 분기. 실패는 반드시 raise typer.Exit(code=1) 또는 sys.exit(1).

WRONG:

@app.command()
def export():
    typer.echo("Starting export...")    # 진행 메시지
    print(json.dumps(data))             # 데이터 출력
# ↑ 둘 다 stdout — pipe로 받으면 메시지가 데이터에 섞임

CORRECT:

@app.command()
def export():
    typer.echo("Starting export...", err=True)   # 진행은 stderr
    print(json.dumps(data))                       # 데이터만 stdout
python -m scripts.manage export | jq .          # 깔끔하게 jq에 전달

“진행 메시지는 stderr, 결과 데이터는 stdout” — Unix 철학.

WRONG:

@app.command()
def reindex(force: bool = False):
    ...
# ↑ --help가 옵션 이름만 표시, 의미 알 수 없음

CORRECT:

@app.command()
def reindex(
    force: Annotated[bool, typer.Option(help="확인 prompt 없이 진행")] = False,
):
    """벡터 인덱스 전체 재구축."""        # docstring → 명령 도움말
    ...

docstring·help 옵션이 곧 사용자 매뉴얼. 6개월 후의 본인이 읽는다.

11 정리

영역 argparse click typer
의존성 표준 외부 외부 (click 위)
정의 방식 imperative parser decorator type hints + decorator
서브커맨드 가능 (boilerplate ↑) 자연 자연
환경변수 통합 직접 처리 envvar= typer.Option(envvar=)
진행률·prompt 직접 구현 click.progressbar·click.prompt typer or Rich
자동완성 직접 구현 click-completion typer 내장
테스트 subprocess + 직접 호출 CliRunner CliRunner
추천 의존 회피 시 서브커맨드 많은 도구 신규 프로젝트 (현대적)

12 응용 분야

시나리오 추천
일회성 데이터 변환 스크립트 argparse (단일 파일)
운영 관리 도구 (CRUD 다양) typer
배포 자동화 (서브커맨드 깊음) click 또는 typer
ML 실험 launcher (config 많음) typer + Pydantic Settings
MINERVA scripts/manage.py typer + Rich + Pydantic Settings
pre-commit hook argparse (의존 최소)

13 관련 주제

선행 학습

MINERVA 시리즈 응용

다른 카테고리 연결

Subscribe

Enjoy this blog? Get notified of new posts by email: