Youngkwang YangEnglish
목차

pip가 의존성 조합을 찾는 과정

Python

uv를 사용해 프로젝트에 다음 두 의존성을 추가해보자.

$ uv add --no-sync "Django<=5.2.16" "asgiref==3.7.2"

$ uv tree --locked
resolver-example v0.1.0
├── asgiref v3.7.2
└── django v5.0.14
    ├── asgiref v3.7.2
    └── sqlparse v0.5.5

Django<=5.2.16만 보면 Django 5.2.16도 선택될 수 있는데, lock 결과에는 Django 5.0.14가 나온다. 왜 하필 5.0.14가 선택됐을까.

asgiref를 특정 버전으로 고정해서 사용하는 경우는 흔한 경우는 아니지만, 이 글에서는 Django 버전이 낮아지는 상황을 설명하기 위해 asgiref==3.7.2 조건을 넣었다. (이 두 의존성을 pip로 설치해도 uv와 동일하게 Django 5.0.14가 선택된다.)

$ python -m pip install --dry-run --ignore-installed --no-cache-dir \
    "Django<=5.2.16" "asgiref==3.7.2"
...
Would install Django-5.0.14 asgiref-3.7.2 sqlparse-0.5.5

(참고로 uv의 리졸버 처리 방식은 pip과 다를 수 있다.)

Django 5.0.14가 선택된 이유

Django<=5.2.16처럼 패키지 이름과 버전 조건을 함께 적은 표현을 requirement라고 한다 (pip freeze로 저장하던 requirements.txt의 그 requirement다). 이 조건을 만족하는 Django 5.2.16, 5.2.15,.. 같은 버전을 후보(candidate)라고 한다.

Django는 asgiref와 sqlparse에 의존한다. 이 예시에서 Django 버전 선택에 영향을 주는 건 asgiref 패키지다. 프로젝트에서 명시한 asgiref==3.7.2가 각 Django 후보의 asgiref 조건도 만족해야 한다.

  • 프로젝트는 asgiref==3.7.2로 버전을 고정했다.
  • Django가 허용하는 asgiref 버전 범위는 Django 버전마다 다르다.

Django가 허용하는 범위에 asgiref 3.7.2가 포함돼야 Django와 asgiref를 함께 설치할 수 있다.

asgiref 3.7.2와 Django 후보의 조건 비교
프로젝트에서 지정asgiref==3.7.2
지정
Django 5.2.16asgiref>=3.8.1
거절
Django 5.0.14asgiref>=3.7.0,<4
선택
  • Django 5.2.16은 asgiref>=3.8.1을 요구하므로 asgiref==3.7.2와 함께 설치할 수 없다.
  • Django 5.2와 5.1의 나머지 후보도 asgiref 3.7.2를 허용하지 않아 거절된다.
  • Django 5.0.14는 asgiref>=3.7.0,<4를 요구하고, 이 범위에는 asgiref 3.7.2가 포함된다.

Pip는 높은 버전부터 후보를 확인하고, 두 조건을 처음으로 만족하는 Django 5.0.14를 선택한다.

여러 의존성의 버전 조건을 모두 만족하는 조합을 찾는 위 과정을 의존성 해석이라고 하고, 이걸 처리하는 코드가 리졸버(resolver)다.

Pip가 설치 후보를 만드는 과정

PyPI에는 패키지 버전마다 Python 버전, OS, CPU에 맞춰 빌드된 wheel 여러 개와 sdist가 올라올 수 있다. Pip은 현재 환경에서 설치할 수 있는 파일만 남긴 뒤, 대응하는 버전을 후보로 만든다.

  • 먼저 Python 버전, OS, CPU와 맞지 않는 배포 파일을 제외한다.
  • 지금까지 수집된 버전 조건과 이미 충돌을 일으킨 후보를 반영해 후보 목록을 좁힌다.
  • 후보의 메타데이터에서 Requires-Dist를 읽고 하위 의존성을 추가한다.

Pip은 후보를 하나씩 확인하면서 필요한 메타데이터를 받는다. 이 때 받는 파일 정보는 배포 파일의 원본이 아니라, 버전 정보와 Requires-Dist 등이 간단히 담긴 .whl.metadata 파일이다. pip install 기본 출력에서 아래처럼 확인할 수 있다.1

  Downloading django-5.2.16-py3-none-any.whl.metadata (4.1 kB)
  Downloading asgiref-3.7.2-py3-none-any.whl.metadata (9.2 kB)
