← Dev

개발 환경을 재현 가능하게 만드는 법 1: uv로 파이썬 환경 고정하기

SaidBySolo
SaidBySolo

2026년 9월 4일


제 컴퓨터에서는 잘 되는데요?

개발하다 보면 한 번쯤 듣거나 하게 되는 말입니다. 같은 저장소에서 코드를 받았고, 필요한 패키지도 설치했는데 다른 사람의 컴퓨터에서는 실행되지 않습니다. 로컬에서 통과한 테스트가 CI에서 실패하기도 합니다.

이때 먼저 확인하는 것은 보통 코드입니다. 하지만 코드가 같아도 실행 결과는 달라질 수 있습니다. Python 버전이 다르거나, 설치된 패키지 버전이 다르거나, 한쪽에만 필요한 라이브러리가 설치되어 있을 수 있기 때문입니다.

문제는 이런 차이가 저장소에 잘 드러나지 않는다는 것입니다. 오류를 하나씩 해결하다 보면 결국 실행은 되지만, 그 과정에서 무엇을 설치하고 바꿨는지 기록하지 않으면 다음 사람도 같은 과정을 반복하게 됩니다. 몇 달 뒤 프로젝트를 다시 연 자신이 그다음 사람이 될 수도 있습니다.

이 문제를 줄이는 방법은 개발 환경을 만드는 데 필요한 정보를 코드와 함께 관리하는 것입니다. 이 글에서는 Python 프로젝트 관리 도구인 uv를 사용해 Python 버전과 의존성을 기록하고, 그 기록으로 환경을 다시 만드는 과정을 살펴보겠습니다.

같은 코드만으로는 부족한 이유

데이터베이스에 연결하는 Python 프로그램을 받았다고 가정해 보겠습니다. README에는 다음 명령이 적혀 있습니다.

pip install "psycopg[binary]"

이 명령으로 패키지를 설치할 수는 있습니다. 하지만 작성자가 사용한 버전까지 알 수는 없습니다. 작성자는 이전 버전으로 개발했고, 오늘 저장소를 받은 사람은 새 버전을 설치했을 수 있습니다. 패키지가 의존하는 다른 패키지까지 고려하면 확인해야 할 차이는 더 늘어납니다.

그렇다면 작성자의 가상 환경인 .venv를 그대로 복사하면 될까요?

가상 환경에는 특정 Python 설치 경로나 운영체제에 맞는 바이너리가 포함될 수 있습니다. macOS에서 만든 환경을 Linux로 옮기거나 프로젝트의 위치만 바꿔도 그대로 동작한다는 보장이 없습니다.

여기서 필요한 것이 재현 가능한 개발 환경입니다. 환경을 구성하는 정보를 저장해 두고, 다른 컴퓨터에서도 같은 절차로 필요한 환경을 만들 수 있게 하는 것입니다.

이를 Python 프로젝트에 적용하면 세 가지 질문으로 정리할 수 있습니다.

  1. 어떤 Python 버전을 사용하는가?
  2. 어떤 패키지가 필요하며, 어떤 버전으로 설치하는가?
  3. 설치한 환경에서 프로그램과 테스트를 어떻게 실행하는가?

이 질문에 대한 답이 저장소에 있다면, 환경 설정이 특정 개발자의 기억에 의존하는 일을 줄일 수 있습니다.

uv가 환경을 관리하는 방식

uv는 Python 설치부터 의존성 관리, 가상 환경 생성과 명령 실행까지 지원합니다. 이 글에서 주목할 부분은 프로젝트에 필요한 정보를 파일로 남기고, 그 정보를 실제 환경에 반영하는 방식입니다.

uv 프로젝트에서는 다음 네 가지가 중심이 됩니다.

항목역할
.python-version개발에 사용할 Python 버전
pyproject.toml프로젝트 정보와 의존성 조건
uv.lock설치할 패키지 버전과 설치 정보
.venv위 정보로 만든 가상 환경

앞의 세 파일은 Git에 커밋하고, .venv는 제외합니다.

예를 들어 pyproject.toml에 “이 패키지의 3.x 버전을 사용한다”라고 적으면, uv.lock에는 그 조건을 만족하는 구체적인 버전이 기록됩니다. uv는 이 정보를 읽고 .venv에 필요한 패키지를 설치합니다.

