Virtual environment, pip 및 pyproject.toml Python

1. 개요

한 대의 머신에서 여러 Python 프로젝트를 진행하면, 곧 버전 충돌을 마주칠 것입니다. 프로젝트 A는 requests 2.28이 필요하고, 프로젝트 B는 requests 2.32가 필요합니다. 이 글에서는 그 문제를 해결하는 세 가지 도구를 설명합니다. venv는 각 프로젝트마다 환경을 격리합니다. pip은 패키지를 설치하고 관리합니다. pyproject.toml은 현대적인 표준에 따라 프로젝트를 선언합니다. 이 글을 읽고 나면, 표준 구조의 Python 프로젝트를 구축할 수 있고, editable 모드로 설치할 수 있으며, pytest로 테스트를 실행할 수 있게 됩니다.

2. Virtual environment

Virtual environment (venv)는 세 가지를 포함하는 디렉토리입니다: 독립적인 Python interpreter 사본(보통 symlink), 독립적인 site-packages 디렉토리, 그리고 활성화 스크립트. venv에 설치한 패키지는 해당 디렉토리에만 있으며, 시스템 Python에 영향을 주지 않습니다.

2.1. venv 생성 및 활성화

venv 모듈은 Python 3.3부터 표준 라이브러리에 포함되어 있으므로 추가 설치가 필요 없습니다:

mkdir shopcart && cd shopcart
python3 -m venv .venv

# Linux / macOS
source .venv/bin/activate

# Windows (PowerShell)
.venv\Scripts\Activate.ps1

python3 -m venv .venv 명령은 프로젝트 내에 .venv 디렉토리를 생성합니다. .venv라는 이름은 일반적인 관례이며, 대부분의 IDE가 자동으로 인식합니다. 활성화한 후, 셸 프롬프트는 (.venv) 접두사를 갖게 됩니다. 이때 python과 pip 명령은 venv를 가리킵니다.

venv를 종료하려면 다음을 실행하세요:

deactivate

2.2. 현재 사용 중인 interpreter 확인

많은 사람들이 이 단계를 건너뛰며, 이것이 ’설치했는데도 ModuleNotFoundError가 나온다’는 종류의 오류의 원인입니다:

which python
python -c "import sys; print(sys.prefix); print(sys.prefix != sys.base_prefix)"
/home/lan/shopcart/.venv/bin/python
/home/lan/shopcart/.venv
True

sys.prefix는 현재 실행 중인 환경의 루트 디렉토리입니다. sys.base_prefix는 기본 Python 설치 디렉토리입니다. 두 값이 다르면 venv 내에 있다는 의미입니다.

참고: .venv 디렉토리를 Git에 커밋하지 마세요. .venv/를 .gitignore에 추가하세요. venv는 절대 경로와 운영 체제에 의존하므로 다른 머신에 복사하면 대부분 작동하지 않습니다. 올바른 방법은 의존성 선언 파일을 커밋한 후 새 머신에서 venv를 다시 생성하는 것입니다.

3. pip으로 패키지 관리

pip은 기본 패키지 설치 프로그램이며, PyPI에서 패키지를 가져옵니다.

3.1. 기본 명령

python -m pip install --upgrade pip
python -m pip install "requests>=2.31,<3"
python -m pip list
python -m pip show requests
python -m pip uninstall -y requests

각 명령은 다음을 수행합니다:

  • install --upgrade pip는 pip 자체를 업그레이드합니다. 오래된 pip 버전은 때때로 pyproject.toml을 올바르게 읽지 못합니다.
  • "requests>=2.31,<3"은 버전 지정자입니다. 셸이 <를 리다이렉션으로 해석하지 않도록 따옴표로 감싸야 합니다.
  • list는 설치된 패키지를 나열합니다. show는 버전, 설치 위치, 그것이 의존하는 패키지 등 자세한 정보를 출력합니다.

3.2. pip 대신 python -m pip를 사용해야 하는 이유

단순히 pip 명령은 현재 사용 중인 python과 다른 Python을 가리킬 수 있습니다. 이는 머신에 여러 버전의 Python이 있거나 venv 활성화를 잊었을 때 자주 발생합니다. python -m pip 명령은 항상 올바른 interpreter의 pip를 실행하므로 패키지가 원하는 환경에 설치됩니다.

3.3. requirements.txt와 pip freeze

패키지 목록을 공유하는 전통적인 방법은 requirements.txt를 사용하는 것입니다:

python -m pip freeze > requirements.txt
python -m pip install -r requirements.txt
certifi==2024.8.30
charset-normalizer==3.4.0
idna==3.10
requests==2.32.3
urllib3==2.2.3

pip freeze는 venv의 모든 패키지를 urllib3과 같은 간접 의존성을 포함해 정확한 버전으로 기록합니다. 이 방법은 실행 환경을 재현하는 데 적합합니다. 그러나 어떤 패키지가 실제로 필요하고 어떤 패키지가 단순히 끌려온 것인지 알 수 없습니다. 또한 프로젝트를 설명하지 않습니다: 이름, 버전, Python 요구 사항. 그것이 pyproject.toml의 역할입니다.

