uv와 Rye로 Python 프로젝트 관리하기: pyproject.toml, 가상환경, Python 버전, lock 파일, Docker

이 글의 핵심

pip, venv, pyenv, poetry를 섞어 쓰면 도구마다 설정 파일과 명령이 달라 팀 환경을 맞추기 어렵습니다. Astral의 uv는 이 역할을 단일 바이너리로 묶고 Rust로 구현한 병렬 설치로 속도도 챙겼습니다. poetry·pipenv에서 옮길 때 달라지는 점, lock 파일을 CI와 Docker에서 재현성 있게 쓰는 법, Python 버전을 못 잡거나 빌드가 실패할 때의 대처를 정리했습니다.

Astral 팀과 Rust 기반 Python 도구

탄생 배경: “Python 도구가 왜 이렇게 느린가?”

Astral은 2022년 Charlie Marsh가 설립한 회사로, “Python 개발 도구를 Rust로 재작성”하는 미션을 가지고 있습니다. Charlie는 Khan Academy 엔지니어로 일하며 Flake8(린터)이 대형 코드베이스에서 너무 느리다는 문제를 겪었으며, Rust로 재작성한 Ruff(2022)를 공개했습니다.

Ruff의 성공:

  • 공식 문서는 Flake8 등 기존 린터보다 10-100배 빠르다고 소개합니다. 파일을 병렬로 처리하고, 여러 플러그인이 각자 AST를 다시 파싱하는 대신 한 번 파싱한 결과로 모든 규칙을 검사하는 구조 덕분입니다
  • Black(포매터) 대체 기능 추가
  • Pandas·FastAPI 등 대형 오픈소스 프로젝트가 채택

이 성공 후 Astral은 “Python의 모든 느린 도구를 Rust로”를 목표로 uv(2024)를 공개했습니다.

uv가 pip·poetry보다 빠른 이유

uv README는 pip보다 10-100배 빠르다고 소개하지만, 실제 배수는 캐시가 비어 있는지, 의존성 트리가 얼마나 큰지, 네트워크가 병목인지에 따라 크게 달라집니다. 숫자보다 어디서 시간이 줄어드는지를 알아 두면 자기 프로젝트에서 효과를 예상하기 쉽습니다.

  1. 병렬 처리: pip은 패키지를 대체로 순서대로 받고 설치하지만, uv는 다운로드·압축 해제·설치를 동시에 여러 개 진행합니다. 작은 패키지가 많은 프로젝트일수록 효과가 큽니다.
  2. 전역 캐시와 링크: ~/.cache/uv/에 풀어 둔 wheel을 새 가상환경에 하드링크(또는 복사)로 연결합니다. 같은 패키지를 다시 설치할 때 다운로드와 압축 해제를 건너뛰므로, 가상환경을 자주 새로 만드는 CI에서 차이가 가장 큽니다.
  3. Resolver: PubGrub 알고리즘 기반 resolver가 충돌 원인을 추적하면서 후보를 좁히고, 메타데이터만 먼저 받아 해결 단계를 빠르게 끝냅니다.

반대로 PyTorch처럼 수백 MB짜리 wheel을 처음 받는 경우는 네트워크 대역폭이 병목이라 도구 차이가 크게 줄어듭니다. 도입 효과를 확인하려면 자기 프로젝트에서 time pip install -r requirements.txt와 time uv pip install -r requirements.txt를 캐시가 빈 상태와 찬 상태로 각각 재 보는 것이 가장 정확합니다.

Rye → uv 통합: 왜 하나로 합쳤나?

Rye(2023): Armin Ronacher(Flask 개발자)가 만든 “Python의 Cargo”

  • pyproject.toml 중심 워크플로우
  • Python 버전 관리 내장
  • 가상환경·의존성·빌드 통합

uv(2024): Astral이 만든 “pip 고속 대체재”

  • 처음엔 pip 호환만
  • 점차 poetry 기능 추가

2024년: Astral이 Rye를 넘겨받고 Rye 워크플로우를 uv에 통합

  • uv init, uv add, uv run 추가
  • Rye는 신규 사용자에게 후속 프로젝트인 uv를 권장
  • 사실상 uv 하나로 대부분의 작업 처리

