목차 ▼
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 swcli 후 swcli 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