Youngkwang YangEnglish
목차

파이썬 패키지가 빌드되는 방식

Python

파이썬에서 uv로 새 프로젝트를 시작하면(uv init --lib) pyproject.toml이 생기는데, 아래처럼 빌드 백엔드(build backend)가 선언되어 있다.

[build-system]
requires = ["uv_build>=0.11.13,<0.12.0"]
build-backend = "uv_build"

이 설정은 패키지를 빌드할 때 사용할 빌드 백엔드를 지정한다. requires에는 빌드 백엔드를 불러오는 데 필요한 패키지가 들어가고, build-backend에는 pip이나 uv가 import할 빌드 백엔드의 경로가 들어간다. 위 값은 uv가 만든 프로젝트의 예시고, 어떤 도구로 init 했는지에 따라 값이 다르다.

uv나 pip 같은 도구는 이 설정을 파싱한 뒤 requires에 있는 패키지를 설치하고, build-backend에 적힌 백엔드를 불러서 빌드 함수를 호출한다.

setup.py로 패키지를 설치하던 방법

pyproject.toml이 도입되기 전에는 프로젝트 루트에 있는 setup.py를 실행해 패키지를 빌드하고 설치했다.

# setup.py
from setuptools import setup

setup(name="hello", version="0.1.0", packages=["hello"])
$ python setup.py install

그때는 빌드 방법을 정한 표준이 없어서, setup.py를 실행해 설치하는 관례가 사실상 표준처럼 쓰였다. python setup.py install로 직접 설치하거나, pip이 sdist(소스 배포판)를 내려받아 압축을 풀고 그 안에 있는 setup.py를 실행하는 방식이었다.

setup.py 파일은 지금도 빌드 설정에 쓸 수 있다. deprecated 된 건 python setup.py install처럼 setup.py를 명령어로 직접 실행하는 방식이다. 이 명령을 실행하면 아래와 같은 경고가 나오고, Python 3.12부터는 새 가상환경에 setuptools도 기본으로 설치되지 않아서 따로 설치하지 않았다면 import 에러가 날 거다.

SetuptoolsDeprecationWarning: setup.py install is deprecated.
Please avoid running ``setup.py`` directly.

setup.py 직접 실행이 deprecated 된 이유는 setup.py 방식에 구조적으로 문제가 있었기 때문인데.. 빌드하기 위해 뭐가 필요한지 setup.py 안에 있다 보니 그 정보를 읽으려면 setup.py를 실행해야 하고, 실행하려면 빌드 의존성이 먼저 설치되어 있어야 한다.

setup.py 실행빌드 의존성 설치실행하려면 빌드 의존성이 필요하다어떤 의존성이 필요한지는 실행해야 알 수 있다

setup.py를 실행하지 않고 파싱하면 되지 않나 싶을 수 있는데, setup.py는 정해진 형식의 데이터 파일이 아니라 그냥 Python 코드다. 빌드 설정이 setup(...) 함수 인자로 넘어가고, 운영체제나 Python 버전에 따라 의존성을 계산하는 코드도 들어갈 수 있어서 확정된 값을 얻으려면 결국 실행해야 한다.

pyproject.toml의 도입 (PEP 518)

PEP 518부터 빌드를 시작하는 데 필요한 패키지를 pyproject.toml의 [build-system] 섹션에 선언하게 됐다. Pip은 setup.py를 실행하기 전에 이 설정을 읽으므로 빌드 의존성을 먼저 설치할 수 있다.

[build-system]
requires = ["setuptools"]

requires[build-system]에서 반드시 정의해야 하는 키고, 빌드를 시작하는 데 필요한 패키지를 나열한다. build-backend는 이 시점에는 없었고, 다음 표준인 PEP 517에서 추가됐다.

지금도 pyproject.toml이 없거나 build-backend가 정의되어 있지 않은 프로젝트를 설치하면, pip은 하위호환을 위해 setuptools 기반의 기본 설정을 적용한다.1 예시로 setup.py만 있는 프로젝트를 설치하면 아래처럼 로그가 표시된다.

$ ls legacy-project/
setup.py  hello/
$ pip install ./legacy-project
Processing ./legacy-project
  Preparing metadata (pyproject.toml): started
  Preparing metadata (pyproject.toml): finished with status 'done'
...
Successfully installed hello-0.1.0