Rye를 쓰던 프로젝트라면 옮기는 작업은 생각보다 작습니다. Rye도 표준 pyproject.toml의 [project] 테이블을 쓰기 때문에 의존성 목록은 그대로 두고, Rye 전용이던 [tool.rye] 설정(개발 의존성, 스크립트)만 uv의 형식으로 옮기면 됩니다. 달라지는 것은 lock 파일입니다. Rye의 requirements.lock과 requirements-dev.lock은 플랫폼별 requirements 형식이었지만 uv의 uv.lock은 하나의 파일에 모든 플랫폼과 Python 버전의 해결 결과를 담는 “유니버설” 형식이라, 한 번 새로 생성해야 합니다. Rye 자체도 내부적으로 uv를 설치 엔진으로 쓰고 있었기 때문에 체감 동작은 거의 같습니다.

설치

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

# pip (비권장 — 부트스트랩용)
pip install uv

uv --version

설치 스크립트는 uv 바이너리를 ~/.local/bin에 넣으므로, 새 셸에서 uv: command not found가 나오면 이 경로가 PATH에 있는지 먼저 확인하세요. pip install uv를 비권장으로 표시한 이유는 uv가 관리하려는 대상(Python과 가상환경) 안에 uv 자신이 들어가 버리기 때문입니다. 특정 가상환경에 설치한 uv는 그 환경이 지워지면 함께 사라지고, 어떤 Python에 설치됐는지에 따라 버전이 제각각이 됩니다. 스크립트로 설치했다면 uv self update로 업데이트할 수 있습니다.

첫 프로젝트

uv init my-app
cd my-app

uv add fastapi uvicorn
uv add --dev pytest ruff

uv run main.py
uv run pytest
  • uv init: pyproject.toml, .python-version, 예제 main.py 생성 (uv.lock과 .venv는 첫 uv add/uv sync/uv run 때 만들어짐)
  • uv add: pyproject.toml에 의존성 추가 + lock 갱신 + 가상환경 동기화
  • uv run: 가상환경을 lock에 맞춰 동기화한 뒤 그 안에서 명령 실행

uv run이 실행 전에 매번 환경을 동기화한다는 점이 pip 시절과 가장 크게 다른 습관입니다. 누군가 pyproject.toml에 의존성을 추가하고 커밋했다면, 받은 쪽은 uv sync를 따로 기억할 필요 없이 uv run pytest만 실행해도 누락된 패키지가 설치됩니다. 반대로 가상환경에 uv pip install로 직접 넣은 패키지는 lock에 없으므로, uv sync나 uv run이 환경을 lock에 맞추는 과정에서 제거됩니다. “어제 설치한 패키지가 사라졌다”는 혼란의 대부분이 이것이며, 프로젝트에 필요한 패키지는 반드시 uv add로 추가해야 합니다.

pyproject.toml

[project]
name = "my-app"
version = "0.1.0"
description = "My Python app"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.115",
    "uvicorn[standard]>=0.32",
]

[project.optional-dependencies]
postgres = [
    "psycopg[binary]>=3.2",
]

# uv add --dev가 기록하는 곳 (PEP 735 의존성 그룹)
[dependency-groups]
dev = [
    "pytest>=8.3",
    "ruff>=0.8",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.ruff]
line-length = 100

uv add가 dependencies를, uv add --dev가 [dependency-groups]의 dev 그룹을 자동 수정합니다.

여기서 헷갈리기 쉬운 두 테이블을 구분해야 합니다. [project.optional-dependencies]는 패키지 사용자가 pip install my-app[postgres]처럼 골라 설치하는 “extras”로, 배포되는 패키지 메타데이터에 포함됩니다. 반면 [dependency-groups]는 테스트 도구나 린터처럼 개발자만 필요한 목록이라 배포 메타데이터에 들어가지 않습니다. 예전 uv 버전의 [tool.uv] dev-dependencies도 같은 역할이었지만 지금은 표준인 dependency-groups로 대체되어 deprecated 경고가 나오므로, 옛 설정을 가져왔다면 옮겨 두는 것이 좋습니다. uv sync는 기본적으로 dev 그룹을 설치하고, --no-dev로 뺄 수 있습니다.

[build-system]이 있으면 uv는 이 프로젝트 자체를 패키지로 빌드해 가상환경에 설치합니다. 라이브러리가 아니라 웹 애플리케이션처럼 배포하지 않는 프로젝트라면 이 테이블 없이 두어도 되며, 그러면 uv는 의존성만 설치하고 프로젝트 코드는 경로에서 바로 실행합니다. 이 차이는 아래 Docker 빌드에서 중요해집니다.

