현대적 의존성 관리 도구 분석: uv, pnpm, Gradle, Cargo

후르츠·2026년 4월 17일

개요

소프트웨어 프로젝트의 규모가 커질수록 의존성 관리는 단순한 편의 기능이 아니라 빌드 신뢰성과 팀 생산성에 직결되는 문제가 된다.

각 언어 생태계는 저마다의 패키지 관리 철학을 가지고 있으며, 최근 몇 년 사이 기존 도구의 한계를 극복한 새로운 세대의 도구들이 등장하여 사실상의 표준으로 자리잡고 있다.

이번 포스트에서는 Python의 uv, JavaScript의 pnpm, JVM의 Gradle + Version Catalog, Rust의 Cargo를 살펴본다. 언어도 다르고 구현도 다르지만, 이들이 해결하는 문제와 내부 동작 방식을 따라가다 보면 결정론적 빌드와 content-addressable 저장소라는 공통의 수렴점이 드러난다.


Python: uv

기존 Python 생태계는 pip, virtualenv, pyenv, poetry 등 역할이 분산된 여러 도구를 조합해 써야 했다. 의존성 해석(resolution)이 느리고 환경 재현이 어려웠으며, Python 버전 관리조차 별도 도구가 필요했다.

uv는 Astral이 Rust로 구현한 통합 도구로, 이 모든 것을 단일 바이너리로 처리한다.

핵심 메커니즘

uv는 의존성 해석에 PubGrub 알고리즘을 채택한다.
Dart 패키지 매니저에서 출발한 이 알고리즘은 충돌이 발생했을 때 그 원인을 명제 단위로 학습하여 동일한 탐색 경로를 반복하지 않는다. pip의 backtracking resolver 대비 충돌 감지가 훨씬 빠르며, 결과가 결정론적이다.

캐시 전략도 공격적이다. 다운로드한 wheel을 글로벌 캐시에 content-addressed 방식으로 저장하고, 프로젝트 간 하드 링크로 공유한다. 동일한 패키지를 여러 프로젝트에서 사용하더라도 디스크에는 단 한 벌만 존재한다. 이 발상은 뒤에서 볼 pnpm과 본질적으로 동일하다.

Python 인터프리터 관리도 내장하여 pyenv 없이 한 줄로 설치된다.

uv python install 3.13
uv init my-project
uv add fastapi
uv run python main.py

uv.lock 파일은 모든 의존성의 해시를 포함하므로, CI 환경에서도 동일한 빌드를 보장한다.
Python 버전 자체도 프로젝트 메타데이터에 명시되어 "인터프리터 버전 + 패키지 버전 + 패키지 해시"가 하나의 재현 단위로 묶인다.


JavaScript: pnpm

npm의 가장 큰 문제는 node_modules 구조이다.

프로젝트마다 동일한 패키지를 중복 설치하고, 패키지가 node_modules를 직접 순회하면서 선언하지 않은 의존성에 접근할 수 있는 유령 의존성(phantom dependency) 문제가 존재한다. yarn은 성능을 개선했지만 구조적 문제는 해결하지 못했다.

핵심 메커니즘

pnpm은 글로벌 content-addressable store를 두고, 프로젝트의 node_modules에는 심볼릭 링크만 생성한다.

~/.pnpm-store/
  v3/
    files/
      00/abcdef...  ← 실제 파일 (해시 기반)

my-project/
  node_modules/
    .pnpm/                              ← store의 hard link
    react -> .pnpm/react@18.2.0/...     ← symlink

이 구조 덕분에 각 패키지는 자신이 package.json에 명시한 의존성에만 접근할 수 있다. 유령 의존성이 구조적으로 불가능해진다. 런타임의 규약이 아니라 파일 시스템의 레이아웃 자체가 캡슐화를 강제하는 것이다.

모노레포 환경에서도 workspace 기능을 통해 패키지 간 로컬 의존성을 효율적으로 관리하며, 중복 설치 없이 store를 공유한다. uv가 wheel 수준에서 수행하는 공유를, pnpm은 디렉토리 트리 수준에서 수행하는 셈이다.


JVM: Gradle과 Version Catalog

JVM 생태계의 Gradle은 이미 오랫동안 빌드 캐시와 증분 빌드를 제공해 왔다. 다른 생태계가 최근에야 확보한 특성들을 Gradle은 오래전부터 가지고 있었던 셈이다.

다만 운영 측면에서는 오랫동안 두 가지 약점을 안고 있었다. 하나는 동적 타입 기반의 Groovy DSL로 인한 IDE 지원 부족, 다른 하나는 멀티모듈 프로젝트에서 의존성 버전 문자열이 모듈마다 흩어지는 문제였다. 이 두 가지를 각각 Kotlin DSLVersion Catalog가 해결한다.

