같은 CLI를 Python으로 다시 만들기




시리즈 소개 이 시리즈는 TypeScript로 실전에서 쓸 수 있는 CLI 도구를 직접 만들어보며 CLI Wrapper의 개념과 설계를 익히는 본편 5부작 + 번외편 2부입니다. 이번 편은 번외 1편으로, 본편에서 만든 greet-cligitx를 Python으로 포팅합니다. TypeScript 코드와 줄 단위로 대조하며 “설계 원리는 언어 독립적”을 실증합니다.


들어가며

본편 1편에서 이런 말을 했었습니다.

CLI Wrapper의 본질은 “사용자 입력 → 무언가 실행 → 가공된 출력”이라는 단순한 파이프라인이다. 언어·라이브러리·배포 방식이 달라져도 이 본질은 변하지 않는다.

이번 편은 그 주장을 코드로 증명합니다. TypeScript로 만든 두 도구를 Python으로 포팅하면서, 어느 부분이 언어 차이이고 어느 부분이 설계 원리인지 선명하게 드러낼 거예요.

이 편을 읽으면 얻는 것:

  • Python으로 같은 CLI를 만들 때의 라이브러리 선택과 관용구
  • TypeScript와 Python의 차이가 의미 있는 부분그냥 표기 차이인 부분의 구분
  • 본편의 설계 판단들이 언어를 바꿔도 유효함을 확인

본편을 완주하지 않으셨다면 이 편은 큰 의미가 없습니다. 본편 3~4편을 먼저 읽고 오시길 권합니다.


1. 라이브러리 매핑

먼저 우리가 쓸 라이브러리들을 한눈에 정리합니다.

용도TypeScriptPython
인자 파싱commanderclick
색상 출력chalkrich
로딩 스피너orarich.console.Console.status
HTTP 클라이언트fetch (내장)httpx
대화형 프롬프트@inquirer/promptsquestionary
외부 프로세스child_process.spawnsubprocess.run
테스트vitestpytest + unittest.mock
배포npm publishpip via pyproject.toml

두 생태계 모두 각 역할에 사실상 표준이 있습니다. Python에서는 특히 clickrich가 지배적이에요. 많은 모던 CLI 도구들(예: poetry, pipx, rich-cli)이 이 조합을 씁니다.

2. 프로젝트 구조

greet-cli-py/
├── pyproject.toml
├── greet_cli/
│   ├── __init__.py           ← CLI 엔트리 (TS의 index.ts)
│   ├── __main__.py           ← python -m greet_cli 지원
│   └── commands/
│       ├── __init__.py
│       ├── hello.py
│       ├── weather.py
│       └── quote.py
└── tests/
    └── test_greeting.py

TypeScript의 src/ 와 1:1로 대응합니다. 차이는 Python 패키지를 명시하는 __init__.py 와, python -m greet_cli 실행을 위한 __main__.py 정도입니다.

pyproject.toml — package.json의 자리

[project]
name = "greet-cli"
version = "0.1.0"
description = "A simple CLI for greetings, weather, and quotes"
requires-python = ">=3.10"
dependencies = [
    "click>=8.0",
    "rich>=13.0",
    "httpx>=0.27",
]

[project.scripts]

greet = “greet_cli:cli”

[build-system]

requires = [“hatchling”] build-backend = “hatchling.build”

2편에서 본 package.json의 주요 필드와 놀랍도록 비슷합니다.

package.json (TS)pyproject.toml (Py)역할
"bin": { "greet": "dist/index.js" }[project.scripts] greet = "greet_cli:cli"터미널 명령어 등록
"engines": { "node": ">=18" }requires-python = ">=3.10"필수 런타임 버전
"dependencies"dependencies런타임 의존성

언어가 달라도 “무엇을 선언해야 하는가”는 동일합니다. 이게 설계 원리의 힘이에요.

3. hello 명령어 — 인자 파싱 비교

두 구현을 나란히 놓고 봅시다. TypeScript 먼저.

// src/index.ts
program
  .command('hello')
  .description('인사 메시지를 출력합니다')
  .option('-n, --name <n>', '인사할 대상 이름', 'world')
  .option('-l, --lang <lang>', '언어 (en, ko, ja)', 'en')
  .action((opts: { name: string; lang: string }) => {
    const greetings: Record<string, (n: string) => string> = {
      en: (n) => `Hello, ${n}!`,
      ko: (n) => `안녕하세요, ${n}님!`,
      ja: (n) => `こんにちは、${n}さん!`,
    };
    const fn = greetings[opts.lang];
    if (!fn) {
      console.error(`Unsupported language: ${opts.lang}`);
      process.exit(1);
    }
    console.log(fn(opts.name));
  });