가상환경

# 가상환경 생성
uv venv

# 활성화
source .venv/bin/activate    # Linux/macOS
.venv\Scripts\activate       # Windows

# uv run으로 활성화 생략 가능
uv run python script.py
uv run pytest

uv venv는 pip를 가상환경에 설치하는 단계가 없어 python -m venv보다 빨리 끝납니다. Python 버전도 uv venv --python 3.12로 명시.

pip가 없다는 점은 장점이자 함정입니다. 가상환경을 활성화한 뒤 습관처럼 pip install을 치면 가상환경 안의 pip가 없으므로 시스템이나 다른 곳의 pip가 실행되어 엉뚱한 Python에 설치되거나, No module named pip 오류가 납니다. uv로 만든 환경에서는 uv pip install이나 uv add를 쓰고, IDE나 다른 도구가 pip를 꼭 필요로 한다면 uv venv --seed로 pip를 함께 설치할 수 있습니다.

Python 버전 관리 (pyenv 대체)

# Python 설치 (여러 버전 공존)
uv python install 3.12 3.11 3.10

# 프로젝트별 고정
uv python pin 3.12

# 버전 확인
uv python list
uv python find 3.11

.python-version 파일을 생성해 uv·pyenv·mise 모두 인식합니다.

uv가 설치하는 Python은 python-build-standalone 프로젝트의 미리 빌드된 배포판이라, pyenv처럼 소스에서 컴파일하지 않아 몇 초 만에 설치가 끝나고 빌드 도구나 OpenSSL 헤더가 필요 없습니다. 대신 배포판 패키지 매니저가 설치한 Python과는 별개이므로, 시스템 Python에만 설치된 모듈(예: 일부 배포판의 python3-apt)은 보이지 않습니다. uv는 필요한 버전이 없으면 자동으로 내려받으므로, 오프라인 빌드 환경이라면 UV_PYTHON_DOWNLOADS=never로 자동 다운로드를 막고 미리 설치된 인터프리터만 쓰게 할 수 있습니다. .python-version은 “무엇을 쓸지”, requires-python은 “무엇을 지원하는지”를 뜻하므로, 핀 버전이 requires-python 범위를 벗어나면 오류가 납니다.

의존성 설치

# pyproject.toml 기반
uv sync                          # install + lock 갱신

# requirements.txt 레거시
uv pip install -r requirements.txt

# 글로벌 도구 (pipx 대체)
uv tool install ruff
uv tool install black
uv tool run ruff check .

lock 파일 (uv.lock)

  • uv sync가 uv.lock 생성/갱신
  • 모든 플랫폼·Python 버전의 해결 결과와 해시를 파일 하나에 담는 유니버설 lock
  • 팀원·CI가 정확히 같은 버전 설치

Poetry의 poetry.lock와 역할은 같습니다. 이 lock이 “유니버설”이라는 것은, 개발자가 macOS에서 lock을 만들어도 Windows나 리눅스 CI가 같은 파일로 자기 플랫폼에 맞는 wheel을 설치할 수 있다는 뜻입니다. 그래서 lock은 반드시 저장소에 커밋해야 하고, 사람이 직접 수정해서는 안 됩니다.

CI와 Docker에서는 두 가지 옵션을 구분해 써야 합니다. --locked는 pyproject.toml과 uv.lock이 어긋나 있으면 오류로 실패하고, --frozen은 일치 여부를 확인하지 않고 lock에 적힌 그대로 설치합니다. 누군가 의존성을 추가하고 lock 갱신을 빠뜨린 커밋을 잡아내려면 CI에서는 --locked가 더 안전하고, --frozen은 pyproject.toml을 아직 복사하지 않은 Docker 레이어처럼 검증이 불가능한 상황에 적합합니다. 특정 패키지만 올리고 싶다면 uv lock --upgrade-package fastapi처럼 대상 패키지만 지정하면, 나머지 버전은 그대로 둔 채 lock을 갱신할 수 있습니다.

워크스페이스 (Monorepo)

# 루트 pyproject.toml
[tool.uv.workspace]
members = ["packages/*"]

