Devin.KR
로그인

파이썬 패키징과 배포 - pyproject.toml 휠 빌드와 CLI 등록 (파이썬 고급 23단원)

개발자 조회 1

이 단원에서 배우는 것

22단원에서 logkitsrc/ 레이아웃과 테스트를 갖췄고 pip install -e 로 설치해 테스트를 돌렸다. 그 설치가 어떻게 성립했는지는 아직 설명하지 않았다. 이 단원에서 pyproject.toml 을 제대로 쓰고, 다른 사람이 pip install logkitlogkit access.log 로 쓸 수 있는 물건까지 만든다.

  • pyproject.toml 의 세 블록(build-system, project, 도구 설정)을 각각 무엇이 읽는지 구분한다
  • src 레이아웃과 편집 가능 설치가 어떤 버그를 막는지 이해한다
  • 휠·sdist 를 빌드하고, 콘솔 명령어를 등록하고, 단일 실행 파일까지의 선택지를 안다

기준은 파이썬 3.11 이상이다. setup.py 는 더 이상 표준 진입점이 아니며, 새 프로젝트에서 쓸 이유가 없다.

왜 필요한가

14단원에서 venv 로 환경을 격리하고 requirements.txt 로 의존성을 적어 뒀다. 거기까지는 내 컴퓨터에서 돌리기를 위한 것이고, 이 단원은 남에게 주기를 위한 것이다. 패키징을 미루면 겪는 일이 정해져 있다.

  • 동료가 코드를 받아 실행하면 ModuleNotFoundError: No module named 'logkit'. "프로젝트 루트에서 실행해야 해요"라고 답한다.
  • 서버에 배포하려니 sys.path.append('/opt/logkit/src') 를 쓰는 래퍼 스크립트를 만든다.
  • 어떤 서버에서만 죽는다. 조사해 보니 requests 버전이 다르다. 어디에도 버전이 적혀 있지 않다.
  • cron 에 등록하려니 /opt/venv/bin/python /opt/logkit/src/logkit/cli.py 같은 줄이 생기고, 이 경로가 어딘가에 하드코딩된다.

이 전부가 하나의 원인에서 나온다. 코드 묶음이 "설치 가능한 단위"로 정의되어 있지 않다. pyproject.toml 은 그 정의를 담는 파일이고, 표준이므로 pip·uv·빌드 도구·테스트 러너·린터가 전부 같은 파일을 읽는다.

문법과 예제

프로젝트 구조

logkit/
├─ pyproject.toml
├─ README.md
├─ src/
│   └─ logkit/
│       ├─ __init__.py
│       ├─ cli.py
│       ├─ notify.py
│       ├─ parser.py
│       └─ report.py
└─ tests/
    ├─ conftest.py
    ├─ test_parser.py
    └─ test_report.py

pyproject.toml 전체

[build-system]
requires = ["hatchling>=1.24"]
build-backend = "hatchling.build"

[project]
name = "logkit"
version = "0.3.0"
description = "액세스 로그를 파싱해 느린 요청 비율을 감시한다"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [{ name = "Your Name", email = "you@example.com" }]
keywords = ["logging", "monitoring", "cli"]
classifiers = [
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Topic :: System :: Monitoring",
]
dependencies = []