Python 버전:

# greet_cli/__init__.py
import click

@click.group()
@click.version_option(version="0.1.0", prog_name="greet")
def cli() -> None:
    """인사와 공개 API 정보를 제공하는 CLI"""


@cli.command()
@click.option("-n", "--name", default="world", help="인사할 대상 이름")
@click.option(
    "-l", "--lang",
    type=click.Choice(["en", "ko", "ja"]),
    default="en",
    help="언어",
)
def hello(name: str, lang: str) -> None:
    """인사 메시지를 출력합니다"""
    run_hello(name, lang)


# greet_cli/commands/hello.py
import sys

GREETINGS = {
    "en": lambda n: f"Hello, {n}!",
    "ko": lambda n: f"안녕하세요, {n}님!",
    "ja": lambda n: f"こんにちは、{n}さん!",
}


def run_hello(name: str, lang: str) -> None:
    fn = GREETINGS.get(lang)
    if not fn:
        print(f"Unsupported language: {lang}", file=sys.stderr)
        sys.exit(1)
    print(fn(name))

주목할 차이점

1. 데코레이터 vs 메서드 체이닝

commander는 .command().option().action() 체이닝으로 명령을 정의합니다. click은 데코레이터를 씁니다. 이건 Python의 관용구예요. 기능적으로는 같은 일을 합니다 — 함수에 메타데이터를 붙이는 것.

2. click의 Choice가 검증까지 해준다

type=click.Choice(["en", "ko", "ja"])

이 한 줄로 **”허용 목록 외의 값은 자동 거부”**가 됩니다. 사용자가 --lang fr 을 넣으면 click이 알아서:

Error: Invalid value for '-l' / '--lang': 'fr' is not one of 'en', 'ko', 'ja'.

이 메시지를 내고 exit code 2로 종료합니다. 우리 run_hello 함수는 아예 호출되지도 않아요.

TypeScript의 commander는 이런 기능이 약합니다. .choices() 메서드가 있긴 하지만 제약이 있어서, 3편 코드에서는 핸들러 안에서 직접 검증했죠. click이 인자 검증에서는 commander보다 우위에 있습니다.

3. 딕셔너리 vs Record 타입

const greetings: Record<string, (n: string) => string> = { ... };
GREETINGS = {
    "en": lambda n: f"Hello, {n}!",
    ...
}

구조는 같습니다. Python은 타입 힌트가 런타임에 강제되지 않지만, mypy나 pyright로 정적 분석이 가능해요. 중급 이상이면 타입 힌트를 쓰는 걸 권장합니다.

4. weather 명령어 — API 호출 비교

Python의 richhttpx 조합이 얼마나 깔끔한지 보여드릴 차례입니다.

# greet_cli/commands/weather.py
import json as json_mod
import sys
from urllib.parse import quote as url_quote

import httpx
from rich.console import Console


def run_weather(city: str, as_json: bool) -> None:
    console = Console()
    url = f"https://wttr.in/{url_quote(city)}?format=j1"

    status_ctx = (
        console.status(f"Fetching weather for {city}...")
        if not as_json
        else None
    )
    if status_ctx:
        status_ctx.__enter__()

    try:
        res = httpx.get(url, timeout=8.0)
        res.raise_for_status()
        data = res.json()
        current = data["current_condition"][0]
        area = data["nearest_area"][0]
        payload = {
            "city": area["areaName"][0]["value"],
            "country": area["country"][0]["value"],
            "temp_c": int(current["temp_C"]),
            "feels_like_c": int(current["FeelsLikeC"]),
            "humidity": int(current["humidity"]),
            "description": current["weatherDesc"][0]["value"],
        }

        if as_json:
            print(json_mod.dumps(payload, indent=2, ensure_ascii=False))
            return

        status_ctx.__exit__(None, None, None)
        console.print(f"[bold]{payload['city']}, {payload['country']}[/bold]")
        console.print(
            f"  [bold]{payload['temp_c']}°C[/bold] "
            f"[dim](체감 {payload['feels_like_c']}°C)[/dim]"
        )
        console.print(f"  [cyan]{payload['description']}[/cyan]")
        console.print(f"  습도 {payload['humidity']}%")

    except httpx.TimeoutException:
        if status_ctx:
            status_ctx.__exit__(None, None, None)
        console.print("[red]요청이 시간 초과되었습니다 (8초)[/red]", file=sys.stderr)
        sys.exit(1)
    except (httpx.HTTPError, KeyError, ValueError) as e:
        if status_ctx:
            status_ctx.__exit__(None, None, None)
        console.print(f"[red]날씨 조회 실패: {e}[/red]", file=sys.stderr)
        sys.exit(1)

