파이썬 패키징 #5 의존성 잠금: lock 파일과 재현성
#2에서 requirements.txt 하나가 “의도"와 “스냅샷"을 겸하다 무너지는 것을 봤고, #3에서 의도가 pyproject.toml로 이사했습니다. 남은 반쪽인 스냅샷의 현대적 형태가 lock 파일입니다. #4의 uv add 뒤에서 조용히 갱신되던 uv.lock을 이번에는 직접 열어 봅니다. 내용을 읽을 줄 알면 재현성, 보안, 업그레이드가 전부 이 파일 하나로 설명됩니다.
선언과 잠금: 같은 정보의 다른 시제 #
두 파일의 관계를 한 문장으로 정리하면 이렇습니다. pyproject.toml은 “우리 코드가 허용하는 범위”(현재와 미래), lock 파일은 “지난 해석에서 실제로 확정된 세계”(과거의 기록)입니다.
# pyproject.toml — 선언
dependencies = ["django>=5.2,<6"]
# uv.lock — 잠금 (발췌)
[[package]]
name = "django"
version = "5.2.4"
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
]
wheels = [
{ url = "...", hash = "sha256:8c8b3..." },
]lock 파일에는 선언에 없던 것이 세 가지 있습니다. 정확한 버전(5.2.4), 선언에는 아예 등장하지 않던 전이 의존성 전부(asgiref, sqlparse까지 각각 항목으로), 그리고 배포물의 해시입니다. django>=5.2,<6라는 범위를 만족하는 수많은 조합 중 하나가 해석(resolve)으로 확정된 결과이고, 이 파일이 있는 한 어떤 머신에서든 같은 조합이 설치됩니다. #2에서 본 “설치 시점 복권"이 구조적으로 사라지는 지점입니다.
uv.lock은 특정 OS에서 freeze한 목록과 달리 플랫폼 독립입니다. macOS 전용, 리눅스 전용 배포물이 조건과 함께 모두 기록되어, 한 파일로 모든 팀원과 CI를 커버합니다. 그래서 lock 파일은 .venv와 달리 반드시 커밋합니다. 재현성의 원본이 바로 이 파일이기 때문입니다.
해시: 재현성 너머의 공급망 방어 #
각 배포물에 붙는 sha256 해시는 단순한 무결성 확인 이상입니다. 설치 시점에 내려받은 파일이 잠글 때의 파일과 바이트 단위로 같은지 검증되므로, 패키지 저장소가 오염되거나 같은 버전 번호로 내용이 바꿔치기되는 유형의 공급망 공격이 설치 단계에서 걸립니다. 잠긴 버전에 새로 알려진 취약점이 있는지는 별도 문제로, uvx pip-audit으로 lock된 환경을 스캔하는 습관을 테스트 #7의 CI에 한 줄 얹으면 됩니다.
CI와 배포에서: –frozen이 기본입니다 #
lock 파일의 가치는 CI에서 완성됩니다. 핵심 플래그가 frozen입니다.
uv sync --frozen # lock 파일 그대로 설치, lock 갱신 시도 안 함--frozen은 “lock과 pyproject.toml이 어긋나 있으면 고치지 말고 실패하라"는 뜻입니다. CI에서 이 플래그가 없으면, 누군가 pyproject.toml만 고치고 lock 갱신을 잊었을 때 CI가 조용히 새로 해석해 로컬과 다른 환경으로 테스트를 통과시키는 사고가 납니다. CI는 검증하는 곳이지 해석하는 곳이 아닙니다. 로컬에서 잠그고, CI는 frozen으로 재현만 합니다.
업그레이드: 이벤트가 아니라 루틴으로 #
잠금의 부작용은 방치입니다. 한번 잠그고 잊으면 보안 패치도 버그 수정도 못 받은 채 버전만 늙어 갑니다. 균형은 업그레이드를 의도적인 루틴으로 만드는 것입니다.
uv lock --upgrade-package django # 하나만 올리기
uv lock --upgrade # 선언 범위 안에서 전부 올리기
uv sync업그레이드는 lock 파일의 diff로 정확히 보이므로, 실무 규칙 세 가지가 따라옵니다.
- 기능 변경과 섞지 않습니다: 의존성 업그레이드는 독립 커밋·독립 PR로 만듭니다. 문제가 생겼을 때 되돌릴 단위가 분명해집니다.
- 주기를 정합니다: “매달 첫 주” 같은 주기로
--upgrade를 돌리고 테스트를 통과시킵니다. Renovate, Dependabot 같은 봇에게 PR 생성을 맡기는 것이 이 루틴의 자동화판입니다. - 범위 밖 메이저 업그레이드는 선언부터 고칩니다: django 6로 가려면 pyproject.toml의
<6을 고치는 결정이 먼저입니다. lock은 선언을 넘을 수 없습니다.
pylock.toml: 도구 사이를 잇는 표준이 왔습니다 #
lock 파일의 오랜 약점은 도구마다 형식이 달라 호환되지 않는다는 것이었습니다(uv.lock, poetry.lock, …). 2025년 승인된 PEP 751이 표준 lock 형식 pylock.toml을 정의하면서 이 벽이 낮아지고 있습니다. 2026년 현재 uv는 pylock.toml을 내보내고 설치에 쓸 수 있으며, pip도 26.1부터 실험적으로 pylock.toml 설치를 지원합니다.
uv export -o pylock.toml # 표준 형식으로 내보내기실무 의미는 이동성입니다. uv로 개발하되 배포 파이프라인은 pip만 있는 환경이라면, 표준 lock을 건네 재현성을 유지한 채 넘어갈 수 있습니다. 도구 선택이 lock 형식에 묶이는 시대가 끝나가는 것입니다. 당분간 프로젝트의 원본은 uv.lock이고 pylock.toml은 교환용이라고 정리해 두면 됩니다.
정리 #
이번 글에서 다룬 내용입니다.
- pyproject.toml은 허용 범위의 선언이고, lock 파일은 해석으로 확정된 전체 의존성 그래프의 기록입니다. 정확한 버전, 전이 의존성 전부, 해시가 담깁니다
- uv.lock은 플랫폼 독립이라 한 파일로 전 팀원과 CI를 커버하고,
.venv와 달리 반드시 커밋합니다 - 해시 검증은 바꿔치기형 공급망 공격을 설치 단계에서 막습니다. 알려진 취약점 스캔은 pip-audit을 CI에 얹어 보완합니다
- CI는
uv sync --frozen이 기본입니다. lock과 선언이 어긋나면 조용히 재해석하지 말고 실패해야 합니다 - 업그레이드는 독립 PR + 정기 루틴으로 만듭니다. 메이저 업그레이드는 선언 수정이 먼저입니다
- PEP 751의 pylock.toml이 도구 간 표준 교환 형식이 되어, uv로 잠그고 pip로 설치하는 이동이 열렸습니다
다음 글(#6 배포)에서는 방향을 뒤집습니다. 지금까지는 남의 패키지를 받는 쪽이었다면, 이제 내 코드를 wheel로 빌드해 PyPI에 올리는 만드는 쪽의 워크플로를 다룹니다.