pyproject.toml이 없는데도 로그에 (pyproject.toml)이 나오는 건, pip이 기본 빌드 시스템 설정을 적용한 뒤 PEP 517 빌드 과정으로 처리하기 때문이다. 이 하위호환 덕분에 오래된 setup.py 기반 패키지도 지금도 설치할 수 있다.

프론트엔드와 빌드 백엔드의 분리 (PEP 517)

PEP 518은 빌드를 시작하기 전에 뭐가 필요한지를 기록했지만, 빌드를 어떤 방식으로 실행할지는 정하지 않았다. PEP 517은 여기에 build-backend를 추가하고, 빌드를 정해진 이름의 Python 함수로 호출하도록 했다. 이때 패키지를 빌드하는 라이브러리를 백엔드, 백엔드를 호출하는 도구를 프론트엔드라고 부른다.

  • 프론트엔드(build frontend)는 사용자가 실행하는 도구로, pip, uv, build가 여기에 속한다.
  • 빌드 백엔드는 패키지를 빌드해서 wheel2이라는 설치용 파일을 만드는 라이브러리로, setuptools, hatchling, uv_build가 여기에 속한다.

프론트엔드는 격리 환경을 만들어 requires에 명시된 패키지를 설치하고, build-backend에 적힌 백엔드를 import해서 정해진 함수를 호출한다. 백엔드가 구현해야 하는 필수 훅은 build_wheelbuild_sdist 두 개다.

프론트엔드pip, uv, build1. 격리 환경 생성, requires 설치격리 환경백엔드setuptools, uv_build2. build_wheel()build_sdist()3..whl.tar.gz

빌드 백엔드의 인터페이스는 Python 함수이므로 프론트엔드를 거치지 않고 직접 호출할 수도 있다. 아래처럼 hello 모듈과 pyproject.toml이 있는 프로젝트를 가정한다.

hello-project/
├─ hello/
│  └─ __init__.py
└─ pyproject.toml
# hello/__init__.py
def greet():
    return "hello"
# pyproject.toml
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

# 패키지 이름과 버전 메타데이터
[project]
name = "hello"
version = "0.1.0"

프로젝트 루트에서 빌드 백엔드인 setuptools를 설치하고 build_wheel을 직접 호출하면 아래처럼 wheel 파일이 만들어진다.

$ pip install setuptools
$ python -c "from setuptools import build_meta; print(build_meta.build_wheel('dist'))"
running bdist_wheel
running build
...
adding 'hello-0.1.0.dist-info/RECORD'
hello-0.1.0-py3-none-any.whl
$ ls dist/
hello-0.1.0-py3-none-any.whl

인자로 넘긴 dist/ 디렉터리에 wheel이 생겼다. 여기서는 build_wheel을 직접 호출했고, setuptools가 내부에서 bdist_wheel 명령을 사용하기 때문에 로그에 running bdist_wheel이 남는다.

위에서는 setuptools를 직접 호출했지만 다른 빌드 백엔드도 훅의 이름과 반환 형식은 같다.

빌드 백엔드 사용 현황

앞에서 나온 빌드 백엔드 중 뭐가 많이 쓰일까. 2024년 말 기준 PyPI 다운로드 상위 8000개 프로젝트를 조사한 결과를 보면,3 setuptools가 압도적 1위고 그 뒤로 Poetry, Hatchling, Flit 등이 사용된다.

setuptools를 쓰는 패키지 상당수는 build-backend 선언 없이 하위호환으로 빌드되고 있다. 잘 동작하는 프로젝트라면 굳이 백엔드를 바꿀 이유가 없으니, 마이그레이션을 안 한 프로젝트가 많아 수치가 높게 나온 것으로 보인다.

최근에 나온 의존성 관리 도구들의 대부분은 직접 구현한 빌드 백엔드를 기본값으로 설정한다. uv는 uv_build, Poetry는 poetry-core를 사용한다. 도구와 빌드 백엔드를 같이 관리하면 설정과 동작을 맞추기 쉽고 성능도 챙길 수 있어서인 것으로 보인다. uv 문서에서도 uv_build를 uv와 긴밀하게 통합해 성능과 사용성을 개선하는 빌드 백엔드로 설명한다.

wheel 내부 구조

방금 빌드 백엔드가 만든 wheel은 패키지 코드와 설치 메타데이터를 담은 압축 파일이다(사실상 zip..). 그래서 unzip으로 내용을 확인할 수 있다.

$ unzip -l hello-0.1.0-py3-none-any.whl
  Length      Name
