Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Rbenv, RubyGems, Bundler가 함께 작동하는 방식 완벽 이해하기

Ruby 프로젝트의 의존성 관리는 일반적으로 프로젝트가 사용하는 Ruby 버전과 gem 버전을 지정하는 것에서 시작합니다. Ruby로 개발해 온 경험상, 의존성 문제를 디버깅하는 일은 가장 큰 어려움 중 하나였습니다. 대부분의 기능이 "그냥 잘 작동"하기 때문에 오류 자체는 흔치 않지만, 한번 문제가 발생하면 원인 파악과 수정이 불필요하게 어렵곤 합니다. 이 글에서는 Ruby 의존성 관리에 관여하는 각 구성 요소를 하나씩 살펴보고, 예기치 않은 문제가 생겼을 때 빠르게 해결할 수 있도록 돕고자 합니다.

Rbenv, RubyGems, Bundler가 함께 작동하는 방식 완벽 이해하기

Ruby 코드 로딩 방식

기본적으로 Ruby 언어는 외부에 정의된 코드를 불러오는 두 가지 주요 메서드를 제공합니다. 바로 loadrequire입니다.

load 'json.rb'
require 'json.rb'
require_relative 'json.rb'

두 메서드 모두 절대 경로와 상대 경로를 인자로 받을 수 있습니다. 하지만 다음과 같은 중요한 차이점이 존재합니다.

  1. load는 여러 번 호출하면 파일을 매번 다시 실행하는 반면, require는 이미 실행된 파일을 다시 실행하지 않고 false를 반환합니다.
  2. load는 절대 경로와 상대 경로만 해석합니다. 반면 require는 경로가 절대 경로로 해석되지 않을 경우 $LOAD_PATH를 검색합니다.

세 번째 변형인 require_relative는 상대 경로를 사용하되, Ruby 프로세스의 작업 디렉터리가 아닌 현재 파일의 위치를 기준으로 코드를 불러옵니다.

Rbenv

버전 관리자(version manager)는 인터프리터(여기서는 Ruby)의 여러 버전을 손쉽게 설치하고 전환할 수 있게 해주며, 프로젝트별로 해당 gem들을 찾을 위치를 지정해주는 도구입니다. 버전 관리자는 대체로 언어에 종속되지 않는 범용적인 도구로, Node.js에는 Nvm과 n, Python에는 pyenv, Ruby에는 Rbenv, rvm, chruby 등이 존재합니다. 그럼 이제 rbenv를 직접 살펴보겠습니다.

Ruby 버전 설치하기

rbenv install 명령으로 원하는 버전의 Ruby를 설치할 수 있습니다.

# 루비 2.6.1 설치
$ rbenv install 2.6.1
Downloading openssl-1.1.1i.tar.gz...
-> https://dqw8nmjcqpjn7.cloudfront.net/e8be6a35fe41d10603c3cc635e93289ed00bf34b79671a3a4de64fcee00d5242
Installing openssl-1.1.1i...
Installed openssl-1.1.1i to /home/directory/.rbenv/versions/2.6.1

Downloading ruby-2.6.1.tar.bz2...
-> https://cache.ruby-lang.org/pub/ruby/2.6/ruby-2.6.1.tar.bz2
Installing ruby-2.6.1...
ruby-build: using readline from homebrew
Installed ruby-2.6.1 to /home/directory/.rbenv/versions/2.6.1

# 설치 확인
$ rbenv versions # 설치된 모든 버전 표시
  system
  2.6.1

# 설치 가능한 버전 목록 조회
$ rbenv install -L
1.8.5-p52
1.8.5-p113
1.8.5-p114
...
2.7.0-rc1
2.7.0-rc2
2.7.0
...
truffleruby+graalvm-20.1.0
truffleruby+graalvm-20.2.0
truffleruby+graalvm-20.3.0

# 위 전체 목록은 약 500개에 달해 일일이 스크롤하기엔 부담스럽습니다.
# 아래 명령처럼 fzf와 조합하면 원하는 버전을 손쉽게 찾을 수 있습니다.
$ rbenv install `rbenv install -L | fzf`