...
  Downloading Django-5.0.14-py3-none-any.whl.metadata (4.1 kB)

후보를 만든 다음에는 모든 조건을 만족하는 조합을 찾아야 하는데, pip은 이 탐색 과정에 resolvelib이라는 라이브러리를 사용한다. Pip의 Provider가 후보와 각 후보의 하위 의존성을 넘기면, resolvelib이 조합을 찾는다. (resolvelib은 PyPI에 접근하거나 wheel 메타데이터를 직접 읽지 않는다.2)

후보 선택과 라운드

resolvelib은 후보 탐색을 라운드라는 단위로 나눈다. 한 라운드에서는 아직 버전이 정해지지 않은 패키지 하나를 고르고, 후보가 지금까지 수집된 버전 조건과 충돌하지 않는지 순서대로 확인한다. 충돌하지 않는 후보를 찾으면 그 후보를 설치할 버전으로 확정하는데, 이 동작을 pin이라고 한다. 후보 하나를 pin하거나 후보가 모두 실패하면 라운드가 끝난다.

아래처럼 PIP_RESOLVER_DEBUG=1을 지정하면 pip이 의존성 해석 과정을 라운드별로 출력하는것을 볼 수 있다.

$ PIP_RESOLVER_DEBUG=1 python -m pip install \
    --dry-run --ignore-installed --no-cache-dir \
    "Django<=5.2.16" "asgiref==3.7.2"

출력에서 각 라운드에 pin된 후보만 추리면 아래와 같다.

pip install "Django<=5.2.16" "asgiref==3.7.2"
  1. round 0asgiref 3.7.2정확한 버전으로 지정됨
  2. round 1Python 3.11.9asgiref의 Requires-Python 조건 확인
  3. round 2Django 5.0.14거절 335.2.16부터 5.1 버전대까지 차례로 확인
  4. round 3sqlparse 0.5.5
  5. round 4종료모든 조건 충족

라운드는 0부터 센다. 0라운드부터 4라운드까지 진행됐고, Django 후보 33개는 모두 2라운드에서 거절됐다.

명령줄에 Django를 먼저 적었더라도 ==로 버전을 정확히 지정한 asgiref의 우선순위가 높아서 asgiref 3.7.2부터 선택된다. Python 3.11.9가 별도 라운드에 표시되는 건 앞에서 선택한 asgiref 3.7.2의 Requires-Python 조건도 함께 검사하기 때문이다.

후보 하나를 거절할 때마다 새 라운드로 넘어가는 건 아니다. 2라운드에서는 asgiref 3.7.2를 그대로 두고 Django 후보만 바꾸고 검사를 이어나간다. Pip 내부 구현을 간단한 의사코드로 옮기면 다음과 같다.

for candidate in candidates: # 후보 리스트 순회
    # 지금까지 정한 조건에 후보의 하위 의존성을 임시로 더한다.
    conditions = add_dependencies(current_conditions, candidate)

    if has_conflict(conditions):
        # 충돌하면 이 후보만 거절한다.
        reject(candidate)
        continue

    # 충돌이 없으면 이 후보를 설치할 버전으로 확정한다.
    pin(candidate, conditions)
    break
else:
    # 후보가 모두 실패하면 앞에서 고른 버전을 다시 바꾼다.
    backjump()

2라운드에서는 앞에서 본 대로 Django 5.2.16부터 내려오며 asgiref 3.7.2와 맞지 않는 33개를 거절하고, Django 5.0.14를 pin한 뒤 다음 라운드로 넘어간다.

2라운드에서 Django 후보를 확인한 순서

프로젝트에서 고정asgiref==3.7.2

  1. Django 5.2.16asgiref>=3.8.1거절
  2. Django 5.2.15asgiref>=3.8.1거절
  3. Django 5.2.14부터 5.1 버전대까지의 후보같은 이유로 거절된 후보 31개거절
  4. Django 5.0.14asgiref>=3.7.0,<4pin

Pip는 조건이 맞지 않는 후보를 차례로 거절하고, 처음으로 조건을 만족하는 Django 5.0.14를 pin한다.

후보 거절과 백점프