따라서 다른 개발자에게 전달해야 할 것은 앞의 세 파일입니다. .venv는 각자의 컴퓨터에서 만들면 됩니다. uv 공식 가이드에서도 프로젝트 선언, lockfile, 가상 환경을 구분해 설명합니다.

Python 버전과 의존성을 선언하는 기준

환경 설정에서 중요한 것은 사용할 버전을 적는 것뿐 아니라, 각 파일이 어떤 결정을 담는지 구분하는 것입니다. 아래 예제는 Python 3.13 계열을 사용하는 애플리케이션을 가정합니다. 버전과 패키지 구성은 프로젝트의 요구 사항에 맞게 선택할 수 있습니다.

Python 버전 정하기

Python 버전은 3.13처럼 계열만 지정할 수도 있고, 3.13.15처럼 패치 버전까지 지정할 수도 있습니다.

3.13만 지정하면 패치 버전은 달라질 수 있습니다. 개발과 테스트에 사용할 버전을 구체적으로 맞추려면 다음처럼 패치 버전까지 기록할 수 있습니다. 아래의 3.13.15는 버전 지정 형식을 보여 주는 예시이며, 실제 프로젝트에서는 사용할 수 있는 버전 중 테스트한 값을 선택하면 됩니다.

uv python pin 3.13.15
uv python install

첫 번째 명령은 .python-version에 3.13.15를 기록합니다. 두 번째 명령은 그 파일에 지정된 Python을 설치합니다. 지원되는 환경에서는 uv가 필요한 Python을 직접 내려받을 수 있으므로, 개발자마다 별도의 설치 방법을 찾을 필요가 줄어듭니다.

한편 pyproject.toml에도 Python 버전과 관련된 설정이 있습니다. requires-python은 다음처럼 지원 범위를 표현할 수 있습니다.

[project]
# Python 지원 범위 설정 예시
requires-python = ">=3.13,<3.14"

비슷한 정보를 두 번 적는 것처럼 보이지만 역할이 다릅니다. .python-version은 개발에 사용할 기본 버전, requires-python은 프로젝트가 지원하는 버전 범위입니다. 위 설정은 개발할 때 3.13.15를 사용하고, 프로젝트의 지원 범위는 3.13 계열로 둔다는 뜻입니다.

uv python pin이 지원 범위까지 자동으로 바꾸는 것은 아니므로 두 설정은 함께 확인해야 합니다. Python 선택과 설치의 세부 동작은 Python 버전 관리 문서에서 확인할 수 있습니다.

필요한 패키지 추가하기

패키지를 추가할 때는 실행에 필요한 의존성과 개발 도구를 구분할 수 있습니다. 예를 들어 데이터베이스 연결에 psycopg, 테스트에 pytest, 코드 검사에 ruff를 사용하는 프로젝트라면 다음처럼 선언합니다.

uv add "psycopg[binary]>=3.2,<4"
uv add --dev "pytest>=8,<9" "ruff>=0.12,<1"

uv add는 패키지를 설치하면서 pyproject.toml과 uv.lock도 갱신합니다. 설치한 사람의 가상 환경뿐 아니라 저장소에도 의존성 정보가 남는 것입니다.

이때 pyproject.toml의 의존성 관련 설정은 다음 형태가 됩니다. 프로젝트 이름이나 빌드 설정 등은 생략했습니다.

[project]
requires-python = ">=3.13,<3.14"
dependencies = [
    "psycopg[binary]>=3.2,<4",
]
 
[dependency-groups]
dev = [
    "pytest>=8,<9",
    "ruff>=0.12,<1",
]

프로그램 실행에 필요한 psycopg는 dependencies에, 개발 도구인 pytest와 ruff는 dev 그룹에 들어갑니다. uv는 기본적으로 dev 그룹도 설치하므로 개발자는 테스트와 코드 검사에 필요한 도구를 함께 받게 됩니다. 이러한 구분은 나중에 배포 환경에서 개발 도구를 제외할 때도 유용합니다. 의존성 관리 문서에서 그룹별 설정을 더 살펴볼 수 있습니다.

