Youngkwang YangEnglish
목차

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

Python

파이썬에서 uv init --lib으로 프로젝트를 만들면, pyproject.toml이 생기는데, 그 파일 안에 빌드 백엔드가 선언되어 있다.

(uv가 만든 pyproject.toml 예시)

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

위 설정은 라이브러리를 빌드할 때 사용할 빌드 백엔드를 지정한다.

  • requires는 빌드 백엔드를 불러오고 실행하는 데 필요한 패키지 목록이다.
  • build-backend에는 pip, uv 같은 패키지 인스톨러가 import할 빌드 백엔드 경로가 들어간다.

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 같은 도구가 setup.py를 실행하는 방식이었다.

참고로 setup.py 파일은 현재도 빌드 설정 파일의 역할을 하고 있다. 다만 python setup.py install로 직접 실행하는 방식은 deprecated 되어서 아래처럼 경고가 뜬다. Python 3.12부터는 새 가상환경에 setuptools가 기본으로 설치되지 않아서, 따로 설치하지 않았다면 import 오류가 날 수 있다.

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

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

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

그럼 setup.py를 실행하지 않고 파싱하면 되지 않나 싶을 수 있는데, setup.py는 정해진 포맷의 데이터 파일이 아니라 단순히 Python 코드다. 빌드 설정이 setup(...) 함수 인자로 전달되고, 운영체제 정보, Python 버전에 따른 의존성 분기 등.. 실행해야만 얻을 수 있는 값들이 들어갈 수 있어서 결국 실행해야 한다.

pyproject.toml의 도입 (PEP 518)

PEP 518이 채택되면서, 빌드를 시작하는 데 필요한 패키지를 setup.py가 아닌 pyproject.toml에 정의할 수 있게 됐다.

[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만 있는 오래된 프로젝트도 내부적으로 setuptools를 빌드 백엔드로 지정해서 같은 방식으로 빌드한다.

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

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

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

프론트엔드는 격리 환경을 만들어 requires에 명시된 패키지를 설치하고, build-backend에 적힌 백엔드를 import해서 정해진 함수를 호출한다. 백엔드는 필수 함수인 build_wheel과 build_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년 말 자료라 2026년 기준으로는 다를 수 있음) PyPI 다운로드 상위 8000개 프로젝트를 조사한 결과,3 setuptools가 압도적 1위이고 그 뒤로 Poetry, Hatchling, Flit 등이 사용되는 걸 볼 수 있다.

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

최근에 나온 의존성 도구들의 대부분은 직접 구현한 빌드 백엔드를 기본값으로 설정한다. (예를 들어 uv는 uv_build, Poetry는 poetry-core) 아마 도구와 빌드 백엔드를 같이 관리하면 설정과 동작을 맞추기 쉽고 성능도 챙길 수 있어서인 것으로 보인다.

uv 문서 내용을 일부 인용하면

uv supports all build backends (as specified by PEP 517), but also provides a native build backend (uv_build) that integrates tightly with uv to improve performance and user experience.

아마 uv에서 직접 개발 관리하다 보니 새로운 표준에 대한 개발도 비교적 쉽고 uv의 방향성에 맞게 조정할 수 있다는 이점이 있지 않을까 한다.

wheel 내부 구조

빌드 백엔드가 만든 wheel은 코드와 설치 메타데이터를 담은 압축 파일이다. 별개의 포맷이 아닌, 일반 압축 파일이다 보니 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

위 규칙에 따라 코드와 .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 파일을 만들고 그 파일 이름을 반환해야 하는데, 어떤 파일을 포함할지 결정하거나, 확장 모듈을 컴파일하는 내부 작업은 빌드 백엔드와 빌드할 패키지에 따라 달라진다. (예를 들어 C 확장) 위의 순수 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

아래 예시 명령어는 위 구조를 압축하는 코드다. 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

sdist는 wheel과 다르게 바로 설치되지 않고 변환 과정을 거치는데, 출력된 로그를 보면 격리 환경에 빌드 의존성을 설치하고 빌드 백엔드의 함수를 호출해 wheel을 만든 뒤에 wheel을 설치한다. 반대로 설치 환경에서 사용 가능한 wheel이 있다면 빌드 과정이 필요 없어서 sdist보다 wheel이 보통 설치가 빠르다.

참고

Footnotes

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

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

  3. PEP 517 build system popularity. 2024-12-01 기준 다운로드 상위 8000개 조사. 실제 분석 대상은 2025-01-08에 받은 7434개 sdist. ↩