Python CLI 도구 개발 실전 튜토리얼 — Click 설치부터 서브 커맨드, Rich 출력, PyPI 배포까지
파이썬(Python) CLI 도구를 Click 프레임워크로 단계별로 만드는 실전 튜토리얼. argparse와 Click의 차이, @click.group()으로 서브 커맨드 구조 설계, 파라미터 타입 유효성 검사, Rich 라이브러리로 진행 표시줄과 테이블 출력 추가하기, 환경 변수·설정 파일 통합, pyproject.toml 작성과 PyPI 배포까지 6단계로 구현한다. CliRunner로 테스트 작성법도 포함. 파이썬 3.10 이상 기준.
반복 작업을 자동화하거나 팀 내부 도구를 만들 때 파이썬(Python) CLI는 가장 빠른 선택지다. 스크립트 하나로 시작해 서브 커맨드, 진행 표시줄, 컬러 출력까지 추가하면 실무에서 바로 쓸 수 있는 도구가 된다. 별도 서버도 필요 없고, 설치는 pip 한 줄이면 충분하다.
이 튜토리얼에서는 표준 라이브러리 argparse의 한계를 짚고, Click 프레임워크로 실용적인 CLI를 6단계로 만든다. 서브 커맨드 구조 설계, 파라미터 유효성 검사, 리치(Rich) 라이브러리로 진행 표시줄과 테이블 출력 꾸미기, 환경 변수와 설정 파일 통합, 마지막으로 PyPI에 배포하는 방법까지 모두 다룬다. 모든 코드는 파이썬 3.10 이상에서 검증했으며 실제 동작하는 예제만 담았다.
1단계 — argparse vs Click, 선택 기준
파이썬 표준 라이브러리인 argparse는 간단한 스크립트에는 충분하다. 파서 하나 만들고 인자 몇 개 추가하면 바로 쓸 수 있다. 그러나 서브 커맨드가 3개 이상 되거나 도움말 형식을 커스터마이즈하거나, 환경 변수와 옵션을 함께 처리하려면 코드가 급격히 복잡해진다. 파서 10개를 연결하는 구조가 생기면 유지보수가 힘들어진다.
Click은 데코레이터 기반 CLI 프레임워크다. 함수 하나에 장식자 하나만 붙이면 명령어가 된다. 플라스크(Flask) 개발팀이 만들었고, 파이썬 CLI 생태계에서 가장 넓게 쓰이는 검증된 라이브러리다. Typer는 Click 위에서 타입 힌트로 파라미터를 선언하는 방식이라 타입스크립트(TypeScript) 배경이 있다면 더 직관적이다. 이 튜토리얼은 Click을 기준으로 한다.
argparse vs Click 코드 비교
# argparse 방식 — 명시적이지만 서브커맨드 늘어날수록 장황해진다
import argparse
def main():
parser = argparse.ArgumentParser(description="파일 복사 도구")
parser.add_argument("src", help="원본 경로")
parser.add_argument("dst", help="대상 경로")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
if args.verbose:
print(f"{args.src} -> {args.dst} 복사 중...")
# Click 방식 — 데코레이터 하나로 동일 기능 구현
import click
@click.command()
@click.argument("src")
@click.argument("dst")
@click.option("-v", "--verbose", is_flag=True, help="자세한 출력")
def copy_file(src, dst, verbose):
"""파일 복사 도구"""
if verbose:
click.echo(f"{src} -> {dst} 복사 중...")
서브 커맨드가 하나일 때는 큰 차이가 없어 보인다. 그런데 배포 스크립트, 데이터 변환 도구, 내부 운영 CLI처럼 명령어 종류가 늘어나면 Click의 그룹 구조가 코드를 훨씬 깔끔하게 유지해 준다. 자동 도움말 생성, 입력 유효성 검사, 환경 변수 매핑이 모두 기본으로 포함된다는 점도 argparse 대비 큰 장점이다.
Click으로 만든 CLI — --help 옵션 출력이 자동으로 생성되며, 옵션 기본값도 함께 표시된다
2단계 — Click 설치와 그룹 구조 잡기
가상 환경을 먼저 만들고 Click과 리치(Rich)를 설치한다. 이 튜토리얼에서는 간단한 파일 처리 도구를 단계별로 만든다. 디렉토리 구조는 아래처럼 잡는다.
# 가상 환경 생성 및 활성화
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install click rich
# filetool/cli.py
import click
@click.group()
@click.version_option(version="1.0.0")
def cli():
"""파일 관리 유틸리티"""
pass
@cli.command()
@click.argument("path", type=click.Path(exists=True))
@click.option("--count", "-n", default=10, show_default=True, help="출력할 줄 수")
@click.option("--encoding", default="utf-8", help="파일 인코딩")
def head(path, count, encoding):
"""파일의 첫 N줄을 출력합니다."""
with open(path, encoding=encoding) as f:
for i, line in enumerate(f):
if i >= count:
break
click.echo(line, nl=False)
@cli.command()
@click.argument("path", type=click.Path(exists=True))
def wc(path):
"""파일의 줄 수, 단어 수, 바이트 수를 출력합니다."""
with open(path, "rb") as f:
content = f.read()
lines = content.count(b"\n")
words = len(content.split())
click.echo(f"{lines:>8} {words:>8} {len(content):>8} {path}")
if __name__ == "__main__":
cli()
그룹 데코레이터는 여러 서브 커맨드를 하나로 묶는 컨테이너다. 함수가 진입점이 되고 각 서브 커맨드는 그룹에 등록한다. 경로 타입을 사용하면 실행 시점에 파일 존재 여부를 자동으로 검사해 명확한 오류 메시지를 출력한다. 직접 조건문으로 파일 존재를 확인하지 않아도 된다.
기본값 표시 옵션을 켜두면 도움말 출력 시 각 옵션의 기본값이 자동으로 보인다. 문서 문자열에 기본값을 일일이 적지 않아도 된다는 점이 실무에서 작은 편의지만 확실히 도움이 된다.
3단계 — 파라미터 타입과 자동 유효성 검사
Click은 파라미터 타입을 선언하면 입력값 변환과 유효성 검사를 자동으로 처리한다. 사용자가 잘못된 값을 입력하면 사용자 친화적인 오류 메시지를 바로 출력하고 도움말을 안내한다. 직접 예외 처리 코드를 작성하지 않아도 된다는 것이 개발 속도에 큰 도움이 된다.
Click 파라미터 타입 — 선택 목록·정수 범위·경로 검사 활용
import click
from pathlib import Path
@cli.command()
@click.argument("files", nargs=-1, type=click.Path(exists=True, path_type=Path))
@click.option(
"--format", "output_format",
type=click.Choice(["json", "csv", "tsv"], case_sensitive=False),
default="json",
help="출력 형식 (기본: json)",
)
@click.option(
"--limit",
type=click.IntRange(1, 1000),
default=100,
show_default=True,
help="처리할 최대 행 수 (1~1000)",
)
@click.option(
"--output", "-o",
type=click.Path(dir_okay=False, writable=True),
help="결과를 저장할 경로 (기본: 표준 출력)",
)
def process(files, output_format, limit, output):
"""여러 파일을 처리해 지정한 형식으로 변환합니다."""
if not files:
raise click.UsageError("파일을 하나 이상 지정하세요.")
click.echo(f"파일 {len(files)}개, 형식: {output_format}, 최대 {limit}행")
for f in files:
click.echo(f" 처리 중: {f}")
인자에 음수 부호가 붙으면 개수 제한 없이 여러 개를 받는다는 의미다. 선택 목록 타입은 허용 값 목록을 도움말에 자동으로 출력하고 목록 외 값이 들어오면 즉시 오류를 낸다. 정수 범위 타입은 지정 범위를 벗어나면 마찬가지로 오류를 출력한다. 이 세 가지만으로도 수동 유효성 검사 코드 수십 줄을 대체할 수 있다. 팀 내부 도구를 만들 때 잘못된 인자로 실수가 나는 경우를 크게 줄여준다.
4단계 — Rich로 진행 표시줄과 테이블 출력 추가하기
리치(Rich)는 터미널 출력을 마크다운처럼 꾸밀 수 있는 라이브러리다. 진행 표시줄, 표, 트리 구조, 코드 하이라이팅을 별도 설정 없이 터미널에서 바로 볼 수 있다. Click과 함께 쓰면 내부 도구도 한층 완성도 높게 만들 수 있다. 사용자가 오래 걸리는 작업에서 진행 상황을 파악하지 못해 취소 버튼을 누르는 상황을 막아주기도 한다.
Rich 진행 표시줄 + 테이블 출력
import click
from pathlib import Path
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
from rich.table import Table
console = Console()
@cli.command()
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option("--top", default=20, show_default=True, help="상위 N개 파일 표시")
def stats(directory, top):
"""디렉토리 파일 통계를 테이블로 출력합니다."""
all_files = [f for f in directory.rglob("*") if f.is_file()]
results = []
with Progress(
SpinnerColumn(),
TextColumn("[bold blue]{task.description}"),
BarColumn(),
TextColumn("{task.completed}/{task.total}"),
console=console,
) as progress:
task = progress.add_task("파일 분석 중...", total=len(all_files))
for f in all_files:
results.append((f.name, f.suffix or "-", f.stat().st_size))
progress.advance(task)
table = Table(title=f"{directory} — 파일 통계 (상위 {top}개)", show_header=True)
table.add_column("파일명", style="cyan", no_wrap=True)
table.add_column("확장자")
table.add_column("크기 (바이트)", justify="right", style="green")
for name, ext, size in sorted(results, key=lambda x: x[2], reverse=True)[:top]:
table.add_row(name, ext, f"{size:,}")
console.print(table)
console.print(f"[dim]전체 {len(all_files)}개 파일[/dim]")
진행 표시줄은 스피너, 막대, 텍스트를 조합한 여러 열 구조를 지원한다. 터미널이 아닐 때(파이프로 연결되는 경우)는 컬러 코드를 자동으로 제거한다. 콘솔 출력 함수는 마크업 문법을 지원해 강조 색상이나 굵은 글씨를 손쉽게 지정할 수 있다. 백엔드 엔지니어가 만든 내부 도구도 시각적으로 완성도 있는 출력을 낼 수 있다는 점이 팀 생산성에 실제로 도움이 된다.
Rich의 Progress와 Table을 활용하면 터미널 출력이 훨씬 읽기 쉬워진다
5단계 — 환경 변수와 설정 파일 통합
실무 CLI는 환경 변수, 설정 파일, 명령줄 옵션 세 가지를 우선순위에 따라 처리해야 한다. 예를 들어 API 키는 환경 변수로 주입하고, 서버 주소는 설정 파일에 저장하며, 실행 시점 옵션으로 덮어쓸 수 있어야 한다. Click은 접두사 설정만으로 환경 변수 자동 매핑을 지원한다.
환경 변수 자동 매핑 + JSON 설정 파일 통합
import click
import json
from pathlib import Path
def load_config(ctx, param, value):
"""설정 파일을 읽어 Context default_map에 등록한다."""
if value is None:
default_path = Path.home() / ".filetool" / "config.json"
value = str(default_path) if default_path.exists() else None
if value and Path(value).exists():
with open(value) as f:
ctx.default_map = json.load(f)
return value
# FILETOOL_API_KEY 환경변수가 --api-key 옵션에 자동으로 매핑됨
@click.command(context_settings={"auto_envvar_prefix": "FILETOOL"})
@click.option(
"--config",
type=click.Path(dir_okay=False),
callback=load_config,
expose_value=False,
is_eager=True,
help="설정 파일 경로 (기본: ~/.filetool/config.json)",
)
@click.option("--api-key", required=True, help="API 키 (FILETOOL_API_KEY 환경변수도 가능)")
@click.option("--timeout", default=30, show_default=True, help="요청 제한 시간(초)")
@click.option("--endpoint", default="https://api.example.com", help="API 엔드포인트")
def upload(api_key, timeout, endpoint):
"""파일을 원격 서버에 업로드합니다."""
masked = api_key[:4] + "****" if len(api_key) > 4 else "****"
click.echo(f"API 키: {masked}")
click.echo(f"엔드포인트: {endpoint}")
click.echo(f"제한 시간: {timeout}초")
# 사용 예시
# FILETOOL_API_KEY=secret filetool upload <- 환경변수로 전달
# filetool --config ~/.filetool/config.json upload <- 설정 파일
우선순위는 명령줄 옵션이 가장 높고, 그 다음이 환경 변수, 설정 파일 기본값, 마지막이 Click 기본값 순이다. 조급 처리 플래그는 다른 옵션보다 먼저 처리되도록 하는 설정이다. 설정 파일을 읽어 기본값 맵에 등록한 뒤, 나머지 옵션들이 이 값을 기본값으로 쓴다.
설정 파일 예시(~/.filetool/config.json)를 홈 디렉토리에 두면 매번 옵션을 타이핑하지 않아도 된다. 팀원에게 배포할 때는 기본 설정 파일 예시를 리포지터리에 포함하면 온보딩이 훨씬 쉬워진다.
6단계 — pyproject.toml 작성과 PyPI 배포
작성한 CLI를 팀원과 공유하거나 공개 도구로 배포하려면 패키지 설정 파일을 작성하고 PyPI에 업로드한다. 파이썬 3.11부터는 기존 방식 대신 새로운 표준 설정 파일이 공식 표준이다. 빌드 시스템을 지정하고 패키지 이름, 버전, 의존성, 진입점을 선언하면 된다.
pyproject.toml 구성 + PyPI 배포 명령
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "filetool"
version = "1.0.0"
description = "파일 관리 CLI 유틸리티"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"click>=8.1",
"rich>=13.0",
]
[project.urls]
Homepage = "https://github.com/yourname/filetool"
# pip install 후 터미널에서 filetool 명령으로 바로 실행
[project.scripts]
filetool = "filetool.cli:cli"
# 배포 절차
# pip install build twine <- 빌드 도구 설치
# python -m build <- dist/ 에 wheel 생성
# twine check dist/* <- 배포 전 유효성 검사
# twine upload --repository testpypi dist/* <- 테스트 PyPI 먼저
# pip install --index-url https://test.pypi.org/simple/ filetool <- 설치 확인
# twine upload dist/* <- 실제 PyPI 배포
스크립트 섹션이 핵심이다. 패키지의 모듈에서 함수를 진입점으로 등록하면 패키지 설치 후 터미널에서 도구 이름을 바로 실행할 수 있다. 팀 내부 도구라면 사설 패키지 서버나 깃허브(GitHub) Packages를 활용하는 방법도 있다. 간단하게는 깃허브 리포지터리 URL로 직접 설치하는 방식도 가능하다.
테스트 PyPI에 먼저 올려 설치 흐름을 검증한 뒤 실제 PyPI에 배포하는 것을 권장한다. 의존성 누락이나 진입점 설정 오류를 미리 발견할 수 있다. 한번 실수해서 잘못된 버전을 배포하면 삭제해도 같은 버전 번호를 다시 쓸 수 없어 번거롭다.
pyproject.toml과 twine으로 PyPI에 배포하면 pip install 한 줄로 어디서나 설치할 수 있다
Click CLI 테스트 작성법
Click은 테스트 실행기를 내장하고 있어 실제 터미널 없이 CLI를 테스트할 수 있다. pytest와 함께 쓰면 입력, 출력, 종료 코드를 모두 검사할 수 있다. 파이프 연결이나 파일 시스템 접근을 흉내 낼 수도 있어 단위 테스트가 무척 편리해진다.
CliRunner로 Click CLI 단위 테스트
from click.testing import CliRunner
from filetool.cli import cli
def test_head_command(tmp_path):
test_file = tmp_path / "sample.txt"
test_file.write_text("줄1\n줄2\n줄3\n줄4\n줄5\n")
runner = CliRunner()
result = runner.invoke(cli, ["head", str(test_file), "--count", "3"])
assert result.exit_code == 0
assert "줄1" in result.output
assert "줄4" not in result.output
def test_missing_file():
runner = CliRunner()
result = runner.invoke(cli, ["head", "없는파일.txt"])
assert result.exit_code != 0
assert "Error" in result.output
def test_process_no_files():
runner = CliRunner()
result = runner.invoke(cli, ["process"])
assert result.exit_code != 0
assert "파일을 하나 이상" in result.output
테스트 실행기를 사용하면 실제 파일 시스템을 건드리지 않고도 표준 출력과 오류 스트림을 캡처해서 검사할 수 있다. 표준 출력과 오류 스트림을 분리하는 옵션을 활용하면 정상 출력과 오류 메시지를 각각 따로 검증할 수 있다. 종료 코드가 0이면 정상 종료, 0이 아니면 오류 종료를 의미한다.
깃허브(GitHub) Actions 같은 CI 환경에서 테스트할 때는 환경 변수 의존 옵션을 실행 시 딕셔너리로 인라인으로 주입할 수 있다. 또한 리치(Rich)의 컬러 코드가 출력에 섞이지 않도록 터미널 환경 변수를 설정해 두는 것이 좋다.
실무에서 Click이 빛나는 순간
실제 프로젝트에서 가장 효과를 발휘하는 패턴 세 가지를 소개한다.
배포 스크립트 대체: 셸 스크립트로 작성하던 배포 자동화를 파이썬으로 옮기면 오류 처리, 분기, 로그 출력이 훨씬 쉬워진다. 특히 배포 전 확인 단계나 롤백 로직을 붙일 때 셸 스크립트보다 훨씬 깔끔하다. 팀원이 파이썬에 익숙하면 유지보수도 수월해진다.
데이터 마이그레이션 도구: 데이터베이스 마이그레이션이나 파일 변환 작업을 일회성 스크립트로 만들면 실수가 생기기 쉽다. Click으로 서브 커맨드를 나누고 드라이런 모드를 추가하면 실제 실행 전에 어떤 변경이 일어날지 미리 확인할 수 있다. 진행 표시줄을 붙이면 대용량 작업 시 진행 상황을 알 수 있어 불안감도 줄어든다.
내부 운영 도구: 로그 분석, 환경 점검, 서비스 상태 확인 같은 반복 작업을 CLI로 만들면 팀 전체가 공유할 수 있다. PyPI에 올리지 않더라도 깃허브 리포지터리 URL로 직접 설치하는 방식으로 팀원에게 배포할 수 있다. 개발 환경 체크리스트를 실행 가능한 CLI로 만들어두면 온보딩 시간도 줄어든다.
자주 막히는 지점 3가지
실제 CLI를 만들면서 가장 많이 마주치는 문제들이다.
파이프라인 출력에 컬러 코드가 섞인다: 리치(Rich)는 기본적으로 터미널 여부를 자동 감지해서 파이프로 연결되면 컬러 코드를 제거한다. 터미널 강제 활성화 옵션을 쓰면 파이프에도 컬러 코드가 들어가므로 주의한다. 기본값을 그대로 두는 것이 가장 안전하다.
윈도우에서 한국어 출력이 깨진다: 출력 함수를 쓰고 스크립트 초반에 출력 스트림의 인코딩을 UTF-8로 재설정한다. 또는 환경 변수로 UTF-8 모드를 활성화하는 방법도 있다. 파이썬 3.15 이상에서는 기본값이 UTF-8이 될 예정이다.
서브 커맨드 간 공통 데이터 공유: 컨텍스트 데코레이터와 컨텍스트 객체를 활용한다. 그룹 함수에서 공유 저장소를 초기화하고 서브 커맨드에서 객체 전달 데코레이터로 받는다. 전역 설정(로그 레벨, 드라이런 모드 등)을 모든 서브 커맨드에 전달하는 데 쓸 수 있다.
⚠️ CLI 출력 함수 — print() 대신 반드시 전용 출력 함수를 쓴다 Click 전용 출력 함수는 파이프 연결 시 깨진 파이프 오류를 자동으로 처리하고 윈도우 콘솔 인코딩 문제를 완화한다. 오류 메시지는 표준 오류 스트림으로 출력해야 파이프라인에서 정상 출력 데이터와 섞이지 않는다. 로그 메시지가 파이프로 전달된 데이터에 끼어들어 처리가 망가지는 상황을 방지한다. 처음부터 이 원칙을 지키면 나중에 셸 파이프라인과 통합할 때 예상치 못한 문제를 크게 줄일 수 있다.
Rich 공식 문서 — 진행 표시줄, 테이블, 트리, 마크다운 렌더링, 로깅 핸들러 활용법
자주 묻는 질문
Click 말고 Typer를 선택해야 하는 경우는 언제인가요?
타입 힌트만으로 파라미터를 선언하고 싶다면 Typer가 더 편하다. 함수 시그니처에 타입 힌트를 붙이면 자동으로 옵션과 인자를 생성한다. 내부적으로 Click을 쓰기 때문에 Click의 기능을 모두 활용할 수 있다. 단, 복잡한 콜백이나 컨텍스트 공유가 많고 Click API를 직접 제어해야 하는 상황이라면 Click 직접 사용이 더 명확하다. FastAPI나 Pydantic에 익숙한 팀이라면 Typer가 학습 비용이 낮다.
명령어 자동 완성(탭 완성)을 어떻게 추가하나요?
Click 8.x에는 셸 완성이 내장돼 있다. bash, zsh, fish 셸을 지원한다. 사용자가 설치 후 완성 스크립트를 셸 설정 파일에 추가하면 탭 완성이 활성화된다. 셸 완성 모듈로 동적 완성 목록도 구현할 수 있다. Typer의 경우 완성 기능이 기본으로 활성화돼 있어 추가 작업이 없다.
다국어(한국어·영어) 메시지를 관리하는 좋은 방법이 있나요?
간단하게는 문자열 상수를 별도 딕셔너리 파일로 분리하는 방식이 내부 도구로는 충분하다. 규모가 크다면 파이썬 표준 gettext 모듈과 babel 라이브러리를 조합해 번역 파일로 관리한다. CLI 도구는 언어 환경 변수를 읽어 언어를 결정하는 패턴도 흔히 쓰인다. 오픈소스 도구라면 Click 자체가 gettext를 통한 현지화를 지원한다.
서브 커맨드 간 공통 옵션(--verbose, --debug)을 어떻게 공유하나요?
그룹 데코레이터에 공통 옵션을 선언하고 컨텍스트 데코레이터로 컨텍스트를 서브 커맨드에 전달하면 된다. 그룹 함수에서 공유 저장소를 초기화하고 공통 값을 저장한다. 서브 커맨드에서 객체 전달 데코레이터로 받아 조건에 따라 활용한다. 이 패턴으로 전역 설정을 모든 서브 커맨드에 깔끔하게 전달할 수 있다. 상세 출력 모드, 드라이런, 출력 형식 설정 같은 공통 값에 자주 쓰인다.
깃허브(GitHub) Actions CI에서 Click CLI를 테스트할 때 주의할 점이 있나요?
몇 가지를 확인한다. 표준 출력과 오류 스트림을 분리하는 옵션을 활용해 각각 따로 검사한다. 파일 시스템이 필요한 테스트는 pytest의 임시 경로 픽스처나 격리된 파일 시스템 컨텍스트 관리자를 쓴다. 환경 변수 의존 옵션은 실행 시 딕셔너리로 인라인 주입한다. 리치(Rich) 컬러 코드가 출력에 섞이지 않도록 CI 환경에 터미널 환경 변수를 단순 단말로 설정한다. 가상 환경은 편집 가능 설치 모드로 패키지를 설치하면 소스를 수정해도 재설치 없이 바로 반영된다.