몇 주 전에 저는 RubyGems가 Ruby의 로드 경로(load path)를 관리하는 방식에 대해 글을 썼습니다. 하지만 Rails는 RubyGems를 직접 사용하지 않고, Bundler를 통해 젬을 관리합니다.
Bundler의 작동 방식을 모른다면, 젬이 Rails 앱으로 불려 들어오는 과정이 다소 마법처럼 느껴질 수 있습니다. Gemfile에 한 줄을 추가하는 것만으로 어떻게 앱 안에 코드가 들어올까요? Bundler, Rails, RubyGems는 어떻게 협력해서 의존성 관리를 쉽게 만들어 줄까요?
왜 Bundler일까?
저는 Bundler를 '엄격한 젬 관리자'라고 생각합니다. 즉, Bundler는 필요한 젬의 정확한 버전을 설치하도록 도와주고, 앱이 오직 그 버전만 사용하도록 강제합니다.
이것이 얼마나 유용한지 이해하려면, Bundler가 등장하기 이전의 세상으로 잠시 돌아가 볼 필요가 있습니다.
Bundler 이전에는 설정 스크립트 하나면 필요한 버전의 젬을 설치하는 일이 나름 쉬웠습니다.
gem install rails -v 4.1.0
gem install rake -v 10.3.2
...(물론 Rails 4.1의 의존성이 Rake 10.3.2의 의존성과 충돌하지 않는다는 전제하에서 말이죠!)
그런데 서로 다른 버전의 젬에 의존하는 여러 Rails 앱을 동시에 작업하고 있다면 어떻게 될까요? 아주 신경을 쓰지 않는 한, 끔찍한 젬 활성화(gem activation) 오류를 만나게 됩니다.
Gem::Exception: can't activate hpricot (= 0.6.161, runtime),
already activated hpricot-0.8.3아우, 이 메시지는 지금도 악몽으로 남아 있습니다. 보통 이 오류가 뜨면 온종일 젬을 설치하고 제거하면서 해당 머신에 딱 맞는 버전을 맞추느라 고생해야 했습니다. 게다가 실수로 gem install rake 한 번이면, 신중하게 세워둔 계획이 순식간에 엉망이 되곤 했습니다.
rvm gemset은 한동안 이 문제를 어느 정도 해결해 주었지만, 설정에 시간이 걸렸고 실수로 잘못된 gemset에 설치해 버리면 결국 같은 문제로 되돌아갔습니다. Bundler를 사용하면 의존성에 대해 거의 신경 쓸 필요가 없습니다. 앱은 대체로 그냥 작동합니다. 게다가 gemset보다 훨씬 적은 설정만으로 충분합니다.
정리하면, Bundler는 두 가지 중요한 역할을 합니다. 필요한 모든 젬을 설치해 주고, RubyGems를 잠가 두어 해당 Rails 앱 안에서는 그 젬들만 require할 수 있도록 만드는 것입니다.
Rails는 어떻게 Bundler를 사용할까?
핵심적으로 Bundler는 젬을 설치하고 격리합니다. 하지만 그게 전부는 아닙니다. Gemfile에 선언된 젬의 코드는 대체 어떻게 Rails 앱까지 들어오는 걸까요?
bin/rails 파일을 열어 보면:
#!/usr/bin/env ruby
begin
load File.expand_path("../spring", __FILE__)
rescue LoadError
end
APP_PATH = File.expand_path('../../config/application', __FILE__)
require_relative '../config/boot'
require 'rails/commands'Rails를 로드하기 위해 ../config/boot를 require하는 것을 확인할 수 있습니다. 이 파일을 살펴봅시다.
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
require 'bundler/setup' # Set up gems listed in the Gemfile.벌써 Bundler가 등장했습니다! (덧붙이자면, 환경 변수 BUNDLE_GEMFILE을 설정하면 다른 Gemfile을 사용하도록 지정할 수 있다는 사실도 이번에 새로 알게 되었습니다. 꽤 유용하죠.)
bundler/setup은 다음과 같은 작업을 수행합니다:
$LOAD_PATH에서 젬으로 연결되는 모든 경로를 제거합니다. (RubyGems가 해 놓은 로드 경로 작업을 되돌리는 셈입니다.)- 그런 다음,
Gemfile.lock에 명시된 젬들만의 로드 경로를$LOAD_PATH에 다시 추가합니다.
이제 파일을 require할 수 있는 젬은 오직 Gemfile에 있는 것들뿐입니다.
여기까지 하면 필요한 모든 젬이 로드 경로 위에 올라옵니다. 하지만 RubyGems만 단독으로 사용할 때는 여전히 필요한 파일을 직접 require해야 합니다. Bundler와 함께 Rails를 쓸 때는 왜 젬을 일일이 require하지 않아도 될까요?
Rails가 부팅된 직후 실행되는 config/application.rb를 살펴보겠습니다.
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)또 Bundiler입니다! Bundler.require는 인자로 전달된 모든 그룹에 속한 젬들을 전부 require합니다. (여기서 '그룹'이란 Gemfile 안에서 지정한 그룹을 의미합니다.)
그렇다면 Rails.groups에는 어떤 그룹들이 담겨 있을까요?
# Returns all rails groups for loading based on:
#
# * The Rails environment;
# * The environment variable RAILS_GROUPS;
# * The optional envs given as argument and the hash with group dependencies;
#
# groups assets: [:development, :test]
#
# # Returns
# # => [:default, :development, :assets] for Rails.env == "development"
# # => [:default, :production] for Rails.env == "production"
def groups(*groups)
hash = groups.extract_options!
env = Rails.env
groups.unshift(:default, env)
groups.concat ENV["RAILS_GROUPS"].to_s.split(",")
groups.concat hash.map { |k, v| k if v.map(&:to_s).include?(env) }
groups.compact!
groups.uniq!
groups
end자, 이제 답이 명확해졌습니다. 개발(development) 모드에서 Rails를 실행하면 Rails.groups는 [:default, :development]가 되고, 프로덕션(production) 모드에서는 [:default, :production]가 됩니다.
따라서 Bundler는 Gemfile에서 해당 그룹들에 속한 젬들을 찾아, 발견한 각 젬에 대해 require를 호출합니다. 예를 들어 nokogiri 젬이 있다면, Bundler가 대신 require "nokogiri"를 호출해 줍니다. 바로 이 덕분에 개발자가 별도의 코드를 작성하지 않아도 젬이 Rails에서 자연스럽게 작동하는 것입니다.
자신의 도구를 깊이 알아라
사용하는 도구를 잘 이해하고 있으면 작업이 훨씬 수월해집니다. 따라서 무언가를 매일 사용하고 있다면, 몇 분이라도 시간을 내어 조금 더 깊이 파헤쳐 볼 가치가 충분합니다.
Ruby와 Rails로 개발한다면 젬은 매일 마주하게 되는 존재입니다. 시간을 들여 젬을 제대로 익혀 두세요!