4. pyproject.toml으로 프로젝트 선언

pyproject.toml은 Python 프로젝트의 표준 구성 파일입니다. 이는 PEP 518, PEP 621 및 관련 PEP에서 정의됩니다. 이 파일은 setup.py, setup.cfg를 대체하며 pytest나 ruff 같은 많은 도구의 구성을 한 곳에 모읍니다.

4.1. 프로젝트 구조

우리는 src layout을 사용합니다. 즉, src/ 내에 코드를 배치합니다. 이 레이아웃은 테스트가 항상 설치된 패키지에서 실행되도록 하며, 실수로 작업 디렉토리에서 직접 가져오지 않습니다:

shopcart/
├── pyproject.toml
├── src/
│   └── shopcart/
│       ├── __init__.py
│       ├── order.py
│       └── cli.py
└── tests/
    └── test_order.py

4.2. 완전한 pyproject.toml 파일

[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"

[project]
name = "shopcart"
version = "0.1.0"
description = "간단한 주문 가격 계산"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.31,<3",
]

[project.optional-dependencies]
dev = [
    "pytest>=8",
    "ruff>=0.5",
]

[project.scripts]
shopcart = "shopcart.cli:main"

[tool.pytest.ini_options]
testpaths = ["tests"]

각 섹션의 의미:

  • [build-system]은 pip에 프로젝트를 빌드하는 데 필요한 도구를 알려줍니다. 여기서는 setuptools입니다. 나머지를 수정하지 않고 hatchling 또는 flit_core로 바꿀 수 있습니다.
  • [project]는 표준 메타데이터를 포함합니다. requires-python은 지원되지 않는 Python 버전에 프로젝트를 설치하는 것을 방지합니다.
  • dependencies는 프로젝트를 실행하는 데 필수 패키지입니다. 여기서는 버전 범위(>=2.31,<3)를 선언해야 하며, 고정(==2.32.3)하지 말아야 합니다.
  • [project.optional-dependencies]는 선택적 패키지를 그룹화합니다. dev 그룹은 최종 사용자가 필요하지 않은 개발 도구를 포함합니다.
  • [project.scripts]는 터미널에서 shopcart 명령을 생성합니다. 이 명령은 shopcart.cli 모듈의 main 함수를 호출합니다.
  • [tool.pytest.ini_options]는 pytest의 전용 구성입니다. 모든 도구는 자신의 [tool.<name>] 네임스페이스를 가집니다.

src layout을 사용하면 setuptools는 자동으로 src/에서 패키지를 찾으므로 추가 선언이 필요 없습니다.

4.3. 패키지 코드

# src/shopcart/order.py
from dataclasses import dataclass, field
from decimal import Decimal


@dataclass(frozen=True)
class OrderLine:
    product: str
    unit_price: Decimal
    quantity: int


@dataclass
class Order:
    customer: str
    lines: list[OrderLine] = field(default_factory=list)

    def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
        if quantity <= 0:
            raise ValueError("quantity는 0보다 커야 합니다")
        self.lines.append(OrderLine(product, Decimal(unit_price), quantity))

    def total(self) -> Decimal:
        return sum((l.unit_price * l.quantity for l in self.lines), Decimal("0"))

Order는 OrderLine의 목록을 보유합니다. 가격은 부동소수점 오류를 피하기 위해 Decimal을 사용하므로 unit_price는 문자열로 전달됩니다. total() 메서드는 각 줄의 소계를 더하며, 초기값은 Decimal("0")입니다.

# src/shopcart/cli.py
from shopcart.order import Order


def main() -> None:
    order = Order(customer="Nguyễn Văn An")
    order.add("커피", "29000", 2)
    order.add("베트남 바게트", "25000")
    print(f"고객: {order.customer}")
    print(f"총액: {order.total():,} VND")

src/shopcart/__init__.py 파일은 비워둘 수 있습니다.

4.4. Editable 모드로 설치

python -m pip install -e ".[dev]"
shopcart
고객: Nguyễn Văn An
총액: 83,000 VND

-e (editable) 플래그는 패키지를 site-packages에 복사하는 대신 소스 코드에 링크합니다. 따라서 order.py를 수정하면 다시 설치할 필요 없이 변경 사항이 즉시 적용됩니다. ".[dev]"는 현재 디렉토리의 프로젝트를 dev 그룹과 함께 설치하므로 pytest와 ruff도 설치됩니다. [project.scripts] 선언 덕분에 shopcart 명령이 터미널에서 사용 가능합니다.

참고: zsh 같은 일부 셸은 [...]를 글로브 패턴으로 해석합니다. 따라서 항상 ".[dev]"를 따옴표로 감싸야 합니다.

5. 테스트 작성 및 실행

# tests/test_order.py
from decimal import Decimal

import pytest

from shopcart.order import Order


def test_total_sums_all_lines():
    order = Order(customer="Trần Thị Bình")
    order.add("복숭아 차", "35000", 2)
    order.add("커스터드 크림", "15000", 3)

    assert order.total() == Decimal("115000")


