목차
33 장

CLI 도구 만들기 (Typer)

argparse가 아니라 Typer로 타입힌트 우선 CLI를 만드는 법. 서브커맨드, 자동 완성, Rich 출력 통합.

본 책의 마지막 챕터입니다. 5부 (운영·패키징·테스트)의 마무리로, 타입힌트 우선으로 CLI를 만드는 법을 다룹니다. 본 챕터의 결과물을 32장의 흐름으로 PyPI에 출시하면 CLI 제작부터 배포까지의 과정이 완성됩니다.

도구는 Typer입니다 — FastAPI와 같은 저자가 만든 CLI 프레임워크로, 함수의 시그니처가 곧 CLI 인터페이스라는 같은 철학을 공유합니다.

argparse의 한계 #

표준 라이브러리 argparse가 있는데도 Typer를 선택하는 이유는 다음과 같습니다.

argparse로 같은 일 하기
import argparse

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("name", type=str)
    parser.add_argument("--age", type=int, default=0)
    parser.add_argument("--greet", action="store_true")
    args = parser.parse_args()

    if args.greet:
        print(f"Hello, {args.name}! You are {args.age}.")

문제는 셋입니다.

  • 타입힌트가 없음args.name의 타입을 IDE가 모릅니다.
  • boilerplate 많음 — 함수가 하는 일보다 파서 설정 코드가 길어집니다.
  • 검증을 직접 — Enum, Path, 범위 등은 추가 코드.

Typer는 같은 코드를 다음처럼 줄입니다.

Typer로 같은 일 하기
import typer

def main(name: str, age: int = 0, greet: bool = False) -> None:
    if greet:
        print(f"Hello, {name}! You are {age}.")

if __name__ == "__main__":
    typer.run(main)

타입힌트가 곧 검증이고, default가 곧 옵션 기본값이고, bool이 곧 플래그입니다.

설치와 첫 실행 #

uv add typer
hello.py
import typer

def main(name: str) -> None:
    print(f"Hello, {name}!")

if __name__ == "__main__":
    typer.run(main)
$ uv run python hello.py World
Hello, World!

$ uv run python hello.py --help
                                                                                
 Usage: hello.py [OPTIONS] NAME                                                 
                                                                                
╭─ Arguments ────────────────────────────────────────────────────────────────╮
│ *    name      TEXT  [default: None] [required]                            │
╰────────────────────────────────────────────────────────────────────────────╯

--help도 자동 생성됩니다.

옵션 vs 인자 #

규칙은 간단합니다.

  • 기본값이 없으면 인자 (argument) — 호출 시 위치로 전달. mycli hello World.
  • 기본값이 있으면 옵션 (option)--name=World 또는 --name World.
def main(name: str, age: int = 0) -> None:
    ...
  • name → required positional argument.
  • age--age=30 optional.

bool의 default는 False가 자연스럽고, 자동으로 --flag / --no-flag 두 형태가 생깁니다.

def main(verbose: bool = False) -> None: ...
$ mycli --verbose
$ mycli --no-verbose

Annotated 패턴 — 메타데이터 분리 #

기본형은 깔끔하지만 도움말, 짧은 이름, 값 검증 등을 더하려면 Annotated가 표준 패턴입니다.

annotated_example.py
from typing import Annotated

import typer


def main(
    name: Annotated[str, typer.Argument(help="이름을 알려주세요.")],
    age: Annotated[int, typer.Option("--age", "-a", min=0, max=150)] = 0,
    verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
) -> None:
    if verbose:
        print(f"name={name}, age={age}")
    print(f"Hello, {name}!")
$ uv run python annotated_example.py --help
...
 -a, --age          INTEGER RANGE [0<=x<=150]
 -v, --verbose / --no-verbose

Annotated[X, typer.Option(...)]타입은 그대로 두고 메타데이터만 추가 합니다. 이 패턴이 본 책 전체에서 본 — Pydantic, FastAPI, Typer가 공유하는 — 일관된 스타일입니다.