핵심 차이 1 — httpx는 타임아웃이 파라미터

TypeScript의 fetch는 타임아웃이 없어서 3편에서 AbortController로 수동으로 구현했습니다. httpx는 타임아웃을 그냥 인자로 받습니다.

httpx.get(url, timeout=8.0)

시간 초과 시 httpx.TimeoutException을 던지므로 분류도 쉽습니다. 이건 순수하게 라이브러리 설계 차이예요. 더 쾌적합니다.

핵심 차이 2 — rich의 마크업 문법

console.print("[bold]Seoul[/bold]")
console.print("[red]에러 메시지[/red]", file=sys.stderr)

chalk가 chalk.red(chalk.bold("...")) 같은 함수 체이닝이라면, rich는 HTML-ish 마크업을 씁니다. 취향 차이지만, 긴 문자열에 여러 스타일을 섞을 땐 rich 쪽이 가독성이 낫습니다.

핵심 차이 3 — status_ctx 수동 진입/탈출

이 부분은 rich의 특이한 점입니다. console.status(...)context manager로 설계되어서 원래는 with 문으로 쓰는 게 정석입니다.

with console.status("Fetching..."):
    data = httpx.get(url).json()

그런데 우리는 as_json 일 때 스피너를 안 띄워야 해서 조건부로 상태를 만들 수 없는 상황이에요. 그래서 수동으로 __enter__ / __exit__ 를 호출하는 조금 어색한 코드가 됐습니다. 더 깔끔한 방법을 찾는다면 contextlib.nullcontext 를 써서 조건부 context manager를 만드는 패턴도 있어요.

공통된 설계 판단

차이는 많지만 설계의 뼈대는 그대로입니다:

  • as_json 모드에서는 장식을 전부 끄고 기계 가독 출력
  • 네트워크 타임아웃·HTTP 에러·파싱 에러를 각각 분류
  • 실패는 stderr + exit code 1

본편 3편의 메시지가 언어를 바꿔도 유효함을 확인할 수 있습니다.

5. gitx 포팅 — subprocess

gitx의 runGit 유틸은 Python에서 훨씬 짧아집니다.

TypeScript (4편):

import { spawn } from 'node:child_process';

export function runGit(args: string[]): Promise<GitRunResult> {
  return new Promise((resolve) => {
    const child = spawn('git', args, { shell: false });
    let stdout = '';
    let stderr = '';
    child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
    child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
    child.on('close', (code) => {
      resolve({ stdout, stderr, exitCode: code ?? 1 });
    });
  });
}

Python:

import subprocess
from dataclasses import dataclass


@dataclass
class GitRunResult:
    stdout: str
    stderr: str
    exit_code: int


def run_git(args: list[str]) -> GitRunResult:
    """git 명령을 실행하고 결과를 캡처한다. shell=False 이므로 injection 불가능."""
    result = subprocess.run(
        ["git", *args],
        capture_output=True,
        text=True,
        shell=False,  # 명시적으로 (기본값이지만 의도를 드러냄)
    )
    return GitRunResult(
        stdout=result.stdout,
        stderr=result.stderr,
        exit_code=result.returncode,
    )


def run_git_streaming(args: list[str]) -> int:
    """출력을 사용자에게 직접 스트리밍 (git push 등)."""
    result = subprocess.run(["git", *args], shell=False)
    return result.returncode

왜 Python이 더 짧은가

Node.js의 spawn비동기 스트림 기반입니다. stdout/stderr가 .on('data', ...) 이벤트로 청크 단위로 오고, 최종 결과는 'close' 이벤트에서 받아야 해요. 그래서 Promise로 감싸는 보일러플레이트가 필요합니다.

Python의 subprocess.run동기 고수준 API입니다. 프로세스 종료까지 기다려서 결과를 객체로 반환합니다. 대부분의 “명령 실행 후 결과 파싱” 시나리오에 딱 맞아요.

두 언어의 동시성 모델 차이가 코드 형태로 드러나는 지점입니다. TypeScript는 async/Promise가 기본이라 짧은 작업에도 그 비용을 지불하고, Python은 동기 API가 기본이라 짧은 작업이 짧게 쓰입니다.

