Python 프로젝트를 새로 잡을 때마다 python -m venv로 가상환경 만들고, pip install로 패키지 넣고, pip freeze > requirements.txt로 목록을 뽑는 과정을 반복하다 보면 매번 조금씩 어긋나는 순간이 옵니다. 팀원 노트북에서는 되는데 CI에서만 깨진다든가, requirements.txt에 적힌 버전은 같은데 하위 의존성이 달라 동작이 다르다든가 하는 문제입니다. 저도 pip 조합과 poetry 사이를 몇 번 오갔는데, 요즘은 uv 하나로 정리하고 나서 이 잔손질이 확실히 줄었습니다. 그래서 이관 방법을 한번 정리해 둡니다.
결론부터 말하자면, uv는 pip, venv(virtualenv), pip-tools, poetry, pyenv가 하던 일을 하나의 Rust 바이너리로 대체하는 도구입니다. 표준 pyproject.toml에 의존성을 적고, uv.lock이라는 크로스플랫폼 잠금 파일로 재현성을 보장하며, 기존 requirements.txt 방식도 uv pip 하위 명령으로 그대로 지원합니다. 설치와 해석이 pip 대비 수십 배 빠른 편이라, 특히 매번 의존성을 새로 받는 CI에서 체감 차이가 큽니다.
1. uv란 무엇인가
uv는 Astral에서 Rust로 만든 Python 패키지 및 프로젝트 관리 도구입니다. 공식 문서는 uv가 pip, pip-tools, pipx, poetry, pyenv, twine, virtualenv의 기능을 한데 모았다고 소개합니다. 핵심은 두 가지입니다. 하나는 속도(전역 캐시와 병렬 해석), 다른 하나는 표준 준수입니다. 프로젝트 설정은 PEP 621 기반 pyproject.toml을 그대로 쓰고, 잠금 파일만 uv 고유 형식(uv.lock)을 씁니다.
여기서 잠금 파일(lockfile)이라는 개념을 먼저 짚고 갑니다. 잠금 파일은 직접 적은 최상위 의존성뿐 아니라 그 하위 의존성까지 정확한 버전과 해시를 통째로 박아 두는 파일입니다. 이게 있어야 다른 사람 머신에서도, 몇 달 뒤 CI에서도 같은 조합이 설치됩니다. requirements.txt로도 흉내 낼 수 있지만, uv.lock은 플랫폼(리눅스/맥/윈도)이 달라도 하나의 파일로 재현되도록 설계된 점이 다릅니다.
2. 설치
uv는 Python으로 만든 도구가 아니라 독립 실행 바이너리입니다. 그래서 시스템에 Python이 없어도 설치되고, 오히려 uv가 Python 버전까지 관리해 줍니다. macOS와 Linux는 공식 설치 스크립트가 가장 간단합니다.
$ curl -LsSf https://astral.sh/uv/install.sh | sh $ uv --version uv 0.9.x (버전 번호는 설치 시점에 따라 다를 수 있음)
Homebrew(brew install uv)나 pipx(pipx install uv)로도 설치할 수 있습니다. 설치 스크립트로 넣었다면 uv self update로 자체 업데이트가 됩니다. 💡 CI 이미지에서는 공식 도커 이미지 ghcr.io/astral-sh/uv를 베이스로 쓰거나, setup-uv 액션을 쓰면 캐시까지 붙어 편합니다.
3. 새 프로젝트 시작하기
먼저 uv가 어떻게 동작하는지 감을 잡기 위해 새 프로젝트를 하나 만들어 봅니다.
$ uv init hello-uv Initialized project `hello-uv` at `/home/me/hello-uv` $ cd hello-uv $ ls -a . .. .git .gitignore .python-version main.py pyproject.toml README.md
생성된 pyproject.toml은 이렇게 생겼습니다.
[project] name = "hello-uv" version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.11" dependencies = []
이제 의존성을 추가합니다. uv add는 pyproject.toml의 dependencies에 항목을 넣고, 가상환경(.venv)을 만들고, 잠금 파일까지 한 번에 갱신합니다.
$ uv add requests Using CPython 3.11.x Creating virtual environment at: .venv Resolved 6 packages in 120ms Installed 5 packages in 18ms + certifi==2025.x.x + charset-normalizer==3.x.x + idna==3.x + requests==2.32.x + urllib3==2.x.x
여기서 처음 헷갈렸던 부분이 있습니다. source .venv/bin/activate로 환경을 켜지 않아도 됩니다. uv run이 알아서 프로젝트 환경을 잠그고 동기화한 뒤 명령을 실행합니다.
$ uv run python -c "import requests; print(requests.__version__)" 2.32.x
물론 예전처럼 직접 activate 해서 쓰는 방식도 됩니다. 그럴 때는 uv sync로 환경을 맞춘 뒤 source .venv/bin/activate를 하면 됩니다. 저는 스크립트 실행은 uv run, 대화형 셸 작업은 activate로 나눠 씁니다.
4. pyproject.toml과 uv.lock, 무엇이 재현성을 만드나
uv add를 한 번이라도 하면 프로젝트 루트에 uv.lock이 생깁니다. 이 파일에는 각 패키지의 정확한 버전, 배포 파일 위치, 해시가 들어 있습니다. 열어 보면 대략 이런 형태입니다.
[[package]]
name = "requests"
version = "2.32.x"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
wheels = [{ url = "...", hash = "sha256:..." }]
역할을 나눠 보면 이렇습니다. pyproject.toml은 사람이 관리하는 의도(어떤 패키지를 어떤 범위로 원하는가)이고, uv.lock은 그 의도를 푼 결과(정확히 어떤 조합이 설치되는가)입니다. 그래서 두 파일 모두 git에 커밋합니다. 공식 문서도 uv.lock을 버전 관리에 넣어 여러 머신에서 동일하게 설치되도록 하라고 권합니다.
⚠️ uv.lock은 손으로 편집하지 마세요. 버전을 바꾸고 싶으면 uv lock --upgrade-package requests처럼 명령으로 갱신하고, 전체를 올리려면 uv lock --upgrade를 씁니다.
5. 개발용 의존성은 그룹으로 분리한다
pytest나 ruff 같은 개발 도구는 배포물에 들어갈 필요가 없습니다. uv는 --dev 옵션으로 이걸 별도 그룹에 넣습니다.
$ uv add --dev pytest ruff Resolved N packages ...
그러면 pyproject.toml에 표준 [dependency-groups] 테이블이 생깁니다.
[dependency-groups]
dev = [
"pytest>=8.x",
"ruff>=0.x",
]
uv sync는 기본적으로 dev 그룹을 포함합니다. 운영 이미지에서는 개발 도구를 빼야 하니 uv sync --no-dev로 실행하면 됩니다. 반대로 특정 그룹만 넣으려면 --group <이름>을 씁니다.
6. pip와 venv에서 이관하기
기존 requirements.txt 프로젝트라면 두 갈래가 있습니다. 프로젝트 방식(pyproject.toml)으로 넘어갈지, 아니면 익숙한 requirements.txt 방식을 그대로 두고 도구만 uv로 바꿀지입니다.
6.1 pyproject.toml 방식으로 넘어가기
requirements.txt의 항목을 그대로 uv add에 넣으면 됩니다.
$ uv init --bare # 기존 코드 디렉터리에서 최소 pyproject.toml만 생성 $ uv add -r requirements.txt Resolved ... packages Installed ... packages
이렇게 하면 requirements.txt의 최상위 패키지가 dependencies로 옮겨지고 uv.lock이 생깁니다. 옮긴 뒤 동작을 확인했으면 requirements.txt는 지워도 됩니다. 다만 배포 파이프라인 등에서 아직 requirements.txt를 참조한다면 6.3의 export로 계속 뽑아낼 수 있습니다.
6.2 requirements.txt 방식을 유지하며 도구만 uv로
pyproject.toml로 가는 게 부담스럽다면, pip-tools를 쓰던 방식을 uv pip로 그대로 바꿀 수 있습니다. uv는 pip 호환 인터페이스를 제공합니다.
# requirements.in -> requirements.txt (해석 + 고정) $ uv pip compile requirements.in -o requirements.txt # requirements.txt 그대로 환경에 설치 $ uv pip sync requirements.txt # 단발성 설치 $ uv pip install requests
여기서 한 가지 주의할 점이 있습니다. uv pip install은 활성화된 가상환경이나 로컬 .venv에 설치합니다. pip처럼 시스템 전역에 깔리지 않습니다. 가상환경이 없으면 uv venv로 먼저 만들면 됩니다.
$ uv venv Using CPython 3.11.x Creating virtual environment at: .venv $ uv pip install -r requirements.txt
6.3 poetry에서 이관하기
poetry도 pyproject.toml을 쓰지만, poetry는 [tool.poetry] 테이블에 의존성을 적고 uv는 표준 [project] 테이블을 씁니다. 그래서 자동 변환이 딱 떨어지진 않습니다. 저는 이렇게 처리합니다. poetry로 requirements.txt를 한번 뽑아서 그걸 uv로 받아들이는 방법입니다.
# poetry 쪽에서 export (poetry-plugin-export 필요) $ poetry export -f requirements.txt --output requirements.txt --without-hashes # uv 프로젝트로 받기 $ uv init --bare $ uv add -r requirements.txt
의존성 목록이 짧으면 그냥 [tool.poetry.dependencies]에 적힌 최상위 패키지만 눈으로 보고 uv add로 다시 넣는 게 더 깔끔할 때도 많습니다. poetry의 ^1.2 같은 캐럿 표기는 uv(PEP 508)에서 >=1.2,<2.0으로 풀어 적으면 됩니다.
7. requirements.txt 호환 - 필요하면 언제든 되뽑는다
uv 프로젝트로 옮겼어도 배포 대상이나 다른 팀이 requirements.txt를 요구할 수 있습니다. 이때는 잠금 파일에서 export 하면 됩니다.
$ uv export --format requirements.txt -o requirements.txt $ uv export --format requirements.txt --no-dev -o requirements-prod.txt
이렇게 뽑은 requirements.txt는 해시까지 포함되므로 pip로 설치해도 동일 조합이 재현됩니다. 즉 uv.lock을 소스 오브 트루스로 두고, requirements.txt는 필요할 때 파생물로 만들어 내는 구성이 가능합니다.
8. CI 빌드에 붙이기와 속도
CI에서 중요한 건 잠금 파일과 실제 설치가 어긋나지 않는 것입니다. uv는 두 플래그로 이걸 강제합니다.
--locked: 잠금 파일이 최신이 아니면 갱신하지 않고 에러로 멈춥니다. pyproject.toml을 고치고 lock을 커밋 안 한 실수를 CI에서 잡아냅니다.--frozen: 잠금 파일이 최신인지 검사하지 않고 있는 그대로 설치합니다. lock이 확실히 최신인 배포 단계에서 시간을 아낍니다.
GitHub Actions 예시입니다.
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install dependencies
run: uv sync --locked --no-dev
- name: Run tests
run: uv run pytest
속도 이야기입니다. 정확한 수치는 패키지 수, 네트워크, 캐시 상태에 따라 달라지므로 단정하긴 어렵습니다. 공식 문서는 pip 대비 10~100배 빠르다고 밝히고 있고, 제가 캐시가 채워진 상태에서 재설치를 돌려 보면 pip 대비 훨씬 짧게 끝나는 편입니다. 특히 전역 캐시가 살아 있는 두 번째 실행부터 체감이 큽니다. 직접 비교하려면 이렇게 시간을 재 보세요.
# pip 쪽 $ python -m venv .venv-pip && . .venv-pip/bin/activate $ time pip install -r requirements.txt # uv 쪽 (캐시 워밍 후 재측정 권장) $ time uv sync --locked
💡 CI 캐시 키에는 uv.lock 해시를 넣으세요. lock이 바뀔 때만 캐시를 새로 만들면 되니, 대부분의 빌드에서 의존성 설치는 캐시 복원 수준으로 끝납니다.
9. 마무리 - 언제 무엇을 쓰나
새 프로젝트라면 처음부터 uv init + uv add로 시작해 pyproject.toml과 uv.lock을 커밋하는 방식을 권합니다. 기존 pip/pip-tools 파이프라인을 크게 못 건드리는 상황이라면, 우선 uv pip compile과 uv pip sync로 도구만 바꿔 속도 이득을 먼저 챙긴 뒤 여유가 될 때 pyproject.toml로 옮기는 순서가 무난합니다. poetry에서는 export를 다리 삼아 넘어오되, 의존성이 적으면 uv add로 다시 적는 편이 깔끔합니다. 어느 경로든 uv.lock을 커밋하고 CI에서 --locked로 검증하는 것만 지키면 재현성 문제는 상당히 정리됩니다.