---------     ----
       32     hello/__init__.py
       49     hello-0.1.0.dist-info/METADATA
       81     hello-0.1.0.dist-info/WHEEL
      270     hello-0.1.0.dist-info/RECORD

이 예시처럼 순수 Python 코드만 있는 wheel은 구조가 단순해서 빌드 백엔드 없이 직접 만들 수도 있다. 안에 들어 있는 파일의 역할은 다음과 같다.

파일명에도 규칙이 있다.

helloname패키지 이름-0.1.0version패키지 버전-py3pythonPython 3에서 동작한다는 뜻-noneabi컴파일된 확장이 없어서 특정 Python 빌드에 묶이지 않는다는 뜻-anyplatform어떤 OS, CPU에서든 설치된다는 뜻.whl

hello 패키지 코드와 .dist-info 메타데이터를 ZIP으로 만들고, 파일 이름을 wheel 규칙에 맞추면 wheel이 된다.

$ zip -r hello-0.1.0-py3-none-any.whl hello hello-0.1.0.dist-info
$ pip install --no-index hello-0.1.0-py3-none-any.whl
Processing ./hello-0.1.0-py3-none-any.whl
Installing collected packages: hello
Successfully installed hello-0.1.0
$ python -c "import hello; print(hello.greet())"
hello

build_wheel은 최종 결과로 이 wheel 파일을 만들어 반환해야 한다. 어떤 파일을 포함할지 정하거나 확장 모듈을 컴파일하는 내부 작업은 백엔드와 패키지에 따라 달라진다. 위의 순수 Python 패키지라면 대략 아래 순서로 처리된다.

build_wheel()파일 선택설정을 읽고 담을 파일 결정메타데이터 생성METADATA, WHEELRECORD 작성 + zip파일마다 해시 계산, 압축

flit_core의 wheel을 빌드하는 코드를 보면 파일을 고르고 메타데이터와 RECORD를 만든 뒤 wheel로 압축하는 과정이 구현되어 있다. hatchling 같은 다른 빌드 백엔드는 설정을 읽고 파일을 고르는 방식이 다르지만, PEP 517 프론트엔드에는 같은 형식의 wheel을 반환한다.

sdist 내부 구조

sdist는 패키지의 소스와 빌드 설정을 묶은 소스 배포판이다. 기본 구조는 wheel보다 단순하게 보인다.

hello-0.1.0.tar.gz
└─ hello-0.1.0/
   ├─ pyproject.toml
   ├─ PKG-INFO
   └─ hello/
      └─ __init__.py

PKG-INFO는 wheel의 METADATA와 같은 형식으로 작성된다.

Metadata-Version: 2.2
Name: hello
Version: 0.1.0

아래 명령은 이 구조를 tar로 압축한 최소 예시다. Pip으로 설치하면 빌드 과정이 로그에 표시된다.

$ tar czf hello-0.1.0.tar.gz hello-0.1.0
$ pip install --no-cache-dir hello-0.1.0.tar.gz
Processing ./hello-0.1.0.tar.gz
  Installing build dependencies: started
  Getting requirements to build wheel: started
  Preparing metadata (pyproject.toml): started
Building wheels for collected packages: hello
  Building wheel for hello (pyproject.toml): started
  Created wheel for hello: filename=hello-0.1.0-py3-none-any.whl ...
Installing collected packages: hello
Successfully installed hello-0.1.0

wheel과 달리 sdist는 바로 설치되지 않는다. 로그를 보면 격리 환경에 빌드 의존성을 설치하고(PEP 518), 빌드 백엔드의 훅을 호출해 wheel을 만든 뒤(PEP 517), 마지막으로 그 wheel을 설치한다. 반대로 환경에 맞는 wheel을 받으면 이 빌드 과정이 필요 없다. 그래서 보통 sdist보다 wheel을 설치하는 편이 빠르다.

빌드 의존성을 격리 환경에 설치하면 빌드에만 필요한 패키지가 사용자 환경에 남지 않고, 사용자 환경에 이미 설치된 패키지가 빌드 결과에 영향을 주는 일도 줄어든다.

참고

Footnotes

  1. pip은 이 하위호환을 setuptools.build_meta:__legacy__라는 빌드 백엔드로 구현한다.

  2. wheel이라는 이름은 치즈 휠 🧀 에서 왔다. 배경 참고.

  3. PEP 517 build system popularity. 2024-12-01 기준 다운로드 상위 8000개 조사.