[tool.uv]
dev-dependencies = ["pytest>=8.3"]
# packages/core/pyproject.toml
[project]
name = "my-core"
version = "0.1.0"
dependencies = []
uv sync           # 루트에서 모든 워크스페이스 동시 설치
uv run --package my-core pytest

pnpm workspaces 같은 경험이 Python에 온 것.

워크스페이스의 모든 멤버는 하나의 uv.lock과 하나의 가상환경을 공유합니다. 그래서 멤버끼리 같은 라이브러리의 서로 다른 버전을 요구하면 해결이 불가능하다는 오류가 납니다. 이는 의도된 제약으로, 모노레포 안의 패키지들이 서로를 import할 때 버전이 어긋나는 문제를 원천적으로 막아 줍니다. 멤버 사이의 의존은 dependencies = ["my-core"]와 함께 [tool.uv.sources]에 my-core = { workspace = true }를 지정해 로컬 경로로 연결합니다. 멤버마다 완전히 독립된 의존성 세트가 필요하다면 워크스페이스보다 별도 프로젝트와 경로 의존성(path = "../core")이 맞는 선택입니다.

Docker 통합

FROM python:3.12-slim AS builder
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev --no-install-project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "main.py"]

이 Dockerfile의 핵심은 의존성 설치와 프로젝트 설치를 두 단계로 나눈 것입니다. 첫 uv sync는 pyproject.toml과 uv.lock만 있는 상태에서 --no-install-project로 의존성만 설치하므로, 소스 코드가 바뀌어도 lock이 그대로면 이 레이어는 캐시에서 재사용됩니다. 이 옵션 없이 소스를 복사하기 전에 uv sync를 실행하면, [build-system]이 있는 프로젝트는 자기 자신을 빌드하려다 소스 파일이 없어 실패합니다. 저도 처음 uv로 Dockerfile을 옮길 때 이 부분에서 hatchling의 “파일을 찾을 수 없다”는 빌드 오류를 한참 들여다봤는데, 공식 Docker 가이드가 두 단계 설치를 권하는 이유가 바로 이것입니다.

환경 변수 두 개도 이유가 있습니다. UV_LINK_MODE=copy는 캐시 마운트가 이미지 레이어와 다른 파일 시스템이라 하드링크를 만들 수 없기 때문에 설정합니다. 설정하지 않으면 Failed to hardlink files; falling back to full copy 경고가 나오고 결과는 같습니다. UV_COMPILE_BYTECODE=1은 설치 시 .pyc를 미리 만들어 컨테이너 첫 기동 시간을 줄입니다. 최종 스테이지로 .venv만 복사하는 방식은 두 스테이지의 Python 경로가 같아야 동작합니다. 가상환경 안의 python은 빌드 스테이지의 인터프리터 경로를 가리키는 링크이므로, 베이스 이미지의 Python 버전이나 설치 위치가 다르면 컨테이너가 시작하자마자 인터프리터를 찾지 못합니다. ghcr.io/astral-sh/uv:latest도 운영용 이미지라면 uv:0.x.y처럼 버전을 고정하는 편이 재현성에 좋습니다.

CI: GitHub Actions

- uses: astral-sh/setup-uv@v4
  with:
    enable-cache: true
    cache-dependency-glob: "uv.lock"

- run: uv sync --frozen
- run: uv run pytest
- run: uv run ruff check .

setup-uv 액션이 uv 바이너리 다운로드와 캐시 복원·저장을 처리합니다. 액션은 메이저 버전이 계속 올라가므로 저장소의 최신 릴리스를 확인해 맞춰 두세요. cache-dependency-glob을 uv.lock으로 지정하면 lock이 바뀔 때만 캐시 키가 바뀌어, 의존성이 그대로인 커밋에서는 다운로드 없이 캐시에서 설치됩니다. CI에서 Python 버전을 여러 개 테스트한다면 actions/setup-python 없이 uv sync --python 3.11처럼 uv가 직접 해당 버전을 내려받게 할 수도 있습니다.

Ruff와의 조합

uv add --dev ruff
uv run ruff check .
uv run ruff format .

같은 Astral 팀이 만들어 설정 파일(pyproject.toml)과 실행 방식(uv run)이 자연스럽게 맞물립니다.

비교 요약

도구속도기능표준
pip느림기본PyPI 표준
poetry느림풍부자체 lock
pipenv느림기본+Pipfile
uv빠름 (병렬·캐시)풍부pyproject.toml