버전 전환하기

Ruby 버전을 전환하는 방법은 여러 가지가 있으며, rbenv는 내부적으로 다음 순서로 판단합니다.

  • RBENV_VERSION 환경 변수를 확인합니다.
  • 실행 중인 스크립트가 있는 디렉터리와 그 상위 디렉터리를 루트까지 거슬러 올라가며 .ruby-version 파일을 찾습니다.
  • $PWD(현재 작업 디렉터리)와 그 상위 디렉터리를 루트까지 올라가며 .ruby-version 파일을 찾습니다.
  • 전역 설정 파일인 ~/.rbenv/version을 사용합니다.

우선순위는 위에서 아래로 적용되며, ~/.rbenv/version은 최후의 폴백(fallback)으로서 전역 버전 역할을 합니다. 실제 동작을 살펴보겠습니다.

# 첫 번째 프로젝트 루트 디렉터리 안에서

# 프로젝트에 사용할 루비 버전 선택
$ touch .ruby-version && echo "2.7.1" >> .ruby-version

# 선택된 버전 확인
$ ruby --version
ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-darwin20] # 결과

$ rbenv version
2.7.1 (set by /path/to/current/directory/.ruby-version) # 결과

# 선택 버전 변경
$ : >> .ruby-version && echo "2.6.1" >> .ruby-version

# 변경 사항 확인
$ ruby --version
ruby 2.6.1p33 (2019-01-30 revision 66950) [x86_64-darwin20] # 결과

$ rbenv version
2.6.1 (set by /path/to/current/directory/.ruby-version)

# .ruby-version이 있는 상태에서 RBENV_VERSION으로 강제 변경
$ export RBENV_VERSION=2.5.1

# 변경 확인
# .ruby-version은 무시됩니다.
$ ruby --version
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-darwin20] # 결과

$ rbenv version
2.5.1 (set by RBENV_VERSION environment variable) # 결과

# 설치되지 않은 버전으로 변경하고 RBENV_VERSION 제거
$ unset RBENV_VERSION & : >> .ruby-version && echo "2.4.1" >> .ruby-version