서브커맨드 — Typer 앱 트리 #

git처럼 mycli init, mycli build, mycli publish 같은 서브커맨드를 만들려면 typer.Typer 인스턴스를 만들고 @app.command()를 붙입니다.

src/swcli/cli.py
import typer

app = typer.Typer(help="Schoolofweb sample CLI")


@app.command()
def init(path: str = ".") -> None:
    """프로젝트 초기화."""
    typer.echo(f"init at {path}")


@app.command()
def build() -> None:
    """프로젝트 빌드."""
    typer.echo("building...")


@app.command()
def publish(prod: bool = False) -> None:
    """배포."""
    target = "PRODUCTION" if prod else "STAGING"
    typer.echo(f"publishing to {target}")


if __name__ == "__main__":
    app()
$ swcli --help
 Commands
   init     프로젝트 초기화.
   build    프로젝트 빌드.
   publish  배포.

$ swcli publish --prod
publishing to PRODUCTION

중첩 — 서브-서브커맨드 #

auth_app = typer.Typer(help="인증 관련 명령.")
app.add_typer(auth_app, name="auth")


@auth_app.command()
def login(email: str) -> None:
    typer.echo(f"logging in as {email}")


@auth_app.command()
def logout() -> None:
    typer.echo("logged out")
$ swcli auth login me@example.com
logging in as me@example.com

git의 git remote add, kubectl의 kubectl config set-context처럼 깊은 트리를 만들 수 있습니다.

Rich와 통합 — 색·진행률·테이블 #

Typer는 내부적으로 Rich를 씁니다 — typer.echo 대신 직접 Rich의 Console, Progress, Table을 쓰면 UX가 더 풍부해집니다.

Console #

from rich.console import Console

console = Console()
console.print("[bold green]Success![/]")
console.print("[red]Failed:[/] connection refused")

Progress #

긴 작업의 진행률
import time

from rich.progress import Progress


@app.command()
def crawl(pages: int = 100) -> None:
    with Progress() as progress:
        task = progress.add_task("[cyan]Crawling...", total=pages)
        for _ in range(pages):
            time.sleep(0.05)
            progress.advance(task)

Progress가 자동으로 백분율·ETA·스피너를 그려줍니다.

Table #

조회 결과를 표로
from rich.table import Table


@app.command()
def users() -> None:
    table = Table(title="Users")
    table.add_column("ID", justify="right", style="cyan")
    table.add_column("Email", style="magenta")
    table.add_column("Active", justify="center")

    table.add_row("1", "alice@example.com", "✔")
    table.add_row("2", "bob@example.com", "✘")

    console.print(table)

콘솔이 색을 지원 안 하는 환경 (CI 로그, 파이프)에서는 Rich가 자동으로 색을 끕니다.

환경변수 자동 매핑 #

옵션에 envvar=를 주면 CLI 인자가 없을 때 환경변수에서 자동으로 읽습니다.

@app.command()
def deploy(
    api_key: Annotated[
        str,
        typer.Option(envvar="SWCLI_API_KEY", help="API key"),
    ],
) -> None:
    typer.echo(f"deploying with {api_key[:4]}***")
$ export SWCLI_API_KEY=secret123
$ swcli deploy
deploying with secr***

CI 환경에서 secret을 환경변수로 주는 표준 흐름과 자연스럽게 맞습니다.

자동 완성 (bash · zsh · fish) #

Typer는 한 명령으로 셸 자동 완성을 설치합니다.

swcli --install-completion

설치 후 swcli p<TAB> 하면 publish가 자동 완성됩니다. 서브커맨드의 옵션 (--prod)도 자동 완성됩니다 — 사용자의 학습 비용을 크게 줄입니다.

종료 코드와 에러 처리 #