트러블슈팅

uv sync 실패

  • --frozen 없이 uv sync로 lock 재해결
  • uv cache clean 후 재시도

해결 단계에서 실패하면 uv는 No solution found when resolving dependencies:로 시작하는 메시지와 함께 “A가 B<2를 요구하고 C가 B>=2를 요구하므로 불가능하다”는 식의 충돌 경로를 보여 줍니다. 이 설명을 끝까지 읽으면 어느 패키지의 상한을 풀어야 하는지 대부분 알 수 있습니다. 흔한 원인 하나는 requires-python이 너무 넓은 경우입니다. uv는 lock을 requires-python 범위의 모든 버전에 대해 해결하려 하므로, >=3.8로 두면 3.8을 지원하지 않는 최신 패키지 때문에 해결이 실패할 수 있습니다. 실제로 지원할 하한으로 좁히면 풀리는 경우가 많습니다. 캐시를 지우는 것은 손상된 다운로드가 의심될 때의 마지막 수단입니다.

Python 버전 안 잡힘

  • uv python install 3.12로 명시 설치
  • .python-version 또는 requires-python 확인

특정 패키지 빌드 실패

  • 컴파일러(gcc, clang)와 헤더가 필요한 패키지 → 시스템 의존성 설치 (build-essential, libpq-dev 등)
  • 소스 빌드 자체를 피하고 싶다면 --only-binary :all:로 wheel만 허용해, 해당 플랫폼용 wheel이 없는 패키지를 빨리 찾아내기

빌드 실패의 대부분은 설치하려는 Python 버전이나 플랫폼용 wheel이 PyPI에 없어서 uv가 소스 배포판(sdist)을 받아 직접 컴파일하려 할 때 생깁니다. 새 Python 버전이 나온 직후나 Alpine(musl) 같은 환경에서 자주 겪는 일입니다. 반대로 --no-binary :all:은 wheel이 있어도 무시하고 모든 패키지를 소스에서 빌드하게 만드는 옵션이라, 빌드 실패를 해결하기보다 늘리는 쪽이므로 특별한 이유 없이 쓰지 않습니다. 빌드에 추가 도구가 필요한 패키지는 uv가 격리된 빌드 환경을 만들기 때문에, 가상환경에 설치한 setuptools나 numpy가 빌드 단계에서는 보이지 않는다는 점도 알아 두세요. 이런 레거시 패키지는 [tool.uv] no-build-isolation-package로 해당 패키지만 격리를 끌 수 있습니다.

Docker에서 느림

  • --mount=type=cache,target=/root/.cache/uv로 캐시 마운트
  • multi-stage build로 빌드 아티팩트 분리

체크리스트

  • uv 설치 + shell PATH 확인
  • uv init + pyproject.toml 표준화
  • uv.lock 커밋
  • uv sync --frozen CI 적용
  • Python 버전 uv python install + pin
  • 글로벌 도구 uv tool install
  • Ruff 통합 (린트·포맷)
  • Docker에 uv 캐시 마운트

마무리

uv는 pip·poetry·pyenv가 각자 해결하던 문제를 단일 바이너리로 통합하고, 병렬 설치와 전역 캐시로 설치 시간을 줄인 도구입니다. 특히 가상환경을 매번 새로 만드는 CI와 Docker 빌드에서 효과가 큽니다. 기존 pip·poetry 프로젝트도 uv sync 한 번으로 이주 체험이 가능하니, 지금 바로 CI 빌드 시간 측정과 함께 도입을 시도해보길 권장합니다.


자주 묻는 질문 (FAQ)

Q. Docker 빌드에서 uv 설치가 느리면 무엇부터 확인해야 하나요?

A. 레이어가 다시 빌드될 때마다 패키지를 새로 받지 않도록 RUN —mount=type=cache,target=/root/.cache/uv로 uv 캐시를 마운트하는 것이 우선입니다. 의존성 설치 단계와 소스 복사 단계를 나누고 multi-stage build로 빌드 아티팩트를 분리하면 코드만 바뀌었을 때 설치 레이어가 재사용됩니다. CI와 컨테이너에서는 uv.lock을 기준으로 설치해야 팀원과 같은 버전이 설치됩니다.


같이 보면 좋은 글