# 변경 확인
$ ruby --version
rbenv: version `2.4.1' is not installed (set by full/path/to/current/directory/.ruby-version) # 결과

Shim과 Rehashing

rbenv를 효과적으로 디버깅하려면 이 두 가지 개념을 정확히 이해해야 합니다.

Shim은 PATH에 존재하는 가벼운 bash 스크립트로, 명령어 호출을 가로채 적절한 Ruby 버전으로 라우팅하는 역할을 합니다. 개념적으로 보면 모든 명령(예: rspec)은 rbenv exec rspec 형태로 변환됩니다. 자세히 살펴보겠습니다.

먼저, rbenv는 설치된 모든 Ruby 버전에 걸쳐 존재하는 모든 명령(rspec, bundle 등)에 대해 shim을 생성하여, 버전과 무관하게 CLI 호출을 가로챕니다. 이 shim들은 ~/.rbenv/shims에서 찾을 수 있으며, 모든 shim은 아래와 같은 동일한 bash 스크립트를 담고 있습니다.

#!/usr/bin/env bash
set -e
[ -n "$RBENV_DEBUG" ] && set -x

program="${0##*/}"
if [ "$program" = "ruby" ]; then
   for arg; do
     case "$arg" in
     -e* | -- ) break ;;
     */* )
        if [ -f "$arg" ]; then
         export RBENV_DIR="${arg%/*}"
         break
       fi
       ;;
     esac
   done
 fi

 export RBENV_ROOT="/home/directory/.rbenv"
 exec "/usr/local/Cellar/rbenv/1.1.2/libexec/rbenv" exec "$program" "$@"

위 스크립트의 동작을 요약하면 대략 다음과 같습니다.

  • 프로그램 이름이 ruby이고 인자에 -e가 포함된 경우,
    • rbenv exec ruby <args>로 변환됩니다.
  • 프로그램 이름이 ruby이고 스크립트 경로가 인자로 전달된 경우,
    • 스크립트가 위치한 디렉터리를 RBENV_DIR로 설정합니다. 이를 통해 rbenv$PWD보다 스크립트 디렉터리를 먼저 검색하여 .ruby-version을 찾습니다. 두 위치 모두에 .ruby-version이 있다면 스크립트 디렉터리의 것이 우선합니다.
  • 프로그램 이름이 ruby가 아닌 경우,
    • rbenv exec <program-name> <args>로 변환됩니다.

마지막으로, rbenv exec <command-name> <args>RBENV_VERSION 환경 변수를 확인해 명령을 전달할 올바른 버전을 결정합니다. 앞서 언급했듯이 RBENV_VERSION은 위에서 설명한 알고리즘에 의해 설정됩니다.

Shim이 PATH에서 가장 앞쪽에 배치되어 있어야 Ruby 실행 파일 호출 시 shim이 먼저 접촉하여 정상적으로 가로챌 수 있습니다. PATH 설정 상태와 shim의 동작 여부를 확인하는 가장 확실한 방법은 다음과 같습니다.

$ which -a bundle

/path/to/home/.rbenv/shims/bundle
/usr/bin/bundle

which -a bundlePATH를 순차적으로 탐색하며 bundle을 찾을 수 있는 위치를 발견한 순서대로 출력합니다. 만약 ~/.rbenv/shims보다 앞선 경로가 출력된다면 shim이 제대로 설정되지 않은 것입니다. 참고로 rbenv which bundlerbenv 컨텍스트 안에서 동작하고 PATH를 탐색하지 않기 때문에 이런 문제를 드러내지 못합니다.

Rehashing은 shim을 생성하는 과정입니다. 새로 설치한 gem이 rspec 같은 실행 파일을 제공한다면, rbenv rehash를 실행해 shim을 생성해야 이후 rspec 호출이 rbenv에 의해 가로채져 적절한 Ruby 버전으로 전달됩니다.

RubyGems

다음은 RubyGems입니다. 공식 Ruby 사이트에서 제공되며, 라이브러리의 생성·공유·설치를 용이하게 하도록 설계된 Ruby 패키징 시스템입니다. 어떤 면에서는 apt-get과 유사한 배포 패키징 시스템이지만, Ruby 소프트웨어에 특화되어 있다는 점이 다릅니다. RubyGems는 gem을 공유하는 사실상의 표준 방법이며, gem은 일반적으로 ~/.rbenv/versions/{version-number}/lib/ruby/gems/{minor-version}/ 또는 사용하는 버전 관리자에 따라 그 변형된 경로에 설치됩니다. Ruby의 기본 Kernel.require 메서드는 gem 설치 디렉터리에서 gem을 불러오는 메커니즘을 제공하지 않습니다. 이를 위해 RubyGems는 Kernel.require를 몽키패치(monkey-patch)하여 다음과 같이 동작하도록 만듭니다.

  • 먼저 $LOAD_PATH에서 gem을 검색합니다.
  • 찾지 못하면 GEM INSTALLATION DIRECTORY에서 gem을 검색합니다.
    • 찾으면 해당 경로를 $LOAD_PATH에 추가합니다.

Ruby 1.9부터는 RubyGems가 기본으로 포함되어 있기 때문에 이 과정이 "네이티브"처럼 작동합니다. 그 이전 버전에서는 RubyGems를 수동으로 설치해야 했습니다. 네이티브처럼 동작한다고 해도, 디버깅 시에는 이 차이를 알아두는 것이 중요합니다.

gem은 특정 문제를 해결하기 위한 연관된 코드들의 묶음입니다. gem을 설치하고 gem 환경 정보를 확인하는 방법은 다음과 같습니다.

$ gem install gemname
$ gem env

RubyGems Environment: 
    - RUBYGEMS VERSION: 3.1.2 
    - RUBY VERSION: 2.7.1 (2020-03-31 patchlevel 83) [x86_64-darwin20] 
    - INSTALLATION DIRECTORY: /path/to/home/.rbenv/versions/2.7.1/lib/ruby/gems/2.7.0 
    - USER INSTALLATION DIRECTORY: /path/to/home/.gem/ruby/2.7.0 
    - RUBY EXECUTABLE: /path/to/home/.rbenv/versions/2.7.1/bin/ruby 
    - GIT EXECUTABLE: /usr/bin/git 
    - EXECUTABLE DIRECTORY: /path/to/home/.rbenv/versions/2.7.1/bin 
    - SPEC CACHE DIRECTORY: /path/to/home/.gem/specs 
    - SYSTEM CONFIGURATION DIRECTORY: /path/to/home/.rbenv/versions/2.7.1/etc 
    - RUBYGEMS PLATFORMS:    
        - ruby    
        - x86_64-darwin-20 
    - GEM PATHS:      
        - /path/to/home/.rbenv/versions/2.7.1/lib/ruby/gems/2.7.0      
        - /path/to/home/.gem/ruby/2.7.0 
    - GEM CONFIGURATION:      
        ...
    - REMOTE SOURCES:      
        - https://rubygems.org/ 
    - SHELL PATH:      
        - /path/to/home/.rbenv/versions/2.7.1/bin

그렇다면 RubyGems는 어떻게 이 문제를 해결할까요? RubyGems는 Kernel의 require 시스템을 자체 require 메서드로 몽키패치합니다. 이것이 적용된 상태에서 require 'honeybadger'가 호출되면 gems 폴더에서 honeybadger.rb를 검색하고, 발견하면 해당 gem을 활성화(activate)합니다.

예를 들어 require 'honeybadger'는 내부적으로 다음과 유사하게 처리됩니다.

  • spec = Gem::Specification.find_by_path('honeybadger')
  • spec.activate

gem을 활성화한다는 것은 곧 해당 gem을 $LOAD_PATH에 넣는다는 의미입니다. RubyGems는 또한 gem 자체를 다운로드하기 전에 그 gem의 모든 의존성을 먼저 다운로드하도록 도와줍니다.

또한 RubyGems는 gem open <gem-name> 명령으로 관련 gem의 디렉터리를 바로 열어볼 수 있는 편리한 기능을 제공합니다. 예를 들면 다음과 같습니다.

Rbenv, RubyGems, Bundler가 함께 작동하는 방식 완벽 이해하기

이를 통해 애플리케이션이 참조하는 gem의 특정 버전을 쉽게 찾고 추적할 수 있습니다.

Bundler

이 계층에서 Bundler는 프로젝트의 모든 의존성을 손쉽게 명시하고, 필요하다면 각각의 버전까지 지정할 수 있게 해줍니다. 그런 다음 gem들과 그 의존성을 해석(resolution)하여 설치합니다. Bundler가 등장하기 전 실제 애플리케이션을 개발하는 일은 다음과 같은 수많은 문제를 안고 있었습니다.

  • 애플리케이션은 수많은 의존성을 가지며, 그 의존성들은 또 다른 의존성과 각자의 버전을 가집니다. 단 하나의 gem이라도 잘못된 버전을 설치하면 앱이 쉽게 깨지고, 이를 고치는 과정은 눈물겨울 정도로 고단했습니다.
  • 또한 서로 다른 두 의존성이 동일한 3차 의존성(third-level dependency)을 참조하는 경우가 있습니다. 호환되는 조합을 찾는 것 자체가 문제였고, 호환성이 존재하더라도 그것을 보장하는 것 또한 문제였습니다.
  • 같은 머신에 여러 애플리케이션이 다양한 의존성과 함께 존재할 때, 우리 애플리케이션은 머신에 설치된 모든 gem에 접근할 수 있습니다. 이는 최소 권한 원칙(principle of least privilege)에 어긋나며, 악성 gem이든 아니든 머신에 설치된 모든 gem에 애플리케이션이 노출되는 결과를 낳습니다.

Bundler는 다음과 같은 방식으로 이 세 가지 문제를 모두 해결하고, 애플리케이션 의존성을 합리적으로 관리할 수 있게 해줍니다.

Bundler는 의존성을 해석하고 잠금 파일(lockfile)을 생성합니다

# Gemfile
gem 'httparty'

bundle 또는 bundle install을 실행하면 잠금 파일이 생성됩니다.

GEM
  specs:
    httparty (0.18.1)
      mime-types (~> 3.0)
      multi_xml (>= 0.5.2)
    mime-types (3.3.1)
      mime-types-data (~> 3.2015)
    mime-types-data (3.2020.1104)
    multi_xml (0.6.0)

PLATFORMS
  ruby

DEPENDENCIES
  httparty

BUNDLED WITH
   2.1.4

위와 같이 Bundler는 설치할 httparty의 버전과 그 의존성들을 Gemfile.lock에 기록합니다. 이 파일은 애플리케이션 의존성의 설계도이므로 반드시 버전 관리 시스템에 커밋해야 합니다. 덕분에 개발, 스테이징, 프로덕션 등 모든 환경에서 프로젝트 의존성의 일관성이 보장됩니다.

Bundler는 의존성 간 호환성을 해석합니다

Bundler는 httparty의 의존성에 대해 적합한 버전을 찾아 지정함으로써 의존성을 해석합니다. 또한 gem들 사이의 의존성 충돌도 해석하려 시도합니다. 예를 들어,

# Gemfile
gem 'httparty' # 'mime-types', '>= 3.0.1, < 4.0.1' gem에 의존
gem 'rest-client' # 'mime-types', '>= 2.0.1, < 3.0' gem에 의존

위 예시는 임의로 만든 것으로, 다음과 같은 오류가 발생합니다.

Bundler could not find compatible versions for gem "mime-types":
In Gemfile:
    httparty was resolved to 0.18.1, which depends on
        mime-types ('>= 3.0.1, < 4.0.1')

    rest-client was resolved to 2.0.4, which depends on
        mime-types ('>= 2.0.1, < 3.0')

두 gem이 서로 호환되지 않는 의존성을 요구하기 때문에 자동으로 해석될 수 없는 것입니다.

Bundler는 Gemfile에 명시되지 않은 gem 접근을 제한합니다

다음과 같은 샘플 Gemfile이 있다고 가정해 보겠습니다.

# Gemfile
gem 'httparty'

# irb
require 'rest-client'

# raises
LoadError (cannot load such file -- rest-client)

이처럼 Bundler는 Gemfile에 명시된 의존성만 프로젝트에서 require할 수 있도록 보장합니다.

Bundle exec

프로젝트 디렉터리에서 rspec을 실행하면 Gemfile에 지정된 버전이 아닌 다른 버전이 실행될 가능성이 있습니다. Gemfile에 명시된 버전 대신 가장 최근에 설치된 버전이 선택되어 실행될 수 있기 때문입니다. bundle exec rspecrspec이 해당 프로젝트의 컨텍스트(Gemfile에 명시된 gem들) 안에서 실행되도록 보장합니다.

Bundle binstubs

흔히 ./bin/rails 같은 명령을 실행하는 글을 본 적이 있을 것입니다. 이 명령은 bundle exec rails와 유사합니다. Binstub은 Ruby 실행 파일을 감싸는 래퍼(wrapper)로, bundle exec의 사용을 더욱 편리하게 만들어 줍니다.

binstub을 생성하려면 bundle binstubs gem-name을 실행합니다. 기본적으로 ./bin 폴더에 binstub이 생성되며, --path 옵션으로 디렉터리를 지정하면 그 위치에 생성됩니다.

참고 자료

더 자세히 알아보려면 다음 자료들을 확인해 보세요.

  • How Do Gems Work?
  • Rbenv
  • RubyGems
  • Bundler