uv로 Python 의존성 관리하기 - 로컬부터 CI/CD까지

의존성 관리가 중요한 이유

“내 컴퓨터에서는 잘 되는데요?!”

하 혈압올라… 의존성 관리의 핵심 원칙은 두가지다.

  • 환경 일관성: 각각 다른 개발자의 환경에서도 문제 없이 동일한 소프트웨어 환경을 구축할 수 있어야 함
  • 재현 가능성: 각 개발자의 로컬 개발 환경의 차이에도 불구하고 동일한 소프트웨어 의존성을 구축해야 함

이 원칙이 깨지는 빈번한 이유는 보통 다음과 같다.

  • 버전의 파편화: 어떤 사람은 Python 3.9를 쓰고 운영 서버는 Python 3.8을 쓰고…
  • 라이브러리 오염: 로컬에 설치된 다른 프로젝트의 라이브러리가 현재 프로젝트에 영향을 미침
  • OS 종속성: 윈도우 환경에서 개발했는데 리눅스 서버로 배포하는 경우

그래서 동일한 환경을 구축하기 위해 세 가지 계층의 대응이 필요하다.

  1. 의존성 고정: 라이브러리 A의 특정 버전을 써라
  2. 가상 환경: 해당 프로젝트만을 위한 환경에서 써라
  3. 컨테이너화: OS 수준에서 환경을 캡슐화하여 개발자의 로컬 = 테스트 서버 = 운영 서버 환경을 구축

uv vs pip: 무엇이 다른가?

uv는 Rust로 작성된 Python 패키지 매니저로, pip 대비 다음과 같은 장점을 가진다.

1. 병렬 다운로드 & 설치

pip는 Global Interpreter Lock(GIL)을 사용하기 때문에 병렬 다운로드와 설치가 불가능하다. uv는 Rust 기반이라 이런 제약이 없다.

2. 글로벌 캐시 시스템

한번 다운로드하면 영원히 재사용한다. 한 프로젝트에서 설치된 패키지는 다른 프로젝트에서도 캐시로 사용된다. pip는 캐시가 있어도 매번 복사 설치한다.

3. 의존성 해결 알고리즘 최적화

pip는 백트래킹으로 의존성을 해결하려고 하는데, uv는 전체 패키지의 제약조건을 한번에 분석해 최적의 버전 조합을 빠르게 계산한다.

4. Universal Locking

모든 플랫폼(OS)과의 호환성을 검토하고 uv.lock을 업데이트한다.

uv 프로젝트 구조

uv init을 실행하면 프로젝트가 초기화되고 다음 파일들이 생성된다.

.python-version

현재 개발 환경의 파이썬 버전을 명시한다.

pyproject.toml

개발자가 직접 설치한 의존성 라이브러리들을 관리한다. uv add library-a로 의존성을 설치하면 이 라이브러리의 버전이 명시된다.

uv.lock

uv add, uv sync를 실행하면 생성된다. pyproject.toml에 적힌 라이브러리뿐만 아니라, 그 라이브러리가 의존하는 하위 의존성까지 전부 계산하여 특정 버전으로 박제한다.

  • add는 라이브러리 추가
  • sync는 명시되어있는 의존성 설치

Universal Lock 파일의 비밀

uv의 락 파일은 OS에 종속되지 않는 유니버설 구조다. 이게 무슨 의미일까?

해당 패키지가 어떤 환경에서 무엇을 필요로 하는지 메타데이터를 정적으로 분석한다. 그리고 각 OS별 바이너리 파일들의 hash를 모두 락 파일에 적어둔다.

uv.lock 파일을 열어보면 이런 정보가 있다.

dependencies = [
    { name = "some-package", marker = "sys_platform == 'win32'" }
]

어떤 패키지에는 마커가 붙어있고 어떤 것에는 안 붙어있는데, 이는 분기 처리한 흔적이다. 안 붙어있는 패키지는 OS마다 동일한 파이썬 라이브러리이기 때문이고, 플랫폼 종속적인 패키지들만 마커가 붙는다.

또한 whl, sdist의 정보는 각 OS별 바이너리 파일의 해시값을 기록해두는 태그다. 이를 통해 어떤 개발 환경에서 개발하더라도 동일한 의존성 파일을 참고할 수 있다.

uv가 빠른 이유: 글로벌 캐시 시스템

uv의 동작 과정은 다음과 같다.

  1. pyproject.toml 파일을 읽고 uv.lock 파일을 업데이트
  2. 각 OS별 캐시 경로에서 라이브러리 파일을 찾음
  3. 캐시에 있으면 새로 다운로드하지 않음, 없으면 다운로드하여 글로벌 캐시에 저장
  4. 프로젝트 폴더의 .venv 폴더에 심볼릭 링크 또는 하드 링크를 사용하여 글로벌 캐시와 가상 환경을 연결

pip처럼 매번 복사하지 않고 링크만 걸기 때문에 빠르고 디스크 공간도 절약된다.

Docker에서 uv 사용하기

로컬에서 개발할 때는 글로벌 캐시를 사용하지만, Docker로 빌드할 때는 어떻게 해야 할까?

Docker 빌드 시에는 글로벌 캐시가 없다. 하지만 Docker BuildKit 캐시를 활용하면 uv의 장점을 살릴 수 있다.

FROM python:3.10-slim

# 1. uv 설치
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# 2. 작업 디렉토리 설정
WORKDIR /app

# 3. 의존성 파일 먼저 복사 (캐시 효율화)
COPY pyproject.toml uv.lock ./

# 4. 도커 캐시 마운트를 활용한 패키지 설치
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-project

# 5. 소스 코드 복사
COPY . .

# 6. 가상환경 활성화 상태로 실행
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]

여기서 사용되는 Docker 캐시는 Docker 빌드 엔진인 BuildKit이 관리하는 별도의 로컬 캐시 버킷에 저장된다. 이 버킷은 이미지 레이어와 관련 없이 호스트에 남기 때문에, 다른 이미지를 빌드할 때도 글로벌 캐시처럼 사용할 수 있다.

GitHub Actions에서 uv 캐시 활용하기

CI/CD 파이프라인에서는 빌드가 끝나면 서버가 통째로 사라지는 경우가 많다. 그래서 캐시를 외부 저장소에 따로 빼두고 가져오는 방식을 사용한다.

Dockerfile은 위와 동일하게 캐시 마운트를 사용하고, workflow yaml 파일은 다음과 같이 구성한다.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # BuildKit 빌더 설정 - 캐시 기능 활성화
      - name: Setup docker buildx
        uses: docker/setup-buildx-action@v3

      # push 설정을 위한 레지스트리 로그인
      - name: Login to container registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: $
          password: $

      # Docker 이미지 빌드 + 캐시 설정
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/$:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

cache-from: type=ghacache-to: type=gha,mode=max 설정을 통해 GitHub의 레포지토리별로 관리되는 캐시 서버에서 uv 라이브러리 파일을 가져오고, 빌드가 끝나면 새로 추가된 라이브러리를 다시 서버에 업로드한다.

Cloud Build 환경에서는?

Google Cloud Build와 같은 환경에서는 GitHub Actions처럼 기본 캐시 기능을 제공하지 않는다. 따라서 캐시 파일을 담아둘 GCS 버킷을 만들고, 빌드 설정에서 직접 캐시 파일을 업로드/다운로드해야 한다.