CLI의 관습은 0 = 성공, 비-0 = 실패입니다. Typer에서는 typer.Exit를 던집니다.

@app.command()
def lint(path: str) -> None:
    errors = run_lint(path)
    if errors:
        typer.secho(f"{len(errors)} errors", fg="red", err=True)
        raise typer.Exit(code=1)
    typer.secho("OK", fg="green")
  • err=True → stderr로 출력 (사용자가 stdout를 파일로 redirect 해도 에러 메시지는 보임).
  • raise typer.Exit(code=1) → 종료 코드 설정.

스크립트와 조합되는 도구라면 stdout와 stderr의 분리가 중요합니다.

테스트 — CliRunner #

Typer는 click의 CliRunner를 그대로 노출합니다. 실제 프로세스를 띄우지 않고 in-process로 테스트합니다.

tests/test_cli.py
from typer.testing import CliRunner

from swcli.cli import app

runner = CliRunner()


def test_hello() -> None:
    result = runner.invoke(app, ["init", "/tmp/myproj"])
    assert result.exit_code == 0
    assert "init at /tmp/myproj" in result.stdout


def test_publish_prod() -> None:
    result = runner.invoke(app, ["publish", "--prod"])
    assert result.exit_code == 0
    assert "PRODUCTION" in result.stdout


def test_missing_required_arg() -> None:
    result = runner.invoke(app, ["init"])
    assert result.exit_code == 0   # init 의 path 는 기본값이 있어 OK

mix_stderr=False로 만들면 stderr를 별도로 검증할 수 있습니다.

Click과의 관계 #

Typer는 내부적으로 Click을 씁니다 — Typer = Click의 타입힌트 우선 wrapper입니다. Click의 풍부한 기능 (Context, pass_context, callback)이 그대로 Typer에서도 동작합니다.

기존 Click 코드베이스에 새 명령을 Typer로 추가하거나, Typer로 만든 도구의 일부에 Click의 고급 기능을 섞는 것도 가능합니다.

흔한 함정 #

typer.Option의 default 위치 #

Annotated를 쓸 때 default는 함수 시그니처의 = 뒤에 옵니다.

# OK
def f(x: Annotated[int, typer.Option()] = 0) -> None: ...

# 비권장 (구식 스타일)
def f(x: int = typer.Option(0)) -> None: ...

구식 스타일은 동작하지만, pyright의 strict 모드에서 default의 타입 추론이 불편해집니다.

모듈을 entry-point로 안 노출 #

32장에서 본 project.scripts의 우항이 잘못되면 설치 후 명령이 안 잡힙니다.

[project.scripts]
swcli = "swcli.cli:app"   # OK — src/swcli/cli.py 의 app 객체

설치 후 which swcli로 확인.

bool 옵션의 --no- prefix가 의도와 다름 #

use_cache: bool = True라고 쓰면 자동으로 --use-cache / --no-use-cache가 생깁니다. --no-use-cache가 어색하면 명시적으로 옵션 이름을 줍니다.

def f(
    no_cache: Annotated[bool, typer.Option("--no-cache")] = False,
) -> None: ...

print vs typer.echo vs console.print #

  • print — 가장 일반적. 보통 OK.
  • typer.echo — click의 echo. Windows의 colorama 호환을 자동 처리.
  • console.print — Rich의 마크업 ([bold red]) 지원.

복잡한 출력이면 console.print, 단순 메시지면 print로 충분합니다. 한 프로젝트 안에서는 하나로 통일.

큰 명령 트리에서 import 비용 #

@app.command()가 붙은 모듈을 모두 import 하면 CLI 시작이 느려집니다. 자주 안 쓰는 명령은 lazy import 하거나, click의 LazyGroup 패턴을 적용합니다.

32장과 묶기 — 출시 과정까지 연결 #

pyproject.toml에 다음만 더하면 본 챕터의 CLI가 패키지로 설치됩니다.

