파이썬 패키징 #6 배포: wheel 빌드와 PyPI 업로드
지금까지는 남이 만든 패키지를 받는 쪽이었습니다. 이번에는 반대편에 서 봅니다. 유틸리티 모듈을 팀의 다른 프로젝트에서도 쓰고 싶거나, 자동화 #7에서 만든 CLI를 uv tool install로 설치되게 하고 싶다면, 코드를 설치 가능한 패키지로 만들어야 합니다. pip install requests가 되기까지 requests 쪽에서 일어난 일이 이번 글의 내용입니다.
패키지가 될 프로젝트의 모양: src 레이아웃 #
배포할 프로젝트는 코드를 src/ 아래 패키지 디렉터리에 둡니다.
invoice-tool/
├── pyproject.toml
├── README.md
├── src/
│ └── invoice_tool/
│ ├── __init__.py
│ └── cli.py
└── tests/uv init --package invoice-tool로 시작하면 이 구조가 만들어집니다. 루트에 코드를 두는 방식(flat 레이아웃)도 동작은 하지만, src 레이아웃이 현대 표준인 이유는 하나입니다. “설치된 패키지"와 “현재 디렉터리의 소스"가 뒤섞이는 사고를 막습니다. 루트에 코드가 있으면 테스트가 설치본이 아니라 소스를 임포트해서, 빌드에서 파일이 빠져도 로컬 테스트는 통과하는 함정이 생깁니다. 이름의 하이픈이 디렉터리에서 언더스코어가 되는 것(invoice-tool → invoice_tool)은 패키지 이름 규칙 때문입니다.
빌드 백엔드: 소스를 배포물로 바꾸는 기계 #
#3에서 읽는 법만 배우고 미뤄 둔 구역이 이제 주인공입니다.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"빌드 백엔드는 소스 트리를 받아 배포물을 만들어 내는 도구입니다. 표준 인터페이스(PEP 517)가 정의되어 있어 백엔드는 갈아 끼울 수 있고, hatchling이 무난한 기본값입니다. 만들어 내는 배포물은 두 종류입니다.
- sdist(소스 배포물): 소스 코드를 그대로 묶은 tar.gz입니다. 설치하는 쪽이 빌드를 다시 합니다.
- wheel: 빌드가 끝난 zip입니다. 설치는 압축을 푸는 것에 가깝게 끝납니다. #2에서 pip가 받아 오던 것이 바로 이것입니다.
순수 파이썬 패키지라면 wheel 하나가 모든 플랫폼을 커버하고, C 확장이 있으면 플랫폼별 wheel이 필요해집니다. 관례는 둘 다 올리는 것입니다.
빌드와 로컬 검증 #
uv builddist/ 아래에 두 파일이 생깁니다.
dist/
├── invoice_tool-0.1.0.tar.gz # sdist
└── invoice_tool-0.1.0-py3-none-any.whl # wheelwheel 파일명의 py3-none-any는 “모든 파이썬 3, ABI 무관, 모든 플랫폼"이라는 호환성 태그입니다. 올리기 전에 로컬에서 설치 리허설을 합니다.
uvx --with dist/invoice_tool-0.1.0-py3-none-any.whl invoice-tool --help개발 중에는 이런 리허설이 매번 필요 없습니다. uv 프로젝트에서는 자기 자신이 editable 설치(소스 수정이 즉시 반영되는 링크 방식)로 환경에 들어가 있어, uv run invoice-tool이 항상 현재 소스로 동작합니다.
TestPyPI에서 리허설, PyPI에 본선 #
PyPI에는 되돌리기가 없습니다. 한번 올린 버전 번호는 삭제해도 재사용할 수 없으므로, 첫 배포는 연습장인 TestPyPI에서 리허설하는 것이 정석입니다.
# TestPyPI에 업로드 (계정·토큰은 test.pypi.org에서 발급)
uv publish --index testpypi --token $TESTPYPI_TOKEN
# 설치까지 확인되면 본선
uv publish --token $PYPI_TOKEN토큰은 계정 전체 권한이 아니라 프로젝트 범위 토큰으로 발급해 씁니다. 버전을 올릴 때는 pyproject.toml의 version을 수정하고(semver 관례: 호환 수정은 패치, 기능 추가는 마이너, 호환 파괴는 메이저) 빌드부터 다시 합니다.
Trusted Publishing: 토큰 없는 배포가 현대 표준입니다 #
토큰은 유출 사고의 단골입니다. 그래서 현재 표준은 CI에서 토큰 없이 배포하는 Trusted Publishing입니다. PyPI 프로젝트 설정에 “이 GitHub 저장소의 이 워크플로가 배포 주체"라고 등록해 두면, GitHub Actions가 실행 시점에 OIDC로 신원을 증명하고 단기 자격 증명을 받아 올립니다. 저장소에 시크릿을 저장할 필요 자체가 없어집니다.
# .github/workflows/publish.yml (핵심부)
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # OIDC 신원 증명
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv build
- run: uv publish # Trusted Publishing이면 토큰 불필요테스트 #7의 CI에 이어지는 그림입니다. 릴리스를 만들면 테스트를 통과한 코드가 사람 손 없이 PyPI로 갑니다.
공개하고 싶지 않다면: 사내 배포의 선택지 #
모든 패키지가 PyPI로 갈 필요는 없습니다. 사내 전용이라면 두 가지가 실용적입니다.
- git 의존성:
uv add "invoice-tool @ git+https://github.com/org/invoice-tool"처럼 저장소를 직접 의존성으로 겁니다. 별도 인프라 없이 시작할 수 있고, 태그로 버전을 고정합니다. - 프라이빗 인덱스: 조직 규모가 커지면 사설 패키지 인덱스(클라우드 아티팩트 저장소 등)를 두고
[[tool.uv.index]]로 등록합니다. 워크플로는 PyPI와 동일해집니다.
정리 #
이번 글에서 다룬 내용입니다.
- 배포할 프로젝트는 src 레이아웃으로 만듭니다. 설치본과 소스가 섞이는 임포트 사고를 구조적으로 막아 줍니다
- 빌드 백엔드(hatchling 등)가 소스를 sdist와 wheel로 바꿉니다. wheel은 빌드가 끝난 zip이고, 관례는 둘 다 올리는 것입니다
uv build로 빌드하고, 올리기 전에 wheel을 직접 설치해 리허설합니다. 개발 중에는 editable 설치 덕에uv run이 항상 현재 소스로 동작합니다- PyPI의 버전 번호는 재사용이 안 됩니다. 첫 배포는 TestPyPI에서 연습하고, 토큰은 프로젝트 범위로 좁힙니다
- CI 배포의 현대 표준은 Trusted Publishing입니다. OIDC로 신원을 증명하므로 저장소에 토큰을 둘 필요가 없습니다
- 사내 전용 패키지는 git 의존성이나 프라이빗 인덱스로 PyPI 없이 배포합니다
다음 글(#7 팀 규약과 도구 선택)은 시리즈의 마무리입니다. uv, poetry, pip를 한 표에 놓고 고르는 기준을 정리하고, CI 캐싱과 도커까지 팀 단위의 규약으로 시리즈 전체를 묶습니다.