[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6"]

[project.scripts]
logkit = "logkit.cli:main"

[project.urls]
Homepage = "https://github.com/you/logkit"

[tool.hatch.build.targets.wheel]
packages = ["src/logkit"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]

블록마다 읽는 주체가 다르다.

블록읽는 주체역할
[build-system]pip / build이 프로젝트를 무엇으로 빌드할지. 격리 환경에 이것부터 설치한다
[project]빌드 백엔드 → 메타데이터이름·버전·의존성·파이썬 요구 버전. PEP 621 표준
[project.scripts]설치 시 pip실행 파일을 만들어 준다
[tool.*]각 도구pytest, ruff, mypy 설정. 서로 무관하다

백엔드는 hatchling, setuptools, flit-core, pdm-backend, maturin(러스트 확장) 중에서 고른다. 순수 파이썬 프로젝트라면 hatchling 이 설정이 가장 적다. C 확장이 있으면 setuptools 를 쓴다. 중요한 것은 무엇을 고르든 [project] 블록의 내용은 동일하다는 점이다. 표준이므로 백엔드를 나중에 갈아 끼울 수 있다.

왜 src 레이아웃인가

패키지를 루트에 두는 이른바 플랫 레이아웃(logkit/logkit/ 이 아니라 logkit/ 바로 아래에 모듈)은 치명적인 착시를 만든다. 프로젝트 루트에서 pytest 를 돌리면 현재 디렉터리가 sys.path 에 들어가므로, 패키지를 설치하지 않아도 import logkit 이 된다. 그래서 이런 상황이 생긴다.

  • 테스트는 전부 통과한다
  • 휠에 parser.py 를 빠뜨렸다
  • 설치한 사용자만 ImportError 를 본다

src/ 아래에 두면 현재 디렉터리가 sys.path 에 있어도 logkit 을 찾을 수 없다. 설치해야만 임포트된다. 즉 테스트가 항상 "설치된 상태의 패키지"를 검증하게 되고, 빌드 설정 실수가 CI 에서 잡힌다. 개발 중에는 편집 가능 설치를 쓴다.

python -m venv .venv
source .venv/bin/activate          # 윈도우: .venv\Scripts\activate
pip install -e ".[dev]"            # 소스를 가리키는 링크로 설치된다
pytest                             # 15 passed

-e(editable)는 소스를 복사하지 않고 참조만 걸어 두므로, 코드를 고치면 재설치 없이 반영된다. [dev] 는 위에서 정의한 optional-dependencies 그룹이다. 따옴표는 zsh 에서 대괄호가 글롭 문자로 해석되는 것을 막기 위해 필요하다.

콘솔 명령어 등록

# src/logkit/cli.py
from __future__ import annotations

import argparse
import sys

from logkit import __version__
from logkit.parser import parse, slow_ratio


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(prog="logkit")
    ap.add_argument("--version", action="version", version=__version__)
    ap.add_argument("--limit-ms", type=int, default=1000)
    ap.add_argument("path")
    args = ap.parse_args(argv)

    with open(args.path, encoding="utf-8") as fp:
        ratio = slow_ratio(parse(fp), args.limit_ms)
    print(f"slow_ratio={ratio:.4f}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

[project.scripts]logkit = "logkit.cli:main" 이 설치 시 .venv/bin/logkit 를 만들고, 그 안에서 main() 을 부른 뒤 반환값을 종료 코드로 쓴다.

$ logkit --version
0.3.0
$ logkit --limit-ms 1000 access.log
slow_ratio=0.5000
$ echo $?
0

main()argv 를 인자로 받게 만든 이유가 있다. 22단원처럼 테스트에서 프로세스를 띄우지 않고 직접 부를 수 있다.

def test_cli_prints_ratio(access_log, capsys):
    assert main(["--limit-ms", "1000", str(access_log)]) == 0
    assert capsys.readouterr().out.strip() == "slow_ratio=0.5000"

그리고 종료 코드를 return 으로 표현하면 sys.exit() 를 로직 곳곳에 뿌리지 않아도 된다. 배치 도구에서 종료 코드는 계약이다. 0 이 아니면 cron·CI·k8s Job 이 실패로 인식하므로, 감시 도구라면 임계 초과를 종료 코드 1 로 표현하는 것도 방법이다.

버전을 한 곳에서 관리한다

# src/logkit/__init__.py
from importlib.metadata import PackageNotFoundError, version

try:
    __version__ = version("logkit")
except PackageNotFoundError:          # 설치 전(소스 트리에서 바로 실행)일 때
    __version__ = "0.0.0.dev0"

pyproject.tomlversion 이 유일한 원본이고, 런타임에는 설치된 메타데이터에서 읽어 온다. 이렇게 하지 않으면 __init__.pypyproject.toml 두 곳의 숫자가 언젠가 반드시 어긋난다. 반대 방향(__init__.py 를 원본으로 삼기)도 가능하다.

[project]
dynamic = ["version"]

[tool.hatch.version]
path = "src/logkit/__init__.py"

빌드와 배포

pip install build twine
python -m build
* Building sdist...
* Building wheel from sdist
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling>=1.24
Successfully built logkit-0.3.0.tar.gz and logkit-0.3.0-py3-none-any.whl

두 가지가 나온다.

  • sdist(.tar.gz) — 소스 배포본. 설치할 때 빌드가 필요하다
  • wheel(.whl) — 빌드 완료본. 압축을 풀어 놓기만 하면 되므로 설치가 빠르고 확실하다

빌드 결과를 반드시 열어 본다. 이 확인을 건너뛰어 파일이 빠진 채로 배포되는 일이 잦다.

python -c "import zipfile; print(zipfile.ZipFile('dist/logkit-0.3.0-py3-none-any.whl').namelist())"
['logkit/__init__.py', 'logkit/cli.py', 'logkit/notify.py', 'logkit/parser.py',
 'logkit/report.py', 'logkit-0.3.0.dist-info/METADATA',
 'logkit-0.3.0.dist-info/WHEEL', 'logkit-0.3.0.dist-info/entry_points.txt',
 'logkit-0.3.0.dist-info/RECORD']

업로드는 테스트 저장소에서 먼저 한다.

twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ logkit

# 확인이 끝나면
twine upload dist/*

PyPI 는 같은 버전을 다시 올릴 수 없다. 0.3.0 을 올린 뒤 오타를 발견하면 0.3.1 을 올리는 수밖에 없다. 삭제해도 그 버전 번호는 영구히 재사용 불가다. 그래서 첫 업로드는 항상 TestPyPI 다. 인증은 사용자명/비밀번호 대신 API 토큰을 쓰고, CI 에서는 토큰조차 필요 없는 Trusted Publishing(OIDC)을 쓰는 것이 지금의 권장 방식이다.

사내 전용이라면 PyPI 에 올리지 않고 git 주소로 설치해도 된다.

pip install "logkit @ git+https://github.com/you/logkit.git@v0.3.0"

애플리케이션 배포: 잠금 파일

dependencies 에는 넓은 범위를 쓴다(requests>=2.31). 라이브러리가 버전을 좁게 못 박으면 그 라이브러리를 쓰는 쪽에서 의존성 충돌이 난다. 반대로 애플리케이션을 서버에 배포할 때는 모든 버전이 고정돼야 재현 가능하다. 두 요구는 파일을 나눠서 만족시킨다.

# 개발자가 쓰는 범위 (pyproject.toml)
dependencies = ["requests>=2.31", "pyyaml>=6"]

# 배포에 쓰는 고정본 (requirements.lock)
pip install -e .
pip freeze --exclude-editable > requirements.lock
# 서버에서
pip install -r requirements.lock
pip install --no-deps dist/logkit-0.3.0-py3-none-any.whl

uvpoetry 를 쓰면 이 잠금 파일을 해시까지 포함해 자동으로 관리해 준다. uv 는 pip 와 호환되는 인터페이스에 훨씬 빠른 해석기를 얹은 도구이고, uv pip install -e ".[dev]" 처럼 그대로 바꿔 쓸 수 있다.

단일 실행 파일

파이썬이 설치되지 않은 곳에 배포해야 한다면 선택지는 셋이다.

도구결과물주의점
PyInstaller플랫폼별 단일 실행 파일빌드한 OS·아키텍처에서만 동작. 크기 30MB+. 백신 오탐
zipapp (표준).pyz 하나파이썬은 필요. 순수 파이썬 의존성만 가능
컨테이너이미지서버 배포라면 대개 이게 가장 단순하다
# 표준 라이브러리만으로 .pyz 만들기
pip install -r requirements.lock --target build/app
cp -r src/logkit build/app/
python -m zipapp build/app -m "logkit.cli:main" -o logkit.pyz -p "/usr/bin/env python3"
./logkit.pyz --limit-ms 1000 access.log
# PyInstaller
pip install pyinstaller
pyinstaller --onefile --name logkit src/logkit/cli.py
./dist/logkit --version

서버용 도구라면 컨테이너가 대개 정답이다. 실행 파일을 만드는 것은 파이썬이 없는 사용자 PC 에 배포할 때의 이야기다.

[project] 에 넣을 수 있는 모든 항목과 백엔드별 차이는 Python Packaging User Guide에 정리돼 있다.

실무에서 자주 틀리는 것

1. 배포 이름과 임포트 이름을 같다고 가정한다

[project] namePyPI 에 올라가는 배포 이름이고, import 하는 이름은 src/ 아래 디렉터리 이름이다. 둘은 무관하다. pip install scikit-learnimport sklearn 하는 것이 대표 예다. 문제는 이 자유도가 사고를 만든다는 점이다.

  • 배포 이름은 대소문자·-·_ 를 구분하지 않고 정규화되지만, 임포트 이름은 정확히 일치해야 한다. name = "log-kit" 인데 디렉터리가 logkit 이면 pip install log-kitimport logkit 이다. 문서에 둘 다 적어야 한다.
  • 흔한 이름을 쓰면 PyPI 에 이미 있어서 업로드가 거부된다. 이름은 프로젝트를 시작할 때 확보해 둔다.
  • 임포트 이름을 test, utils, config 같은 흔한 단어로 지으면 다른 패키지와 충돌한다. 특히 src/ 없이 루트에 tests/__init__.py 와 함께 두면 tests 라는 최상위 패키지가 설치되는 사고가 난다.

2. 코드가 아닌 파일이 휠에 안 들어간다

빌드 백엔드는 기본적으로 .py 만 담는다. 템플릿·JSON 스키마·기본 설정 파일을 패키지 안에 뒀다면 설치본에서 사라진다. 게다가 개발 중에는 편집 가능 설치라 소스가 그대로 보이므로 절대 재현되지 않는다. 배포 후 처음 알게 되는 유형이다.

[tool.hatch.build.targets.wheel]
packages = ["src/logkit"]
artifacts = ["src/logkit/rules/*.yaml"]

그리고 그 파일을 읽을 때 os.path.dirname(__file__) 로 경로를 조립하면 zip 안에서 실행될 때 깨진다. 표준 API 를 쓴다.

from importlib.resources import files

text = files("logkit.rules").joinpath("default.yaml").read_text(encoding="utf-8")

3. __init__.py 없는 디렉터리가 조용히 빠진다

파이썬 3.3 부터 __init__.py 없이도 임포트가 되지만(네임스페이스 패키지), 빌드 백엔드의 자동 탐색은 __init__.py 를 기준으로 하위 패키지를 찾는 경우가 많다. src/logkit/rules/__init__.py 를 안 뒀다면 로컬에서는 잘 돌고 휠에서는 rules 가 통째로 빠진다. 휠 내용을 확인하는 습관이 이 문제를 잡는 유일한 방법이고, CI 에 한 줄로 넣어 둘 수 있다.

python -m build
pip install --force-reinstall dist/*.whl
cd /tmp && python -c "import logkit.rules; print('ok')"     # 소스 트리 밖에서 확인

cd /tmp 가 핵심이다. 프로젝트 루트에서 확인하면 소스가 보여서 의미가 없다.

4. requirements.txtdependencies 를 둘 다 관리한다

14단원에서 만든 requirements.txt 를 그대로 두고 pyproject.toml 을 추가하면, 둘 다 있는데 내용이 다른 상태가 된다. 자주 보는 사고다. pip install -e .pyproject.toml 만 보고, pip install -r requirements.txt 는 그 파일만 본다. 개발자마다 다른 명령을 쓰면 환경이 갈라진다. 규칙을 정한다.

  • 패키지의 의존성 정의pyproject.toml 한 곳에만 둔다
  • requirements.lock생성물이다. 손으로 고치지 않고 pip freezeuv lock 으로 만든다
  • 파일 맨 위에 "이 파일은 자동 생성됩니다" 주석을 넣는다

스스로 확인하기

  1. pip install -e . 로 설치한 뒤 src/logkit/parser.py 를 고쳤다. 재설치가 필요한 경우와 필요 없는 경우를 각각 들어라.
  2. 동료가 pip install logkitlogkit: command not found 라고 한다. 확인할 것 세 가지는?
  3. 사내 배치 서버 20대에 이 도구를 배포한다. 휠, 컨테이너, PyInstaller 중 무엇을 고르고 왜인가?

정답

  1. 필요 없는 경우: 함수 본문·클래스·새 모듈 추가 등 코드 변경 전부. 편집 가능 설치는 소스를 직접 참조하므로 저장 즉시 반영된다. 필요한 경우: pyproject.toml 을 고쳤을 때. 특히 [project.scripts] 에 명령어를 추가하거나 dependencies 를 늘렸을 때는 재설치해야 bin/ 의 실행 파일과 메타데이터가 갱신된다. version 을 올렸을 때도 importlib.metadata.version 이 옛 값을 돌려주므로 재설치가 필요하다.
  2. (1) 어느 파이썬 환경에 설치됐는가. 시스템 파이썬에 설치하고 가상환경에서 찾고 있을 수 있다. python -m pip show logkitwhich python 을 같이 본다. (2) 스크립트 디렉터리가 PATH 에 있는가. pip install --user 로 설치하면 ~/.local/bin(macOS 는 ~/Library/Python/3.x/bin)에 들어가는데 PATH 에 없는 경우가 많다. (3) [project.scripts] 가 휠에 실제로 들어갔는가. dist-info/entry_points.txt 를 확인한다. 어느 경우든 임시 우회로 python -m logkit.cli 가 동작하도록 __main__.py 를 두면 사용자가 덜 막힌다.
  3. 서버가 20대이고 파이썬 버전이 통제된 사내 환경이라면 휠 + 잠금 파일이 가장 단순하다. 사내 인덱스나 git 태그에서 설치하고, 롤백은 이전 버전 재설치로 끝난다. 서버마다 파이썬 버전이 제각각이거나 시스템 파이썬을 오염시키면 안 되는 상황이면 컨테이너가 낫다. 인터프리터까지 통째로 고정되므로 "내 서버에서만 안 돼"가 사라진다. PyInstaller 는 여기서 최악의 선택이다. 서버에는 어차피 파이썬이 있고, 30MB 짜리 바이너리를 빌드 서버 아키텍처마다 만들어야 하며, 문제가 생겼을 때 안을 들여다보기 어렵다.

마지막 단원에서는 이 도구를 운영에 올렸을 때 필요한 것들을 채운다. 무슨 일이 있었는지 알려 주는 로깅, 환경마다 달라지는 설정, 그리고 코드 스타일 논쟁을 없애는 린터·포매터다.