핵심 메커니즘

Kotlin DSL은 빌드 스크립트를 정적 타입 기반으로 전환하여, IDE 자동완성과 컴파일 타임 오류 검출을 가능하게 한다.

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.0"
}

dependencies {
    implementation(libs.spring.web)
    testImplementation(kotlin("test"))
}

Version Catalog(libs.versions.toml)는 의존성 좌표와 버전을 중앙 집중화한다.

[versions]
spring-boot = "3.3.0"

[libraries]
spring-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }

이렇게 선언된 카탈로그는 libs.spring.web 같은 타입 세이프 접근자로 생성되어, 모든 서브모듈에서 동일한 참조로 사용된다. 버전 충돌을 컴파일 타임에 잡아내는 Kotlin DSL과 결합하면, 멀티모듈 프로젝트에서 발생하기 쉬운 "어느 모듈은 3.3.0, 어느 모듈은 3.2.5" 같은 드리프트가 원천적으로 차단된다.

Gradle의 Build CacheConfiguration Cache는 변경되지 않은 태스크의 결과를 재사용하여 빌드 시간을 단축한다. 원격 빌드 캐시 서버를 구성하면 팀 전체가 캐시를 공유할 수 있는데, 이는 개인 머신 단위의 캐시가 아닌 팀 단위의 content-addressable 캐시를 구성하는 것에 가깝다.


Rust: Cargo

Rust는 언어 설계 단계부터 패키지 매니저를 포함했다. Cargo는 별도 설치 없이 툴체인에 포함되며, 빌드·테스트·문서화·배포를 단일 도구로 처리한다.

"처음부터 표준 패키지 매니저를 포함하며 시작한 언어"라는 점이 Cargo의 근본적인 강점이다. 파편화된 선택지가 없고, 모든 크레이트가 crates.io라는 단일 레지스트리와 Cargo.toml 규약을 전제로 작성된다.

핵심 메커니즘

Cargo.toml에 의존성을 선언하면, Cargo는 crates.io(또는 git, 로컬 경로)에서 패키지를 가져와 Cargo.lock에 해시와 함께 정확한 버전을 고정한다.

[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Features 시스템은 조건부 컴파일을 의존성 수준에서 제어한다. 동일한 크레이트라도 사용자가 활성화한 feature 조합에 따라 컴파일되는 코드가 달라지며, 필요 없는 코드는 아예 컴파일 대상에서 빠진다. 바이너리 크기와 컴파일 시간을 줄이는 동시에, 의존성 트리에 불필요한 하위 의존성이 섞여 들어오는 것을 방지한다.

빌드 캐시는 크레이트 단위로 동작하며, 변경되지 않은 의존성은 재컴파일하지 않는다. sccache 같은 외부 도구와 연동하면 원격 캐시도 활용할 수 있다. Workspace 기능으로 모노레포를 구성할 때는 크레이트 간 의존성을 로컬로 연결하고 Cargo.lock을 단일 파일로 통합 관리한다.


수렴하는 패턴

네 도구의 구현은 저마다 다르지만, 해결하려는 문제와 설계 원칙은 수렴한다.

첫째, 결정론적 빌드. 모든 도구가 lock 파일에 의존성의 정확한 버전과 해시를 기록한다. 버전 번호만으로는 충분치 않다는 인식이 공유되고 있다. 동일 버전이라도 레지스트리에서 재배포되거나 변조될 가능성이 있으므로, 해시까지 포함해야 진짜로 재현 가능한 빌드가 된다.

둘째, content-addressable 글로벌 저장소. uv의 wheel 캐시, pnpm의 store, Cargo의 레지스트리 캐시, Gradle의 Build Cache는 모두 동일한 발상이다. "같은 내용은 한 번만 저장하고 여러 곳에서 참조한다." Git이 blob을 다루는 방식과 본질적으로 같다.

셋째, 의존성 캡슐화. pnpm의 심볼릭 링크 구조, Cargo의 features 시스템, Gradle의 Version Catalog는 각기 다른 층위에서 "선언하지 않은 것에 의존하지 않는다"는 원칙을 강제한다. 이는 대규모 프로젝트에서 의존성 그래프가 암묵적으로 오염되는 것을 막는다.

넷째, 통합 인터페이스. 버전 관리자, 가상환경 도구, 빌드 도구, 배포 도구가 하나의 바이너리로 통합되는 경향이 있다. 파편화된 도구 조합을 기억하는 인지 비용이 생산성에 미치는 영향이 크다는 학습의 결과이다.

언어는 다르지만, 의존성 관리가 풀어야 할 문제의 본질은 결국 같다.

profile
Backend Software Engineer

0개의 댓글