shell=False 의 중요성은 동일

Python에서도 shell=True 는 shell injection의 문입니다.

# ❌ 위험
subprocess.run(f"git commit -m '{user_message}'", shell=True)

# ⭕ 안전
subprocess.run(["git", "commit", "-m", user_message], shell=False)

4편에서 TypeScript로 설명한 원리가 완전히 동일하게 적용됩니다. 이게 설계 원리의 힘이에요.

6. 대화형 프롬프트 — questionary

@inquirer/prompts의 Python 대응은 questionary입니다.

# TypeScript (4편)
import { checkbox, confirm } from '@inquirer/prompts';

const selected = await checkbox({
  message: '삭제할 브랜치를 선택하세요',
  choices: candidates.map((name) => ({ name, value: name })),
});

# Python
import questionary

selected = questionary.checkbox(
    "삭제할 브랜치를 선택하세요",
    choices=candidates,
).ask()

한 가지 주의할 점: questionary의 .ask()사용자가 Ctrl+C 하면 None을 반환합니다. 예외를 던지지 않아요.

selected = questionary.checkbox(...).ask()
if selected is None:
    print("중단되었습니다.")
    sys.exit(130)

TypeScript의 @inquirer/promptsExitPromptError를 던져서 최상위 .catch에서 처리했는데, Python은 반환값으로 체크합니다. 관용구가 다를 뿐 의도는 같아요.

7. 테스트 — pytest

3편·5편에서 vitest로 쓴 테스트를 pytest로 옮기면:

# tests/test_greeting.py
import pytest
from greet_cli.commands.hello import GREETINGS


def test_english_greeting() -> None:
    assert GREETINGS["en"]("world") == "Hello, world!"


def test_korean_greeting() -> None:
    assert GREETINGS["ko"]("철수") == "안녕하세요, 철수님!"


def test_japanese_greeting() -> None:
    assert GREETINGS["ja"]("Sakura") == "こんにちは、Sakuraさん!"

httpx 모킹

TypeScript의 vi.stubGlobal('fetch', ...) 대응:

from unittest.mock import patch, Mock
import httpx

def test_fetch_quote_success() -> None:
    mock_response = Mock()
    mock_response.raise_for_status = Mock()
    mock_response.json.return_value = {"content": "테스트", "author": "A", "tags": []}

    with patch("httpx.get", return_value=mock_response) as mock_get:
        # ... fetchQuote 호출
        mock_get.assert_called_with("https://api.quotable.io/random", params={}, timeout=8.0)

더 깔끔하게 쓰려면 respx 라이브러리를 쓰는 것도 좋습니다. httpx를 URL 패턴으로 모킹하게 해줘요.

subprocess 모킹

from unittest.mock import patch, Mock

def test_run_git_success() -> None:
    mock_result = Mock()
    mock_result.stdout = "a1b2c3d\n"
    mock_result.stderr = ""
    mock_result.returncode = 0

    with patch("subprocess.run", return_value=mock_result) as mock_run:
        result = run_git(["rev-parse", "--short", "HEAD"])
        assert result.exit_code == 0
        assert result.stdout == "a1b2c3d\n"
        mock_run.assert_called_with(
            ["git", "rev-parse", "--short", "HEAD"],
            capture_output=True,
            text=True,
            shell=False,
        )

5편에서 TypeScript로 본 모킹 패턴과 구조가 똑같습니다. 테스트 전략(순수 로직/IO 분리)이 언어를 가리지 않는다는 또 다른 증거죠.

8. 배포 — pipx로 global install

Python CLI의 “npm install -g” 대응은 pipx install 입니다.

# 로컬 테스트
pip install -e .            # npm link에 해당

