파이썬에서 보편적 락파일 표준을 논의하는 과정에서, 동적 메타데이터를 허용하는 과도한 유연성이 생태계 전반에 성능 저하와 복잡성, 캐시/해결기 문제를 낳고 있음을 짚는다. 자바스크립트의 단일·정적 메타데이터 모델과 대비해, 파이썬도 제약을 강화해 일관성과 가시성을 높여야 한다는 제안을 담았다.
작성일: 2024년 11월 26일
현재 파이썬에는 새로운 보편적 락파일 표준을 만들기 위한 논의가 진행 중이며, 대부분은 Python 포럼에서 이루어지고 있다. 이 논의는 모두를 만족시키는 표준을 만드는 일이 얼마나 어려운지를 드러냈다. 각기 다른 파이썬 패키징 도구들이 락파일의 형태나 용도를 조금씩 다르게 상정하고 있다는 점이 분명해졌다.
그 논의 속에서 또 하나의 주제가 떠올랐다. 파이썬은 메타데이터 문제를 안고 있다. 파이썬의 메타데이터 시스템은 지나치게 복잡하며, 내가 “제약의 부재”라고 부를 만한 문제로 고통받고 있다.
자바스크립트는 제약이 어떻게 시스템을 단순화하고 개선하는지 잘 보여주는 예다. 자바스크립트에서 메타데이터는 단순하다. 로컬에서 패키지를 개발하든 npm에서 패키지를 사용하든, 메타데이터는 같은 방식으로 표현된다. 단일 package.json 파일이 name, version, dependencies 같은 패키지의 핵심 메타데이터를 담는다. 이 단순함은 크지만 유익한 제약을 부과한다:
npm 패키지와 메타데이터 사이에 1:1 관계가 있다. 모든 npm 패키지는 단 하나의 package.json을 가지며, 그것이 메타데이터의 단일 진실 공급원(source of truth)이다. 메타데이터는 require('packageName/package.json')처럼 프로그램적으로도 손쉽게 접근할 수 있다.
의존성(및 다른 모든 메타데이터)은 플랫폼과 아키텍처를 가로질러 일관적이다. 플랫폼별 바이너리는 optionalDependencies와 짝지어진 필터 메커니즘(os, cpu)으로 처리된다. 1
모든 메타데이터는 정적이며, 배포나 설치 전에 package.json을 명시적으로 수정해야만 업데이트된다. npm version patch처럼 그 메타데이터를 조작하는 도구도 제공되며, 파일을 제자리에서 수정한다.
이러한 제약은 다음과 같은 이점을 제공한다:
의존성이 로컬로 설치되었든 원격 소스에서 왔든 동작이 동일하다. git에서 오든 npm에서 오든 파일시스템 레이아웃조차 차이가 없다. 덕분에 설치된 의존성을 로컬 개발 사본으로 바꾸더라도 기능 변화 없이 동작하게 할 수 있다.
모든 메타데이터에 대해 단 하나의 진실 공급원이 존재한다. package.json만 수정하면 되고, 그 메타데이터의 소비자는 해당 파일 변경만 감시하면 된다. 복잡한 외부 정보를 참조할 필요가 없다.
해결기(resolver)는 버전에 대한 의존성 메타데이터를 가져오기 위해 단 하나의 API 호출에 의존할 수 있어 효율이 좋아진다. 실제로는 의존성의 모든 가능한 의존성 정보를 얻기 위해 단 하나의 URL만 조회하면 된다. 2
메타데이터의 움직이는 부품이 줄고 표준 위치가 하나뿐이므로 감사(auditing)가 훨씬 쉬워진다.
반대로, 파이썬은 역사적으로 메타데이터에 거의 제약을 두지 않았다. 예를 들어, 예전 setup.py 기반 빌드 시스템은 빌드 과정에서 임의의 코드를 실행할 수 있게 했다. 한때는 빌드 단계에서 생성된 version이 PyPI에 업로드되는 것과 일치하는 게 바람직하다고 강하게 권장되기도 했다. 하지만 실제로는 버전에 대해 거짓말을 해도 괜찮았다. 겉으로는 2.0이라고 주장하는 소스 배포본을 PyPI에 올려도, 실제로는 2.0+somethinghere나 완전히 다른 버전을 설치하게 만들 수 있었다.
결과적으로, 패키지가 PyPI에 게시되기 전에도, 다운로드 후 로컬에 설치될 때도 메타데이터가 매번 새로 생성된다. 이는 메타데이터가 일치하지 않아도 될 뿐 아니라 완전히 달라도 허용된다는 뜻이다. 내 컴퓨터에서는 어떤 패키지가 cool-dependency에 의존한다고 주장하는 반면, 너의 컴퓨터에서는 uncool-dependency에 의존한다고 주장해도 괜찮다. 심지어 달의 위상이나 시간대에 따라 다른 패키지에 의존해도 허용된다.
특히 editable 설치와 캐싱은 문제가 심각한데, 메타데이터가 기록되자마자 바로 무효가 될 수 있기 때문이다. 3
이 가운데 일부는 새 pyproject.toml 표준이 정적 메타데이터를 장려하면서 어느 정도 나아졌다. 하지만 빌드 시스템은 “동적 메타데이터”로 되돌아가 이를 재정의하는 것이 전적으로 허용되어 있으며, 실제로 흔히 그렇게 한다.
실제로 이 시스템은 누구에게나 엄청난 보이지 않는 비용을 부과한다.
복잡하고 분절된 메타데이터 접근: PyPI 패키지 이름과 설치된 파이썬 모듈 사이에는 명확한 연관성이 없다. PyPI 패키지 이름을 알아야 importlib.metadata를 통해 메타데이터에 접근할 수 있다. 메타데이터는 비록 정적이라 하더라도 pyproject.toml에서 읽지 않으며, 대신 패키지 이름을 통해 site-packages에 설치된 .dist-info 폴더(정확히는 그 안의 METADATA 파일)에서 읽는다.
메타데이터 재생성의 의무: 그 결과 pyproject.toml에서 메타데이터를 수정하면, .dist-info의 메타데이터를 업데이트하려면 패키지를 재설치해야 한다. 사람들은 이를 흔히 잊기 때문에, 메타데이터 비동기화가 아주 흔하다. 오늘날 정적 메타데이터에도 해당된다!
불분명한 캐시 무효화: 메타데이터가 동적일 수 있으므로, 패키지를 언제 자동으로 재설치해야 할지 불분명하다. 동적 메타데이터가 사용될 때는 pyproject.toml 변경만 추적해서는 충분하지 않다. 예를 들어 uv에는 오래된 메타데이터를 감지하도록 돕는 매우 복잡하고 명시적인 캐시 관리 시스템이 있다. 이는 당연히 비표준이며, uv가 버전 관리 시스템을 이해해야 하고, 다른 도구와도 공유되지 않는다. 예컨대 버전 정보에 git 해시가 포함된다는 사실을 알고 있다면, uv에 git 커밋을 주시하라고 알려줄 수 있다.
파편화된 메타데이터 저장 위치: 생성된 메타데이터가 어디에 저장되는지조차 복잡하다. 도구마다 메타데이터 저장 방식이 약간씩 다르다.
로컬 작업(예: editable 설치)의 경우 빌드 시스템에 따라 다르다:
setuptools를 쓰면 메타데이터가 두 곳에 기록된다. 레거시 위치인 <PACKAGE_NAME>.egg-info/PKG-INFO 파일과, site-packages 내의 새 메타데이터 위치인 <PACKAGE_NAME>.dist-info/METADATA 파일이다.
hatch와 대부분의 현대 빌드 시스템을 쓰면 메타데이터는 site-packages에만 기록된다(경로: <PACKAGE_NAME>.dist-info/METADATA).
빌드 시스템을 설정하지 않았다면 설치 도구에 따라 달라진다. pip는 editable 설치에도 setuptools로 휠을 빌드한다. uv는 uv build를 실행해야만 휠을 빌드하고 메타데이터를 제공한다. 그렇지 않으면 메타데이터는 제공되지 않는다(이론적으로 동적이 아니기만 하다면 pyproject.toml에서 찾을 수는 있다).
소스 배포본(sdist)의 경우 먼저 앞서 설명한 빌드 단계가 실행된다. 그 다음 메타데이터가 PKG-INFO 파일에 들어간다. 현재 sdist에는 루트의 PKG-INFO와 <PACKAGE_NAME>.egg-info/PKG-INFO 두 위치에 배치된다. 그러나 그 메타데이터는 내 생각에 주로 PyPI에서만 사용되고, sdist를 로컬에 설치할 때는 메타데이터가 pyproject.toml(또는 setuptools를 쓰면 setup.py)에서 다시 생성된다. 그래서 sdist에 담긴 메타데이터와 설치 후의 메타데이터가 달라질 수 있다.
휠의 경우 메타데이터는 오직 <PACKAGE_NAME>.dist-info/METADATA에만 배치된다. 휠은 정적 메타데이터를 가지므로 빌드 단계가 없다. 휠 안에 있는 것이 항상 사용된다.
동적 메타데이터는 해결기를 느리게 만든다: 동적 메타데이터는 해결기와 설치 도구의 작업을 매우 어렵게 만들고 속도를 떨어뜨린다. 오늘날 시 같은 고급 해결기(예: poetry, uv)는 sdist와 휠에서 의존성 메타데이터가 일관적이라고 가정하기 때문에 올바른 패키지를 설치하지 못하는 경우가 있다. 하지만 PyPI에는 sdist가 불완전한 의존성 메타데이터(개발자의 머신에서 sdist를 만들 때 생성된 것만)로 게시된 경우가 많이 있다.
이걸 제대로 처리하느냐의 차이는, 모든 메타데이터가 담긴 하나의 정적 URL을 치는 것과, zip 파일을 내려받고, 가상환경을 만들고, 빌드 의존성을 설치하고, 전체 sdist를 생성한 다음 최종 생성된 메타데이터를 읽는 것 사이의 차이다. 실행 시간에서 수십, 수백 배의 차이가 날 수 있다.
이는 캐싱에도 확장된다. 메타데이터가 계속 바뀔 수 있다면 해결기가 이를 어떻게 캐시해야 하는가? 해결 과정의 일부로 가능한 모든 소스 배포본을 빌드해 메타데이터를 알아내야 하는가?
pyproject.toml에는 올바른 정보가 보이는데도, 동작은 그렇지 않을 수 있다. 대부분의 사람들은 “egg info”나 “dist info”가 무엇인지 모른다. 왜 sdist의 메타데이터 위치가 휠이나 로컬 체크아웃과 다른지도 모른다.동적 메타데이터를 지원한다는 것은 개발자들이 복잡하고 혼란스러운 시스템을 계속 유지한다는 뜻이기도 하다. 예를 들어 hatch에는 동적으로 readme를 생성하는 플러그인이 있어 4, 문서를 표시하는 데조차 임의의 파이썬 코드를 실행하게 만든다. 버전에 git 해시를 자동으로 포함하는 플러그인도 있다. 그 결과 실제로 어떤 버전이 설치되었는지 알아내려면 단일 파일만 보는 것으로 충분하지 않을 수 있고, 무슨 일이 일어나는지 도구에 의존해야 할지도 모른다.
파이썬의 동적 메타데이터 문제는 매우 크지만, 해결기나 패키징 도구를 직접 만들지 않는 이상 그 고통을 크게 느끼지 못할 수 있다. 사실 동적 메타데이터의 힘을 꽤 즐길 수도 있다. 따라서 이를 없애자는 아이디어를 꺼내면 당연히 반발이 크다. 수많은 워크플로가 이에 의존하고 있어 보이기 때문이다.
이 문제를 지금 고치는 일은 기술적 문제라기보다 사회적 문제라서 매우 어려울 수 있다. 처음부터 제약을 뒀다면 이런 괴상한 사용 사례는 애초에 나타나지 않았을 것이다. 그러나 제약이 없었기 때문에, 사람들은 마음껏 이를 활용했고 그에 따른 온갖 결과가 뒤따랐다.
이제는 치즈를 옮길 때가 됐다고 생각하지만, 표준만으로 이를 해낼 수 있을지는 불분명하다. 어쩌면 uv나 poetry 같은 도구가 동적 메타데이터 사용 시 경고하고 강하게 말리도록 하는 것이 해법일지 모른다. 그러면 시간이 지나면서 동적 메타데이터를 쓰는 패키지의 사용자들이 패키지 저자들에게 사용을 중단하라고 요구하기 시작할 것이다.
동적 메타데이터의 비용은 실제로 존재하지만, 늘 작게 체감될 뿐이다. 해결기가 불필요하게 느릴 때, 패키징 도구가 잘못된 의존성을 설치할 때, 캐시 키를 재구성하려고 처음으로 매뉴얼을 읽어야 할 때나 패키지를 계속 재설치하도록 강제해야 할 때, 로컬 의존성이 자꾸 깨져서 재설치를 반복해야 할 때마다 조금씩 느끼게 된다. 체감은 작지만, 모두가 내는 ‘작은, 작은 세금’이다. 하지만 그 세금은 우리가 모두 내는 것이고, 사용자 경험을 될 수 있는 것보다 상당히 더 나쁘게 만든다.
여기서 더 깊은 교훈은, 개발자들에게 지나친 유연성을 주면 필연적으로 경계를 밀어붙이게 되고, 그에 따른 큰 부작용이 생길 수 있다는 점이다. 파이썬의 패키징 생태계는 처음부터 제약이 부족했기에, 이제 와서 제약을 부과하는 일이 막대한 도전이 되었다. 한편 자바스크립트 같은 다른 생태계는 초기부터 더 구조화된 접근을 취해, 이러한 함정을 상당 부분 피했다.
예를 들어 sentry-cli에서 이것이 어떻게 동작하는지 볼 수 있다. @sentry/cli 패키지는 모든 플랫폼별 의존성을 optionalDependencies로 선언한다(관련 package.json). 각 플랫폼 빌드는 package.json에 os와 cpu에 대한 필터를 둔다. 예컨대 arm64 리눅스 바이너리 의존성은 이렇게 생겼다: package.json. npm은 모든 optional dependency를 설치하려 시도하지만, 현재 플랫폼과 호환되지 않는 것들은 건너뛴다.↩
예컨대 @sentry/cli 2.39.0 버전의 경우, 이 단일 URL에 해결기에 필요한 모든 정보가 담겨 있다: registry.npmjs.org/@sentry/cli/2.39.0↩
과거에 흔했던 오류 중 하나는 로컬 개발 중 스크립트를 실행하려다 pkg_resources.DistributionNotFound 예외가 발생하는 것이었다.↩
내가 readme 생성기를 구박했다며 Bluesky에서 약간의 비판을 받았다. 이들은 의존성이나 버전 같은 메타데이터와 같은 문제를 일으키지는 않지만, 그래도 복잡성을 높이기는 한다. 이상적인 세계에서는 site-packages에서 보는 내용이 버전 관리에 있는 내용과 일치하며, 거기에 README.md 파일이 그대로 있다. 자바스크립트, 러스트 등 많은 생태계가 그렇다. 그러나 우리가 가진 것은(동적이든 복사이든) 빌드 단계가 그 readme 파일을 가져다가, dist-info 안의 RFC 5322 헤더 인코딩 파일에 집어넣는 방식이다. 그래서 의존성에서 “Command-클릭”으로 readme를 바로 여는 대신, 로컬에서 readme를 읽고 싶다면 특별한 도구나 전문가만 아는 난해한 지식이 필요하다.↩
이 글의 태그: python