클라우드 Mac의 Python wheel 배포 검증: 태그와 네이티브 라이브러리

클라우드 Mac의 Python wheel 배포 검증: 태그와 네이티브 라이브러리

Windows에서 Python 프로젝트를 수정하고, 네이티브 확장이 포함된 패키지는 클라우드 Mac에서 빌드한다고 가정해 보겠습니다. 로그에는 wheel 생성이 성공했다고 나오지만, 동료가 설치하면 아키텍처가 맞지 않거나 동적 라이브러리를 찾을 수 없다는 오류가 발생합니다. 이런 문제는 대개 압축 파일 생성 여부가 아니라 패키지에 무엇이 들어 있는지, 의존성이 어디를 가리키는지, 테스트 중 소스 디렉터리를 몰래 참조하지는 않았는지에 달려 있습니다.

이 글에서는 pyproject.toml이 있는 기존 프로젝트를 기준으로 태그 점검, 네이티브 라이브러리 점검, 격리 설치라는 세 단계의 검증 절차를 구성합니다. 저장소 업로드는 다루지 않으며, 현재 장비에서 통과했다는 이유로 모든 시스템과 호환된다고 간주하지도 않습니다.

1. 인터프리터와 검증 범위부터 고정하기

원격 Mac의 프로젝트 루트 디렉터리에서 다음 명령을 실행합니다. 이미 설치되어 있고 프로젝트 요구 사항을 충족하는 Python을 사용하세요. 검증 도중에 인터프리터까지 함께 업그레이드하지 마세요.

python3 -m venv .wheel-build
.wheel-build/bin/python -m pip --version
.wheel-build/bin/python -c \
'import platform, sys; print(sys.version); print(platform.machine()); print(sys.executable)'
sw_vers
xcodebuild -version

출력 결과를 커밋 ID와 함께 보관합니다. 인터프리터 버전, 프로세스 아키텍처, 운영체제 버전은 모두 검증의 기준 정보입니다. Apple Silicon 장비라고 해서 현재 Python 프로세스가 반드시 arm64로 실행되는 것은 아닙니다.

지원할 Python 버전과 최소 macOS 버전, 네이티브 확장 포함 여부를 명확히 정하세요. 순수 Python 패키지도 네이티브 패키지에 의존할 수 있으므로 최상위 프로젝트만 확인해서는 안 됩니다.

wheel 파일명의 플랫폼 태그는 호환성을 선언하는 표시이지 변환 도구가 아닙니다. arm64 파일의 이름을 universal2로 바꿔도 바이너리에 다른 아키텍처가 추가되지는 않습니다.

2. 검증할 빌드 산출물을 명확히 만들기

먼저 dist에 이전 패키지가 남아 있지 않은지 확인한 뒤, 이번에 검증할 파일을 생성합니다. 빌드 백엔드와 그 버전은 프로젝트에서 사용하는 제약 조건 관리 방식으로 고정해야 합니다. 기본 빌드 격리 기능이 모든 빌드 의존성의 버전까지 자동으로 고정해 주지는 않습니다.

mkdir -p dist
find dist -maxdepth 1 -name '*.whl' -print
.wheel-build/bin/python -m pip wheel \
  --no-deps --wheel-dir dist .

위의 파일 확인 명령에서 이전 파일이 출력되면 별도의 보관 디렉터리로 옮기고, 새 산출물과 섞어서 검증하지 마세요. --no-deps는 런타임 의존성의 wheel을 생성하지 않는다는 뜻일 뿐, 빌드 단계에서 의존성을 다운로드하지 못하게 하는 옵션은 아닙니다.

다음 검사는 디렉터리에 wheel이 정확히 하나만 있는지 확인하고 내부 메타데이터를 출력합니다.

.wheel-build/bin/python - <<'PY'
from pathlib import Path
from zipfile import ZipFile

wheels = list(Path("dist").glob("*.whl"))
if len(wheels) != 1:
    raise SystemExit("Expected exactly one wheel")
wheel = wheels[0]
print(wheel.name)
with ZipFile(wheel) as archive:
    metadata = [
        name for name in archive.namelist()
        if name.endswith(".dist-info/WHEEL")
    ]
    if len(metadata) != 1:
        raise SystemExit("Invalid wheel metadata")
    print(archive.read(metadata[0]).decode("utf-8"))
PY

Tag와 파일명에 선언된 호환성이 배포 대상에 맞는지 대조합니다. py3-none-any는 일반적으로 플랫폼에 독립적인 패키지에 사용됩니다. 패키지 안에 네이티브 실행 파일이 들어 있다면 태그를 그대로 믿을 것이 아니라 패키징 방식을 다시 검토해야 합니다.

3. 네이티브 라이브러리가 빌드 장비에 의존하는지 추적하기

wheel을 임시 디렉터리에 풀고 확장 모듈과 동적 라이브러리를 점검합니다. 다음 코드는 검사만 수행하며 패키지 안의 프로그램을 실행하지 않습니다.

inspect_dir="$(mktemp -d)"
.wheel-build/bin/python - "$inspect_dir" <<'PY'
from pathlib import Path
from zipfile import ZipFile
import sys

wheel, = Path("dist").glob("*.whl")
with ZipFile(wheel) as archive:
    archive.extractall(sys.argv[1])
PY
find "$inspect_dir" -type f \
  \( -name '*.so' -o -name '*.dylib' \) \
  -exec file {} \; -exec otool -L {} \;

이 압축 해제 절차는 프로젝트에서 직접 만든 신뢰할 수 있는 산출물에만 사용하세요. 출처를 알 수 없는 압축 파일을 작업 디렉터리에 바로 풀지 마세요.

