3P by GN⁺ 2달전 | ★ favorite | 댓글 2개
  • 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_cachedinstall을 연속 실행
    • 이로 인해 의존 관계가 있는 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을 정수 기반으로 변환해 해석기 성능 향상 가능
    • 이미 관련 리팩터링 시도가 있었으나 하위 호환성 문제로 보류

결론 및 향후 계획

  • uv의 속도는 언어보다 불필요한 작업을 제거한 설계 덕분이며, Bundler도 같은 방향으로 개선 가능
  • RubyGems와 Bundler는 이미 현대적 패키지 관리 구조를 갖추고 있어, uv 수준의 속도 달성이 현실적
  • 가장 큰 과제는 레거시 코드와 호환성 유지
  • Rust로 재작성하지 않아도 99%의 성능 향상은 Ruby 코드 내에서 가능, 나머지 1%는 미미한 수준
  • 후속 글에서는 Bundler와 RubyGems의 실제 프로파일링과 구체적 병목 원인을 다룰 예정

Talk is cheap. Show me the code!

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 없이 설치 순서를 알 수 있게 개선할 수 있을 것 같음
  • Aaron이 Bundler를 Rust로 재작성하기보다 실질적인 알고리즘 개선에 집중하는 점이 마음에 듦

    • 속도 향상도 좋지만, 나는 Ruby 설치 자체를 관리해주는 기능이 더 필요함
      여러 버전 관리 도구와 Ruby 버전이 뒤섞여 있는 혼란스러운 환경이 정말 답답함
    • Aaron이 Shopify 소속이라 그런지 gem.coop 프로젝트 언급이 없어서 복잡한 감정이 듦
      문제는 단순히 속도가 아니라 통제권과 생태계의 방향성이라 생각함
      Ruby는 지난 10년간 속도에 집중했지만, 문서 품질과 커뮤니티 관리가 오히려 더 중요했음
      언어가 쇠퇴하는 이유를 진지하게 고민하고, 다양한 아이디어를 밀어붙여야 할 시점임
  • 최근 관련 글로 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에서 더 짜내려면,
      1. 파싱이 빠른 인덱스 포맷 사용 (관련 gist)
      2. 초기 다운로드는 스레드로 처리
      3. 압축 해제와 post-install은 fork로 분리
        이런 식으로 접근할 것 같음
  • 글로벌 캐시를 모든 bundler 인스턴스가 공유”하는 아이디어를 검토 중임
    장기적으로는 큰 이득이 있을 것 같지만, 숨은 복잡성이 있는지 판단 중임
    관련 이슈: rubygems #7249

    • 완전히 단순하진 않지만, 다른 생태계의 선행 사례를 참고하면 충분히 가능함
      Ruby가 처음 이 문제를 푸는 건 아니니, 이제는 그 혜택을 누릴 때임
  • 최적화의 기본 원칙은 간단함 — 아무것도 하지 않는 게 가장 빠름

    • “똑똑한 코드가 빠르다”는 착각을 버려야 함
      불필요한 일 자체를 안 하는 것이 진짜 최적화임