Bundler가 uv만큼 빠를 수 있을까?
(tenderlovemaking.com)- Bundler의 성능 한계를 분석하며, Python의 패키지 관리자 uv가 빠른 이유를 비교
- uv의 속도는 Rust 언어 때문이 아니라 병렬 다운로드, 글로벌 캐시, 메타데이터 기반 의존성 처리 등 구조적 설계 덕분임
- Bundler는 다운로드와 설치 과정이 결합되어 있어 병렬 처리에 제약이 있으며, 이를 분리하면 큰 개선 가능
- 글로벌 캐시 통합, 하드링크 설치, PubGrub 해석기 통합 등으로 RubyGems와 Bundler의 중복을 줄일 수 있음
- 언어 재작성 없이도 대부분의 성능 향상은 Ruby 코드 내에서 달성 가능, uv 수준의 속도에 근접할 수 있음
Bundler와 uv의 성능 비교
- RailsWorld에서 제기된 “왜 Bundler는 uv만큼 빠르지 않은가?”라는 질문을 계기로 Bundler의 성능 병목을 조사
- 작성자는 Bundler가 uv 수준의 속도를 달성할 수 있다고 확신하며, 성능 차이는 언어가 아니라 설계의 문제라고 명시
- Andrew Nesbitt의 글 *“How uv got so fast”*를 인용해 uv의 핵심 최적화 방식을 Bundler에 적용 가능 여부로 분석
Rust로의 재작성 여부
- uv가 Rust로 작성된 점은 사실이나, 속도의 본질적 원인은 Rust 자체가 아님
- Bundler의 병목을 제거해 “Rust로 다시 쓰는 것만이 남은 개선책”이 된다면 그것이 성공이라 평가
- Rust 재작성은 기존 호환성 제약 없이 실험적 설계를 시도할 자유를 제공하지만, 필수 조건은 아님
Bundler의 구조적 병목
- Bundler는 gem 다운로드와 설치를 하나의 메서드에 결합해 병렬 다운로드가 불가능
- 예시 코드에서
install메서드가fetch_gem_if_not_cached와install을 연속 실행 - 이로 인해 의존 관계가 있는 gem(
a -> b -> c)은 순차적으로만 설치됨
- 예시 코드에서
- 실험 결과, 의존성이 있는 경우 9초 이상 소요되지만, 독립적인 gem(
d, e, f)은 병렬 다운로드로 4초 내 완료 -
다운로드와 설치를 분리하면 의존성 규칙을 유지하면서도 병렬 처리 가능
- 네 단계(다운로드 → 압축 해제 → 컴파일 → 설치)로 분리 제안
- 순수 Ruby gem은 의존성 설치 순서를 완화해 추가 속도 향상 가능
캐시 및 설치 최적화
- uv의 글로벌 캐시와 하드링크 설치 방식을 Bundler에도 적용 가능
- Bundler와 RubyGems는 현재 Ruby 버전별로 별도 캐시를 사용
-
$XDG_CACHE_HOME기반의 공유 캐시로 통합 필요 - 하드링크 설치는 캐시 통합 후 적용 가능
- Bundler는 이미 PubGrub 의존성 해석기를 사용하지만, RubyGems는 여전히 molinillo를 사용
- 두 시스템의 해석기 통합이 기술 부채 해소의 핵심
Rust 관련 최적화 요소의 적용 가능성
- Zero-copy 역직렬화는 RubyGems의 YAML 파싱 단계에서 일부 적용 가능성
-
Ruby의 GVL(Global VM Lock) 은 IO 중심 작업에서는 병렬 처리에 큰 제약이 없음
- IO와 ZLIB 처리는 GVL을 해제하므로 병렬 실행 가능
- 단, 작은 파일 쓰기에서는 GVL 관리 오버헤드가 성능 저하 요인
- Ruby 내부에서 이를 개선하는 작업이 진행 중
-
버전 비교 최적화: uv는 버전을
u64정수로 인코딩해 비교 속도를 높임- Ruby에서도
Gem::Version을 정수 기반으로 변환해 해석기 성능 향상 가능 - 이미 관련 리팩터링 시도가 있었으나 하위 호환성 문제로 보류
- Ruby에서도
결론 및 향후 계획
- uv의 속도는 언어보다 불필요한 작업을 제거한 설계 덕분이며, Bundler도 같은 방향으로 개선 가능
- RubyGems와 Bundler는 이미 현대적 패키지 관리 구조를 갖추고 있어, uv 수준의 속도 달성이 현실적
- 가장 큰 과제는 레거시 코드와 호환성 유지
- Rust로 재작성하지 않아도 99%의 성능 향상은 Ruby 코드 내에서 가능, 나머지 1%는 미미한 수준
- 후속 글에서는 Bundler와 RubyGems의 실제 프로파일링과 구체적 병목 원인을 다룰 예정
Hacker News 의견들
-
Bundler의 구조를 잘 아는 건 아니지만, 가장 큰 개선은 uv의 캐시 설계를 도입하는 것이라 생각함
uv가 빠른 이유 중 핵심이 캐시 구조에 있고, 이는 다른 언어나 생태계에서도 복제 가능함
다만requires-python의 상한을 무시하는 부분은 성능 때문이 아니라 더 나은 의존성 해결을 위해서임
예를 들어 프로젝트가 Python 3.8 이상을 요구하지만, 어떤 의존성이<4제한을 걸면 Python 4에서 설치 불가해짐
uv는 모든 지원 버전에 대해 해결하므로 상한을 무시해도 시간 절약은 거의 없음
관련 논의는 Python Discuss 포럼에서 볼 수 있음 -
PEP 658 이후 Python의 Simple Repository API가 메타데이터를 직접 제공하듯, RubyGems.org도 이미 비슷한 정보를 제공함
그런데 gem을 풀어서야 native extension 여부를 알 수 있음
그래서 이 정보를 RubyGems.org 메타데이터에 직접 추가하면 의존성 설치 트리를 완전히 병렬화할 수 있지 않을까 제안함- 나도 같은 생각을 했지만, gemspec의 정보와 RubyGems.org 메타데이터가 다를 가능성이 있음
예전에 RubyGems.org에서 일할 때, 메타데이터가 버전별로 추출된다는 걸 기억함
과거 버전의 gemspec을 다시 처리해야 하는데, 이는 위험한 메타데이터 변경이 될 수 있음
그래서 과거 버전에는 적용이 어렵겠지만, 앞으로는 unpack 없이 설치 순서를 알 수 있게 개선할 수 있을 것 같음
- 나도 같은 생각을 했지만, gemspec의 정보와 RubyGems.org 메타데이터가 다를 가능성이 있음
-
Aaron이 Bundler를 Rust로 재작성하기보다 실질적인 알고리즘 개선에 집중하는 점이 마음에 듦
- 속도 향상도 좋지만, 나는 Ruby 설치 자체를 관리해주는 기능이 더 필요함
여러 버전 관리 도구와 Ruby 버전이 뒤섞여 있는 혼란스러운 환경이 정말 답답함 - Aaron이 Shopify 소속이라 그런지 gem.coop 프로젝트 언급이 없어서 복잡한 감정이 듦
문제는 단순히 속도가 아니라 통제권과 생태계의 방향성이라 생각함
Ruby는 지난 10년간 속도에 집중했지만, 문서 품질과 커뮤니티 관리가 오히려 더 중요했음
언어가 쇠퇴하는 이유를 진지하게 고민하고, 다양한 아이디어를 밀어붙여야 할 시점임
- 속도 향상도 좋지만, 나는 Ruby 설치 자체를 관리해주는 기능이 더 필요함
-
최근 관련 글로 How uv got so fast (2025년 12월, 457개 댓글)이 있음
-
RubyGems를 더 빠르게 만들려면 각 gem의 파일 목록을 레지스트리/데이터베이스화하는 게 핵심임
이렇게 하면require시마다 파일 시스템을 스캔할 필요가 없음
gem을 직접 수정하면 메타데이터를 다시 해시해야 하지만, 어차피 수동 수정은 권장되지 않음- 예전에 이와 비슷한 코드를 작성했는데, 디스크 캐시는 없지만 해시를 즉석에서 생성해도 큰 속도 향상이 있었음
지금은 구식이겠지만 여전히 애착이 가는 미니 프로젝트임
코드: fastup - “bundle install” 최적화는 방향이 잘못된 접근임
진짜 문제는$LOAD_PATH가 모든 gem을 추가해 조합 폭발을 일으키는 구조임
여러 캐시 프로젝트가 존재한다는 건 이게 실제 문제라는 증거임
예전에 앱 시작에 몇 분이 걸렸는데, load path를 조작해 분 단위로 단축한 적도 있음 - 런타임에서 이걸 처리하려 했지만, Ruby에는 효율적인 데이터 구조가 부족해 구현이 어려웠음
- 사실 이건 이미 bootsnap이 하는 일임
예전에 bootsnap을 bundler에 통합하자고 제안했지만 거절당했음
- 예전에 이와 비슷한 코드를 작성했는데, 디스크 캐시는 없지만 해시를 즉석에서 생성해도 큰 속도 향상이 있었음
-
RubyGems의 구조 설명이 흥미로웠음
gem은 tar 파일이고, 그 안의 YAML GemSpec이 의존성을 선언함
RubyGems.org는 이 정보를 API로 제공하므로 eval 없이도 의존성 확인이 가능함
다만 YAML은 파싱 효율이 낮은 포맷이라, JSON이나 protobuf 같은 대안이 더 나을 수도 있음
그래도 gemserver가 이미 의존성 정보를 반환한다면 큰 문제는 아닐 듯함- YAML이 별로긴 하지만, 일반적인 gemspec 크기에서는 성능 영향이 미미할 것 같음
- 사람이 수정하지 않고 검토만 하는 용도의 lockfile이라면, YAML의 복잡한 기능을 제거한 단순 파서를 만들 수 있음
예: 버전, 의존성, 해시 정도만 포함하는 구조 - 사실 이런 메타데이터는 RubyGems나 PyPI가 데이터베이스에 미리 파싱해 저장함
uv가 빠른 이유도 여기에 있음 — 패키지를 다운로드하지 않고도 의존성 계산이 가능함
-
예전에 gem 설치 방식을 개선한 프로토타입 영상을 만든 적 있음
how_gems_should_be.mov -
Ruby의 fibers(또는 Async 라이브러리) 는 종종 과대평가됨
스레드와 마찬가지로 커넥션 풀 같은 상위 레벨 조정 문제가 여전히 존재함
그래도 IO 바운드 설치 작업을 비동기로 처리하면 의미 있는 성능 향상을 볼 수 있음- 순수 Ruby에서 더 짜내려면,
- 파싱이 빠른 인덱스 포맷 사용 (관련 gist)
- 초기 다운로드는 스레드로 처리
- 압축 해제와 post-install은 fork로 분리
이런 식으로 접근할 것 같음
- 순수 Ruby에서 더 짜내려면,
-
“글로벌 캐시를 모든 bundler 인스턴스가 공유”하는 아이디어를 검토 중임
장기적으로는 큰 이득이 있을 것 같지만, 숨은 복잡성이 있는지 판단 중임
관련 이슈: rubygems #7249- 완전히 단순하진 않지만, 다른 생태계의 선행 사례를 참고하면 충분히 가능함
Ruby가 처음 이 문제를 푸는 건 아니니, 이제는 그 혜택을 누릴 때임
- 완전히 단순하진 않지만, 다른 생태계의 선행 사례를 참고하면 충분히 가능함
-
최적화의 기본 원칙은 간단함 — 아무것도 하지 않는 게 가장 빠름
- “똑똑한 코드가 빠르다”는 착각을 버려야 함
불필요한 일 자체를 안 하는 것이 진짜 최적화임
- “똑똑한 코드가 빠르다”는 착각을 버려야 함