사용자 홈 디렉터리, 빌드 작업 디렉터리, 로컬 패키지 관리자의 설치 경로 접두사가 있는지 중점적으로 살펴봅니다. 이런 절대 경로는 다른 장비에 존재하지 않는 경우가 많습니다. 시스템 라이브러리 경로를 모두 오류로 간주해서는 안 됩니다. 반대로 @rpath나 @loader_path가 있다고 해서 자동으로 올바른 것도 아닙니다. 실제로 패키지에 포함된 라이브러리와 해당 라이브러리의 로드 경로 설정까지 확인해야 합니다.

수정한 뒤에는 반드시 다시 빌드하고 검증해야 합니다. 압축을 푼 디렉터리에서만 파일을 고친 뒤, 정작 수정되지 않은 원본 wheel을 배포해서는 안 됩니다. 점검이 끝나면 이번에 만든 임시 디렉터리를 삭제합니다.

4. 소스 디렉터리 밖에서 실제로 설치하기

먼저 wheel과 모든 런타임 의존성을 별도 디렉터리에 준비합니다. 이 단계에서는 네트워크에 접속할 수 있습니다. 의존성 버전은 프로젝트의 릴리스 제약 조건으로 관리하고, 최종 다운로드 목록을 보관해야 합니다.

mkdir -p wheelhouse
.wheel-build/bin/python -m pip download \
  --only-binary=:all: --dest wheelhouse dist/*.whl

의존성 중 하나라도 조건에 맞는 바이너리 패키지가 없다면 이 단계는 실패해야 합니다. 아무런 고지 없이 소스 빌드로 대체하지 마세요. 그렇게 하면 검증 대상 자체가 달라집니다.

다음은 소스 디렉터리 밖에 환경을 만드는 절차입니다. 실행할 때 프로젝트에서 실제로 사용하는 임포트 이름을 입력하세요. 이 이름은 배포 패키지 이름과 다를 수 있습니다.

read -r -p "Import module: " module
wheelhouse="$(pwd)/wheelhouse"
wheel="$(find "$(pwd)/dist" -maxdepth 1 -name '*.whl' -print)"
sandbox="$(mktemp -d)"
python3 -m venv "$sandbox/venv"
(
  cd "$sandbox"
  ./venv/bin/python -m pip install \
    --no-index --find-links "$wheelhouse" "$wheel"
  ./venv/bin/python -m pip check
  ./venv/bin/python -I - "$module" <<'PY'
import importlib
import sys

module = importlib.import_module(sys.argv[1])
print(getattr(module, "__file__", None))
print(list(getattr(module, "__path__", [])))
PY
)

임포트한 모듈의 위치가 소스 트리가 아니라 새 환경 안에 있는지 확인합니다. pip check는 선언된 의존성 관계만 점검합니다. 네이티브 라이브러리가 로드되는지 확인하지 않으며, 지연 로딩되는 기능을 호출하지도 않습니다. 따라서 확장 모듈을 실제로 실행하는 기능 테스트도 한 번 수행해야 합니다. 예를 들어 고정된 소규모 샘플을 처리하고 결과를 단언문으로 검증할 수 있습니다.

5. 통과 기준을 릴리스 기록에 남기기

매번 최소한 커밋 ID, 인터프리터 정보, wheel 파일명, 해시값, 의존성 목록, 기능 스모크 테스트 결과를 보관합니다. shasum -a 256 dist/*.whl로 해시값을 기록해 방금 테스트한 파일과 실제 배포하는 파일이 동일한지 확인하세요.

검사가 실패하면 계층별로 원인을 해결합니다. 태그가 맞지 않으면 빌드 대상을 다시 확인하고, 동적 라이브러리가 누락되었다면 의존성 수집 과정을 점검합니다. 소스 디렉터리 안에서만 임포트된다면 패키지 검색 규칙과 리소스 선언을 확인하세요. 검색 경로를 늘려 누락된 파일 문제를 덮어서는 안 됩니다.

마지막으로, 지원한다고 명시한 최소 운영체제 버전과 다른 대상 인터프리터에서도 설치와 실행을 반복합니다. 더 최신 시스템에서 한 번 성공했다는 사실은 현재 조합이 통과했다는 것만 증명합니다. 패키지에 더 넓은 호환성 태그를 붙이는 것보다 실제 검증 범위를 명확히 기록하는 편이 더 신뢰할 수 있습니다.

자주 묻는 질문

Apple Silicon에서 빌드하면 자동으로 universal2가 되나요?

아닙니다. 관련 네이티브 바이너리에 arm64와 x86_64가 모두 있어야 하며 각 아키텍처의 실행도 검증해야 합니다. 파일 이름만 바꿔서는 호환성이 생기지 않습니다.

왜 소스 디렉터리 밖에서 가져오기를 검사하나요?

로컬 모듈이 wheel의 파일 누락을 가릴 수 있기 때문입니다. 임시 디렉터리의 새 가상 환경에 설치하고 격리 모드에서 실제 가져오기 경로를 확인하세요.

전용 클라우드 Mac에 작업을 맡기세요

작업 일정에 맞춰 위치와 요금제를 선택하고 전용 물리 Mac mini를 대여하세요. 개통 후 콘솔에서 접속 정보를 확인할 수 있으며, 이용 중 문제가 생기면 콘솔에서 지원 티켓을 제출하세요.

클라우드 Mac 요금제 선택

작업 일정에 맞는 위치 선택

사양과 이용 기간을 확인한 뒤 나만의 전용 물리 Mac mini를 개통하세요.