가상 환경에만 패키지를 수동으로 설치하면 이 기록이 빠질 수 있습니다. 당장은 실행되더라도 다른 개발자와 CI에는 전달되지 않습니다. 프로젝트에서 계속 사용할 패키지라면 uv add로 추가해 필요한 이유와 버전을 저장소에서 확인할 수 있게 합니다.

허용 범위와 설치 버전 구분하기

앞의 예제에서 psycopg의 버전 조건은 >=3.2,<4입니다. 3.2 이상 4 미만을 허용한다는 뜻이지, 언제 설치해도 같은 버전을 고른다는 뜻은 아닙니다.

이 차이를 메우는 것이 lockfile인 uv.lock입니다. uv가 직접 의존성과 하위 의존성의 조건을 함께 계산한 결과를 저장합니다. 운영체제나 Python 버전에 따라 필요한 패키지가 다르면 그 조건도 담을 수 있습니다.

이렇게 역할을 나누면 pyproject.toml에서는 프로젝트가 허용하는 범위를 표현하고, uv.lock에서는 실제 설치에 사용할 해석 결과를 공유할 수 있습니다. 같은 lockfile을 사용하더라도 플랫폼에 따라 설치되는 패키지나 바이너리는 달라질 수 있습니다.

uv add를 실행했다면 lockfile도 생성되어 있습니다. 의존성 선언을 직접 수정한 경우에는 uv lock으로 갱신할 수 있습니다. 이 명령은 의존성을 해석하고 lockfile을 작성하며, 가상 환경에 설치하는 작업은 uv sync가 맡습니다.

.python-version, pyproject.toml, uv.lock은 함께 Git으로 관리합니다. 반면 설치 결과인 .venv는 .gitignore에 넣습니다. 환경을 만드는 입력과 그 결과를 구분해야 변경 사항을 공유하기 쉽습니다.

설치할 때 lockfile 검증하기

환경을 설치하는 과정도 관리 대상입니다. 선언에 빠진 내용이 설치 도중 자동으로 보완된다면, 저장소에 기록된 설정만으로 실행할 수 있는지 놓치기 쉽습니다. uv 프로젝트에서는 다음 명령으로 Python을 준비하고 lockfile을 검증하며 의존성을 설치할 수 있습니다.

uv python install
uv sync --locked

uv sync는 가상 환경을 준비하고 패키지를 설치합니다. 여기서 --locked는 lockfile을 갱신해야 하는 상황이면 오류를 내도록 하는 옵션입니다.

예를 들어 누군가 pyproject.toml에 의존성을 추가하고 uv.lock 갱신을 빠뜨렸다면, 설치 과정에서 그 누락을 발견할 수 있습니다. 옵션 없이 실행하면 uv가 필요한 갱신을 자동으로 수행할 수 있습니다. 공식 문서의 locking과 syncing 설명에서 이 동작을 확인할 수 있습니다.

명령 실행에는 uv run을 사용합니다.

uv run --locked python --version
uv run --locked ruff check .

테스트가 작성된 프로젝트라면 다음처럼 실행합니다.

uv run --locked pytest

uv run을 사용하면 셸에서 가상 환경을 직접 활성화하지 않아도 됩니다. 로컬과 CI에서 같은 명령을 사용할 수 있어 실행 절차를 공유하기도 쉽습니다.

다만 uv run은 기본적으로 불필요한 패키지까지 제거하지는 않습니다. 환경을 정리하려면 먼저 uv sync --locked를 실행합니다. 동기화 직후에는 uv run --no-sync pytest처럼 환경 확인과 동기화를 생략할 수도 있습니다.

정말 다시 만들 수 있는지 확인하기

설정 파일이 있다는 것과 그 파일만으로 실행할 수 있다는 것은 다릅니다. 오래 사용한 가상 환경에는 수동으로 설치한 패키지가 남아 있을 수 있습니다.

이를 확인하는 방법은 프로젝트의 .venv를 삭제하고 다시 설치한 뒤 기존 검사와 테스트를 실행하는 것입니다. 예를 들어 macOS와 Linux 셸에서는 다음과 같이 확인할 수 있습니다.

rm -rf .venv
uv python install
uv sync --locked
uv run --no-sync ruff check .

테스트가 있다면 uv run --no-sync pytest도 실행합니다. 이 과정에서 모듈을 찾을 수 없다는 오류가 발생한다면, 이전 환경에만 설치되어 있던 패키지가 없는지 확인할 수 있습니다.