Pip 문서에서는 충돌한 후보를 버리고 다른 후보를 시도하는 전체 과정을 백트래킹 (backtracking)이라고 설명한다. 다만 같은 패키지의 다음 후보를 시도하는 경우와 앞에서 선택한 패키지의 버전까지 바꾸는 경우는 구분해야 한다.

  • Django 후보 하나가 충돌하면 asgiref==3.7.2는 그대로 두고 다음 Django 버전을 시도한다. 이 경우에는 현재 후보만 거절한다.
  • 현재 검사중인 패키지의 후보를 모두 시도해도 설치할 버전이 없으면 충돌에 영향을 준 이전 선택으로 돌아가 다른 버전을 시도한다. resolvelib에서는 이 동작을 백점프(backjumping)라고 한다.

둘의 차이는 거절된 후보의 개수가 아니라, 앞에서 이미 골랐던 버전까지 바꿨는지에 있다.3

Django와 Channels로 예를 들면,

$ PIP_RESOLVER_DEBUG=1 python -m pip install \
    --dry-run --ignore-installed --no-cache-dir \
    "Django>=2.2.28,<=3.0" "channels>=1.1.2,<2"

Django 3.0은 asgiref~=3.2를 요구하지만 Channels 1.1.2부터 1.1.8까지는 모두 asgiref~=1.1을 요구한다.4 두 범위가 겹치지 않아 Channels 후보가 모두 거절된다.

Channels 후보를 모두 거절한 뒤 일어나는 백점프
Channels 후보남은 후보
  1. Channels 1.1.8asgiref~=1.1거절
  2. Channels 1.1.7asgiref~=1.1거절
  3. Channels 1.1.6부터 1.1.3까지asgiref~=1.1거절
  4. Channels 1.1.2asgiref~=1.1거절
이미 pin한 후보
  1. Python 3.11.9충돌과 무관건너뜀
  2. Django 3.0asgiref~=3.2충돌에 관여되돌림
백점프 뒤 pin 순서
  1. Channels 1.1.8
  2. asgiref 1.1.2
  3. Django 2.2.28

모든 Channels 후보가 거절되면 pip는 충돌과 무관한 Python 3.11.9 선택은 건너뛰고 Django 3.0 선택을 되돌린다.

백점프가 일어나면 Django 3.0을 선택했던 지점으로 되돌아간다. 이후 Channels 1.1.8, asgiref 1.1.2, Django 2.2.28 순서로 후보를 pin하면서 세 패키지를 함께 설치할 수 있는 조합을 찾는다.

즉 백점프는 무조건 바로 전 라운드로 돌아가는 동작이 아닌, 충돌과 관계없는 선택을 건너뛰고 충돌을 해결할 수 있는 이전 선택으로 돌아가는 동작이다.

설치할 수 없는 조합

Django와 asgiref를 모두 정확한 버전으로 지정하면 대체 후보가 없고, 고정된 두 버전을 함께 설치할 수 없으면 ResolutionImpossible 예외가 발생한다.

$ python -m pip install --dry-run --ignore-installed --no-cache-dir \
    "Django==5.2.16" "asgiref==3.7.2"
ERROR: Cannot install asgiref==3.7.2 and django==5.2.16 because
these package versions have conflicting dependencies.

The conflict is caused by:
    The user requested asgiref==3.7.2
    django 5.2.16 depends on asgiref>=3.8.1
...
ERROR: ResolutionImpossible: for help visit ...

에러 메시지의 The conflict is caused by 아래에서 어떤 조건이 충돌했는지 확인할 수 있다.

참고

Footnotes

  1. PEP 658 형식의 메타데이터가 없으면 pip는 의존성 정보를 확인하기 위해 배포 파일 전체를 내려받기도 한다.

  2. resolvelib은 의존성 조합을 탐색하는 라이브러리다. Pip는 resolvelib을 프로젝트 내부에 포함해 사용하고, pip의 Provider가 파이썬 패키지 정보를 resolvelib에 넘긴다.

  3. 후보를 거절하는 반복문후보가 모두 실패했을 때 백점프하는 분기는 resolvelib 소스에서 따로 확인할 수 있다.

  4. ~=는 호환 릴리스(compatible release) 연산자다. ~=3.2>=3.2,==3.*와 같고, ~=1.1>=1.1,==1.*와 같다. 자세한 규칙은 PyPA 버전 명세에서 확인할 수 있다.