파이썬 패키징과 배포 - pyproject.toml 휠 빌드와 CLI 등록 (파이썬 고급 23단원)
이 단원에서 배우는 것
22단원에서 logkit 은 src/ 레이아웃과 테스트를 갖췄고 pip install -e 로 설치해 테스트를 돌렸다. 그 설치가 어떻게 성립했는지는 아직 설명하지 않았다. 이 단원에서 pyproject.toml 을 제대로 쓰고, 다른 사람이 pip install logkit 후 logkit 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.toml 의 version 이 유일한 원본이고, 런타임에는 설치된 메타데이터에서 읽어 온다. 이렇게 하지 않으면 __init__.py 와 pyproject.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
uv 나 poetry 를 쓰면 이 잠금 파일을 해시까지 포함해 자동으로 관리해 준다. 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] name 은 PyPI 에 올라가는 배포 이름이고, import 하는 이름은 src/ 아래 디렉터리 이름이다. 둘은 무관하다. pip install scikit-learn 후 import sklearn 하는 것이 대표 예다. 문제는 이 자유도가 사고를 만든다는 점이다.
- 배포 이름은 대소문자·
-·_를 구분하지 않고 정규화되지만, 임포트 이름은 정확히 일치해야 한다.name = "log-kit"인데 디렉터리가logkit이면pip install log-kit후import 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.txt 와 dependencies 를 둘 다 관리한다
14단원에서 만든 requirements.txt 를 그대로 두고 pyproject.toml 을 추가하면, 둘 다 있는데 내용이 다른 상태가 된다. 자주 보는 사고다. pip install -e . 은 pyproject.toml 만 보고, pip install -r requirements.txt 는 그 파일만 본다. 개발자마다 다른 명령을 쓰면 환경이 갈라진다. 규칙을 정한다.
- 패키지의 의존성 정의는
pyproject.toml한 곳에만 둔다 requirements.lock은 생성물이다. 손으로 고치지 않고pip freeze나uv lock으로 만든다- 파일 맨 위에 "이 파일은 자동 생성됩니다" 주석을 넣는다
스스로 확인하기
pip install -e .로 설치한 뒤src/logkit/parser.py를 고쳤다. 재설치가 필요한 경우와 필요 없는 경우를 각각 들어라.- 동료가
pip install logkit후logkit: command not found라고 한다. 확인할 것 세 가지는? - 사내 배치 서버 20대에 이 도구를 배포한다. 휠, 컨테이너, PyInstaller 중 무엇을 고르고 왜인가?
정답
- 필요 없는 경우: 함수 본문·클래스·새 모듈 추가 등 코드 변경 전부. 편집 가능 설치는 소스를 직접 참조하므로 저장 즉시 반영된다. 필요한 경우:
pyproject.toml을 고쳤을 때. 특히[project.scripts]에 명령어를 추가하거나dependencies를 늘렸을 때는 재설치해야bin/의 실행 파일과 메타데이터가 갱신된다.version을 올렸을 때도importlib.metadata.version이 옛 값을 돌려주므로 재설치가 필요하다. - (1) 어느 파이썬 환경에 설치됐는가. 시스템 파이썬에 설치하고 가상환경에서 찾고 있을 수 있다.
python -m pip show logkit과which 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를 두면 사용자가 덜 막힌다. - 서버가 20대이고 파이썬 버전이 통제된 사내 환경이라면 휠 + 잠금 파일이 가장 단순하다. 사내 인덱스나 git 태그에서 설치하고, 롤백은 이전 버전 재설치로 끝난다. 서버마다 파이썬 버전이 제각각이거나 시스템 파이썬을 오염시키면 안 되는 상황이면 컨테이너가 낫다. 인터프리터까지 통째로 고정되므로 "내 서버에서만 안 돼"가 사라진다. PyInstaller 는 여기서 최악의 선택이다. 서버에는 어차피 파이썬이 있고, 30MB 짜리 바이너리를 빌드 서버 아키텍처마다 만들어야 하며, 문제가 생겼을 때 안을 들여다보기 어렵다.
마지막 단원에서는 이 도구를 운영에 올렸을 때 필요한 것들을 채운다. 무슨 일이 있었는지 알려 주는 로깅, 환경마다 달라지는 설정, 그리고 코드 스타일 논쟁을 없애는 린터·포매터다.