# 배포
python -m build             # tsc build에 해당
twine upload dist/*         # npm publish에 해당

# 사용자 설치
pipx install greet-cli      # npm install -g greet-cli에 해당

왜 pip이 아니라 pipx?

pip install -g 같은 건 없습니다. 일반 pip install 은 현재 Python 환경에 설치하는데, 이건 CLI 도구로는 문제가 많아요. 다른 패키지와 의존성 충돌이 나기 쉽죠.

pipx각 CLI 도구를 전용 격리 환경(venv)에 설치하고 실행 스크립트만 PATH에 노출합니다. Python CLI 배포의 사실상 표준이에요. poetry, black, ruff 같은 도구들도 이렇게 설치합니다.

pyproject.toml의 배포 설정

5편에서 본 package.json의 files, engines, prepublishOnly 대응은 pyproject.toml에 다 있습니다.

[project]
name = "greet-cli"
version = "0.1.0"
requires-python = ">=3.10"

[tool.hatch.build]

include = [“greet_cli/**”] # files 필드에 해당

[tool.hatch.build.targets.wheel]

packages = [“greet_cli”]

관용구만 다를 뿐 선언해야 할 정보는 동일합니다.

9. 실행해보기

Python 버전을 실제로 돌려봅니다.

$ python -m greet_cli hello
Hello, world!

$ python -m greet_cli hello --name 철수 --lang ko
안녕하세요, 철수님!

$ python -m greet_cli hello --name Sakura --lang ja
こんにちは、Sakuraさん!

# click의 자동 검증
$ python -m greet_cli hello --lang fr
Usage: python -m greet_cli hello [OPTIONS]
Try 'python -m greet_cli hello --help' for help.

Error: Invalid value for '-l' / '--lang': 'fr' is not one of 'en', 'ko', 'ja'.

$ python -m greet_cli --help
Usage: python -m greet_cli [OPTIONS] COMMAND [ARGS]...

  인사와 공개 API 정보를 제공하는 CLI

Options:
  --version  Show the version and exit.
  --help     Show this message and exit.

Commands:
  hello    인사 메시지를 출력합니다
  quote    랜덤 명언을 가져옵니다
  weather  도시의 현재 날씨를 조회합니다

TypeScript 버전과 사용자 경험이 거의 동일합니다. 에러 메시지 품질(click의 자동 검증)만 약간 더 낫고요.

10. 비교 요약

지금까지 본 차이를 정리합니다.

Python이 더 편한 부분

  • httpx의 timeout 파라미터 — AbortController 불필요
  • subprocess.run의 동기 API — Promise 감싸기 불필요
  • click의 Choice 검증 — 핸들러 진입 전 자동 차단
  • rich의 마크업 — 길게 스타일링할 때 가독성 좋음

TypeScript가 더 편한 부분

  • async/await이 처음부터 일급 — API 호출이 많은 CLI에선 자연스러움
  • 타입 시스템이 강제strict: true 로 런타임 오류를 컴파일에 잡음
  • commander의 체이닝 — 데코레이터 중첩보다 깔끔하다고 느끼는 취향 있음
  • 단일 파일 배포 — 번들러 조합으로 더 작은 바이너리 가능

어느 쪽도 우열이 없는 부분

  • CLI 인터페이스 설계 — 동사형 서브커맨드, --json 모드, exit code 관례 등
  • 에러 처리 전략 — stderr로 메시지, 적절한 exit code
  • 테스트 전략 — 순수 로직 분리, 외부 호출 모킹
  • 배포 체크리스트 — 메타데이터, 스크립트 등록, CI 자동화
  • shell injection 방어shell=False + 인자 배열

마지막 범주가 많다는 게 이번 편의 핵심 메시지입니다. CLI Wrapper의 진짜 지식은 언어에 묶여있지 않아요.

마치며

이번 편의 핵심을 세 문장으로 요약하면 이렇습니다.

  • 라이브러리 매핑(commander→click, chalk→rich, fetch→httpx, child_process→subprocess)을 이해하면, TypeScript로 익힌 CLI Wrapper 지식이 그대로 Python에서 쓰인다. 관용구만 바뀔 뿐 설계는 동일하다.
  • 두 언어는 각자 편한 지점이 있다. Python은 동기 API·자동 검증·간결한 subprocess 호출이 강점이고, TypeScript는 async-first·강력한 타입·npm 생태계가 강점이다. 만들려는 도구 성격에 맞춰 선택하면 된다.
  • 언어를 바꿔도 유효한 설계 판단이 훨씬 많다. exit code, stderr, JSON 모드, shell injection 방어, 테스트 전략, 배포 체크리스트 — 이것들은 본편에서 배운 것을 그대로 가져가면 된다.

Python을 선호하는 분은 이 편을 가이드 삼아 같은 두 도구를 Python으로 만드실 수 있을 겁니다. 그리고 TypeScript를 계속 쓰실 분이라도, “다른 언어에서도 같은 고민을 한다” 는 걸 확인하는 것만으로 본편 내용이 더 단단하게 자리잡을 거예요.

다음 번외편에서는 Go로 포팅합니다. 그때는 주제가 조금 바뀝니다 — 배포가 중심이 될 거예요. Go의 단일 바이너리 배포가 왜 CLI의 관점에서 매력적인지, 그리고 그 대가는 무엇인지 살펴봅니다. 👋




댓글 남기기