def test_empty_order_has_zero_total():
    assert Order(customer="Lê Minh").total() == Decimal("0")


def test_add_rejects_non_positive_quantity():
    order = Order(customer="Phạm Hà")
    with pytest.raises(ValueError, match="quantity"):
        order.add("생수", "10000", 0)
python -m pytest -q
...                                                     [100%]
3 passed in 0.02s

테스트는 shopcart.order을 설치된 패키지로 가져옵니다. 이것이 위의 pip install -e 단계가 필요한 이유입니다. 설치하지 않으면 src layout은 ModuleNotFoundError를 보고하며, 이것은 의도적입니다: 사용자가 설치할 것을 올바르게 테스트하도록 강제합니다. 세 번째 테스트는 pytest.raises를 사용하여 유효하지 않은 수량이 “quantity”라는 단어를 포함하는 메시지와 함께 거부되는지 확인합니다.

6. 버전 고정: pyproject.toml 또는 requirements.txt?

이 두 파일은 상호 배타적이지 않습니다. 서로 다른 목적을 제공합니다:

pyproject.toml Lock 파일 (requirements.txt 고정)
목적 프로젝트에 필요한 것 설명 환경을 정확하게 재현
버전 범위 (>=2.31,<3) 고정 (==2.32.3)
사용처 라이브러리 및 애플리케이션 배포되는 애플리케이션 (서버, Docker)

PyPI에 게시할 라이브러리의 경우 pyproject.toml만 필요합니다. 버전을 고정하면 라이브러리 사용자가 충돌을 경험할 것입니다.

서버에서 실행되는 애플리케이션의 경우, 모든 배포가 동일한 버전 집합을 설치하도록 lock 파일을 추가해야 합니다:

python -m pip install -e .
python -m pip freeze --exclude-editable > requirements.lock

배포할 때는 먼저 lock 파일에서 설치한 후 프로젝트를 설치하여 pip이 의존성 버전을 자동으로 업그레이드하지 않도록 합니다:

python -m pip install -r requirements.lock
python -m pip install --no-deps .

lock 프로세스를 더 간단하게 하려면 pip-tools (명령 pip-compile pyproject.toml) 또는 uv를 사용할 수 있습니다. 둘 다 pyproject.toml을 직접 읽고 해시가 포함된 lock 파일을 생성합니다.

7. 흔한 오류

  • sudo pip install로 패키지를 설치합니다. 이 명령은 운영 체제 패키지를 덮어쓰고 시스템 도구를 손상시킬 수 있습니다. 많은 최신 Linux 배포판은 externally-managed-environment 오류로 완전히 차단합니다. 해결책은 항상 venv를 사용하는 것입니다.
  • 설치 또는 실행 전에 venv를 활성화하는 것을 잊습니다. 섹션 2.2에서와 같이 which python으로 확인하거나 .venv/bin/python -m pip ...을 직접 호출할 수 있습니다.
  • 한 Python으로 venv를 생성하고 다른 Python으로 실행합니다. venv는 그것을 만든 interpreter에 밀접하게 연결됩니다. Python을 업그레이드할 때(예: 3.11에서 3.12로) .venv를 삭제하고 다시 생성하세요.
  • 프로젝트 디렉토리를 이동하거나 이름을 바꿉니다. .venv/bin의 스크립트는 절대 경로를 포함하므로 이동 후 작동하지 않습니다. venv를 다시 생성해야 합니다.
  • 오래된 setup.py와 pyproject.toml을 혼합하고 둘 다에서 의존성을 선언합니다. 하나의 진실 공급원을 선택하세요. 새 프로젝트의 경우 [project] 섹션입니다.
  • 개발 도구를 dependencies에 포함합니다. pytest 또는 ruff는 최종 사용자가 설치할 필요가 없도록 optional-dependencies(또는 도구가 지원하는 경우 [dependency-groups])에 있어야 합니다.

참고: black 또는 httpie와 같은 독립적인 CLI 도구를 머신 전체에서 실행해야 하는 경우에만 venv를 사용하지 않아야 합니다. 이 경우 pipx가 더 적합합니다: 각 도구마다 자체 venv를 생성하고 명령을 PATH에 추가합니다.

8. 결론

이 글은 Python 프로젝트의 세 가지 기본 도구를 다루었습니다:

  • python -m venv .venv는 각 프로젝트마다 의존성을 격리합니다.
  • python -m pip는 현재 사용 중인 interpreter에 패키지를 설치합니다.
  • pyproject.toml은 메타데이터, 의존성, 선택적 그룹, CLI 명령 및 도구 구성을 하나의 파일로 선언합니다.

src layout, pip install -e ".[dev]"와 pytest를 결합하면 깨끗하고, 테스트하기 쉽고, 공유하기 쉬운 프로젝트를 갖게 됩니다. 안정적으로 배포해야 하는 애플리케이션의 경우 pyproject.toml 옆에 lock 파일을 추가하여 모든 환경이 동일한 버전 집합을 설치하도록 하세요.

pythonpipvenvpyprojectpackaging