pyproject.toml — 33장 + 32장
[project]
name = "swcli"
version = "0.1.0"
dependencies = [
  "typer>=0.12,<1",
  "rich>=13,<15",
]

[project.scripts]
swcli = "swcli.cli:app"

32장의 흐름대로 uv build && uv publish 하면 사용자가 pip install swcliswcli init, swcli publish --prod 같은 명령을 받을 수 있습니다.

본 책의 끝에서 #

여기까지 오신 분께 한 번 정리합니다.

  • **1부 (1-7장)**에서 변수 · 타입힌트 · 흐름 · 컬렉션 · 함수 · 에러 · pyproject를 봤습니다.
  • **2부 (8-14장)**에서 dataclass · Generic · Protocol · context manager · generator · decorator · pattern matching · asyncio 입문을 다뤘습니다.
  • **3부 (15-21장)**에서 매직 메서드 · descriptor · metaclass · asyncio 깊이 · GIL · typing 고급 · 성능까지 들어갔습니다.
  • **4부 (22-29장)**에서 FastAPI · Pydantic · SQLAlchemy · 인증 · 백그라운드 잡 · 테스트와 배포 · 종합 실습까지 한 웹 서비스를 처음부터 끝까지 만들었습니다.
  • **5부 (30-33장)**에서 정적 검증 · 관측성 · 라이브러리 출시 · CLI까지 운영 가능한 도구로 다듬는 법을 봤습니다.

이 모든 게 한 언어 — Python — 안에서 일관된 멘탈 모델로 연결됩니다. 타입힌트는 검증·문서·자동 완성이 되고, 함수 시그니처는 라우트가 되고 CLI가 되고, async는 웹·백그라운드·DB를 같은 패턴으로 묶고, 도구 체인은 한 pyproject.toml에 모입니다.

연습문제 #

  1. swcli 출시 과정 직접 해보기 — 본 챕터의 swcli를 그대로 만들어 32장의 흐름으로 TestPyPI에 0.0.1로 출시해 보세요. 설치한 다음 swcli --install-completion까지 동작하는지 확인하세요. 어디가 막혔나요?
  2. 하위 명령 트리 확장 — 본인의 일상 작업 하나 (예: “git repo 정리”, “사진 파일 정리”, “API 응답 캐시 비우기”)를 골라 Typer로 서브커맨드 트리가 있는 CLI로 만들어 보세요. 자동 완성과 환경변수 매핑을 활용하면 일상 명령이 얼마나 짧아지나요?
  3. CliRunner의 한계CliRunner가 in-process 라 os.exit 같은 일부 동작은 잡지 못합니다. 본인의 CLI 중 진짜 프로세스를 띄워야 검증되는 동작 (signal 처리, subprocess 호출)이 있다면 subprocess.run(["swcli", ...]) 기반 테스트와 CliRunner 기반 테스트를 어떻게 나누시겠나요?
노트
한 줄 요약 — Typer는 함수 시그니처가 곧 CLI 인터페이스라는 철학으로 argparse의 boilerplate를 없앱니다. Annotated로 메타데이터를 분리하고, Rich로 출력을 풍부하게 하고, CliRunner로 in-process 테스트한 뒤, 32장의 흐름으로 PyPI에 출시하면 제작부터 배포까지 연결됩니다.

본 책의 마지막 챕터입니다. 5부의 첫 챕터 30장 타입체커 설정과 CI 통합부터 본 챕터까지 한 줄로 이어집니다 — 코드의 정확성을 자동화하고 (30장), 운영 중 무슨 일이 일어나는지 보이게 만들고 (31장), 만든 코드를 세상에 출시하고 (32장), 사용자가 손에 잡고 쓰는 인터페이스를 만든다 (33장). 책의 모든 장이 이 한 줄에서 만납니다.

다음은 본인의 프로젝트입니다. 본 책이 그 출발점이 됐길 바랍니다.

X