예를 들어 테스트 코드가 httpx를 사용하는데 개발자가 수동으로 설치한 뒤 선언을 빠뜨렸다면, 오래 사용한 환경에서는 테스트가 통과할 수 있습니다. 새 환경에서 실패하는 것을 확인한 뒤 uv add --dev httpx로 추가하고 변경된 파일을 커밋하면 다른 개발자에게도 전달됩니다.

물론 한 번 성공했다고 모든 환경에서의 동작을 보장하는 것은 아닙니다. 그래도 기존 가상 환경에 남아 있던 패키지 없이 실행되는지는 확인할 수 있습니다. CI에서 매번 새 환경을 구성하면 이러한 누락을 지속적으로 검사할 수 있습니다.

버전을 고정한 뒤 업데이트하는 방법

버전을 고정하면 오래된 패키지를 계속 사용하게 되는 것은 아닐까요? lockfile을 사용하는 프로젝트에서도 업데이트는 필요합니다. 다만 변경 시점을 직접 정하고 검증하게 됩니다.

uv는 새 버전이 나왔다는 이유만으로 기존 lockfile의 버전을 바꾸지는 않습니다. 전체 의존성을 갱신하려면 다음 명령을 사용합니다.

uv lock --upgrade

특정 패키지를 대상으로 할 수도 있습니다.

uv lock --upgrade-package psycopg

업데이트는 pyproject.toml의 허용 범위 안에서 이루어집니다. 변경된 uv.lock을 확인하고 uv sync --locked로 환경에 반영한 뒤 테스트와 코드 검사를 실행합니다. 자세한 규칙은 잠긴 패키지 버전 업데이트 문서에 설명되어 있습니다.

이렇게 하면 의존성 업데이트도 코드 변경처럼 리뷰할 수 있습니다. 문제가 발생했을 때 어떤 버전이 바뀌었는지 확인하고, 이전 커밋의 선언과 lockfile로 돌아가 환경을 다시 구성할 수 있습니다.

uv로 관리할 수 있는 범위

Python 버전과 패키지를 맞췄다면 이제 모든 컴퓨터에서 같은 결과가 나올까요?

데이터베이스 연결을 예로 들면 아직 남은 조건이 있습니다. psycopg를 같은 버전으로 설치해도 PostgreSQL 서버의 버전이나 설정이 다를 수 있습니다. 필요한 환경 변수가 빠져 있거나, 시스템 라이브러리가 달라 실행에 실패할 수도 있습니다.

uv.lock이 운영체제, 시스템 라이브러리, 외부 서비스와 그 데이터를 모두 고정해 주는 것은 아닙니다. 같은 Python 버전이라도 배포판이나 빌드 방식에 차이가 있을 수 있습니다. 환경을 더 엄격하게 맞추려면 uv 자체의 버전과 설치 옵션도 함께 관리해야 합니다.

uv로 관리할 수 있는 핵심 범위는 Python 실행 버전과 프로젝트 의존성입니다. 2편에서는 이 파일들을 Dockerfile과 Docker Compose에서 활용해, 애플리케이션 실행 환경과 외부 서비스의 구성을 함께 관리하는 방법을 살펴보겠습니다.

마무리

개발 환경의 차이는 코드 밖에 있어서 발견하기 어렵습니다. 누군가에게는 이미 설치된 패키지가 다른 사람에게는 없고, 같은 설치 명령도 실행한 시점에 따라 다른 버전을 가져올 수 있습니다.

Python 버전을 .python-version에 기록하고, 의존성을 pyproject.toml과 uv.lock으로 관리하면 이런 차이를 확인할 근거가 생깁니다. 여기에 설치와 실행 명령까지 공유하면 새로 합류한 개발자도 같은 절차로 작업을 시작할 수 있습니다.

환경에 문제가 생겼을 때 .venv를 지우고 다시 만들어 볼 수 있다는 것은 작은 변화처럼 보입니다. 하지만 그때부터 개발 환경은 누군가의 컴퓨터에만 남아 있는 상태가 아니라, 팀이 함께 확인하고 수정할 수 있는 프로젝트의 일부가 됩니다.

참고 자료