Ruby

[RoR] Rails Autoloading 알아보기

강씨아저씨 2019. 1. 27. 14:48

오늘의 포스팅 내용은 Rails에서 Autoloading 이 어떤 규칙으로 동작하는지에 대해 알아볼 예정이다.
포스팅의 내용은 사내에서 Ruby와 Rails 의 상수참조에 대한 공유자료를 만들기 위해서 관련 포스팅들을 읽고 내용을 정리한것이다.
내용이 많기때문에 Ruby의 상수참조와 Rails 의 Autoloading 에 대해서 나눠서 소개할 예정이다.
지난번 포스팅에서 Ruby의 상수참조 규칙을 알아봤다면(https://idea-sketch.tistory.com/56) 이번에는 Rails의 Autoloading 에 대해서 알아볼 예정이다. 

Ruby on Rails 에서는 별도의 load 혹은 require 없이도 외부의 클래스나 모듈에 접근이 가능하다. 이렇게 별도의 작업 없이도 접근이 가능한 이유는 Rails 의 Autoloading 의 과정을 통해서 Rails 에서 알아서 필요한 파일들을 load 하거나 require 하기 때문이다. 
 Rails 에서는 클래스나 모듈 등의 상수 참조시 루비에서의 상수참조 규칙을 적용해 본 후, 일치하는 상수를 찾지 못할경우 Autoloading 과정을 수행한다. 그렇다면 우선은 지난번 포스팅에서 배웠던 루비의 상수참조 규칙에 대해 다시한번 떠올려보자.

루비의 상수참조 규칙

지난번 포스팅에서 배웠듯이 루비의 상수참조는 다음과 같은 순서로 진행된다. 

  1. Module.nesting 을 통해서 상수참조를 시도한다. 
  2. Module.nesting 의 첫번째 대상의 Ancestor chain 을 통해서 상수참조를 시도한다. 
  3. Object 에서 상수참조를 시도한다. 

그렇다면 이번에는 Rails 에서 상수를 참조하기 위해 Autoloading 을 어떻게 사용하는지 알아보자. 

레일즈의 상수 Autoloading

위에서 설명한 루비의 상수참조 과정에서 상수참조를 실패했을때는 Module#const_missing 이 호출된다. 

module Foo
  def self.const_missing(name)
    puts "#{self} looking for #{name}"
    super
  end
end
> Foo::Bar
Foo looking for Bar
NameError: uninitialized constant Foo::Bar 

Rails 에서는 ActiveSupport::Dependencies(AS::D) 에서 Module#const_missing 을 오버라이드 해서 상수를 찾기위한 Autoloading 과정을 실행하게 된다. 그리고 조건에 일치하는 후보군을 찾은경우 load 혹은 require 하고 실제로 해당상수가 정의되어 있는지를 확인하게된다.(https://github.com/rails/rails/blob/master/activesupport/lib/active_support/dependencies.rb)

Rails 에서 상수를 찾기위한 프로세스를 보면 다음과 같다. 

  1. Module#const_missing 이 호출될경우 AS::D 가 Dependencies.load_missing_constant(from_mod, const_name) 를 실행시킨다.
  2. load_missing_constant 에서는 search_for_file를 실행하면서 autoload_paths 에 추가되어있는 위치를 대상으로 상수와 일치하는 파일을 찾는다. 
    def search_for_file(path_suffix)
      ...
      autoload_paths.each do |root|
        path = File.join(root, path_suffix)
        return path if File.file? path
      end
    end
    
    1. 만약 상수와 일치하는 파일이 존재할경우 require_or_load 를 통해서 해당 파일을 load 혹은 require 한다.
    2. 그 후 로드된 파일을 대상으로 상수 참조를 시도하게 된다. 

      if from_mod.const_defined?(const_name, false)
        log("constant #{qualified_name} autoloaded from #{expanded}.rb")
        return from_mod.const_get(const_name)
      else
        ...
      end
  3. 만약 상수와 일치하는 파일이 존재하지 않고 상수와 동일한 명칭의 directory 가 존재할경우 상수이름의 모듈을 생성해서 로드된 상수목록에 추가하게 된다.

    def autoload_module!(into, const_name, qualified_name, path_suffix)
      return nil unless base_path = autoloadable_module?(path_suffix)
      mod = Module.new
      into.const_set const_name, mod
      log("constant #{qualified_name} autoloaded (module autovivified from #{File.join(base_path, path_suffix)})")
      autoloaded_constants << qualified_name unless autoload_once_paths.include?(base_path)
      autoloaded_constants.uniq!
      mod
    end
    
  4. 그리고 3번마저도 없다면 현재까지 알고있는 정보를 바탕으로 가능한 많은 경우의수로 상수를 찾기위해 추측을 하기 시작한다.(단 경우의수 중에 하나라도 찾으려는 상수를 이미 정의하고 있으면 안된다)

    # Since Ruby does not pass the nesting at the point the unknown
    # constant triggered the callback we cannot fully emulate constant
    # name lookup and need to make a trade-off: we are going to assume
    # that the nesting in the body of Foo::Bar is [Foo::Bar, Foo] even
    # though it might not be. Counterexamples are
    
    elsif parent = from_mod.module_parent && parent != from_mod &&
     ! from_mod.module_parents.any? { |p| p.const_defined?(const_name, false) }
      
      return parent.const_missing(const_name)
    end
    
  5. 마지막으로 2~4번에 해당하는 조건이 없다면 NameError 를 반환하게 된다.

파일참조 규칙
위의 프로세스중 2번 과정은 주어진 상수정보를 바탕으로 일치하는 파일(.rb)을 찾는 과정인데 Rails 에서는 ActiveSupport::Inflector#underscore 를 이용해서 대문자는 소문자와 언더바(_) 로 변경하고 :: 는 / 로 바꾸는 작업을 진행한다.(https://apidock.com/rails/ActiveSupport/Inflector/underscore)

이 매칭규칙을 이용해서 Rails는 Autoloading 을 위한 파일을 찾는다. Rails는 기본적으로 /app 의 하위폴더를 기본적으로 매칭파일을 찾기 대상에 넣고 autoload_paths 에 추가된 위치들도 추가로 검색한다. 

# config/application.rb
module AutoloadingSample
  class Application < Rails::Application
    config.autoload_paths += ["#{Rails.root}/lib"]
  end
end

만약에 autoload_paths 에 model 과 lib 를 추가했다면 Foo 를 찾기위해 다음과 같이 검색한다. 

  • lib/foo.rb
  • app/assets/foo.rb
  • app/channels/foo.rb
  • ...
  • app/models/foo.rb
  • app/models/concerns/foo.rb

상수 추측하기
위의 프로세스에서 4번 과정에 대해서 조금더 알아보자. 4번까지 갔다는것은 현재상태가 다음과 같음을 의미한다. 

  1. 루비의 상수참조로 문제를 해결하지 못했다 ( 상수가 아직 로드되지 못했다. )
  2. 찾고자하는 상수를 파일경로로 변환했을때 일치하는 파일(.rb)이 존재하지 않는다.
  3. 찾고자하는 상수에 부합하는 폴더도 존재하지 않는다.

기본적으로 주어진 정보내에서 확인해볼수 있는것들을 다 확인해봤는데 원하는 상수를 찾지 못했을경우 Rails 는 가능한 많은 경우의수를 대상으로 상수를 찾기위해 탐색하기 시작한다.
만약 Foo::Bar::Baz를 참조하면, Rails는 이미 로드된 상수를 찾을 때까지 다음 상수를 차례로 로드하려고 시도한다.

  • Foo::Bar::Baz
  • Foo::Baz
  • Baz

그런데 만약 이미 로드된 상수 Baz가 발견된다면 Rails는 즉시 이것이 찾고 있는 Baz가 될 수 없다는 것을 알고(상태 1번을 통해 추측할 수 있다) 탐색을 하지 않고 결과적으로 NameError 를 반환하게 된다. 

종합해보면
이제 우리는 Rails 의 Autoloading 이 대략적으로 어떻게 동작하는지 알게되었다. 이를 예제와 함께 다시한번 확인해보자.

  1. 아직 로드되지 않은 상수 Foo::Bar::Baz 가 참조 시도됨
  2. 루비의 상수규칙을 통해서 검사해봤지만 해당상수를 차지 못하고 Module#const_missing 을 호출함
  3. Rails 의 Autoloading 조건으로 상수참조를 시도
  4. foo/bar/baz.rb에 대한 autoload_paths 내의 검색
  5. 일치하는 파일이 있을경우 파일로드 후 상수참조를 시도
    1. 상수가 정의되어 있으면 반환
    2. 상수가 정의되어 있지않으면 LoadError 반환
  6. 상수와 일치하는 폴더가 있으면 상수 이름의 Module 생성후 반환
  7. 4~6이 실패할경우 가능한 많은 경우의 수로 상수를 찾기위한 추측탐색
  8. 모두 실패할경우 NameError 반환

이렇게 Rails 의 Autoloading 을 통해서 우리는 명시적으로 파일들을 로드하지 않아도 된다.
그러나 Autoloading 을 통해서 예상치 못한 상황을 맞이할 수도 있다. 이번에는 이러한 상황에 대해 알아보자. 

레일즈 Autoloading 의 함정
우리는 Rails 가 기본적으로 상수를 찾지 못할경우 추측을 통해서 상수를 탐색한다는것을 알게되었다. 그리고 이를 통해서 예상치 못한 함정에 빠질수있는 다는것을 확인해보자. 다음 파일이 autoload_path에 있고 현재 로드된 상수가 없다고 가정해보자

# qux.rb
Qux = "I'm at the root!"

# foo.rb
module Foo
end

# foo/qux.rb
module Foo
  Qux = "I'm in Foo!"
end

# foo/bar.rb
class Foo::Bar
  def self.print_qux
    puts Qux
  end
end

Foo::Bar.print_qux 를 하면 무슨일이 생길까?
만약 루비의 상수참조라면 Foo::Bar 에서 참조할 수 있는 Qux 상수가 존재하지 않기때문에 Object 에 로드된 ::Qux 를 참조하게 될것이다. 

# Using normal ruby loading
> Foo::Bar.print_qux
I'm at the root!
=> nil 

Rails 의 Autoloading 기준으로 본다면 상황은 조금 다르다. 처음에는 다음과 같은 결과를 반환한다.

# Using Rails autoloading
> Foo::Bar.print_qux
I'm in Foo!
=> nil 

Rails 는 오직 루비의 상수참조 규칙으로는 Qux를 해결하지 못했다는 것만 알고 있는 상태로 Foo:Bar::Qux 상수를 참조를 시도한다. Foo::Bar::Qux 로는 적절한 상수를 찾지 못한다음 Rails 는 Foo::Qux 가 이미 로드되어 있지 않다는것을 확인한후 Foo::Qux 를 대상으로 다시 상수참조를 시도하게 된다. 
 foo/qux.rb가 존재하고, 실제로 Foo 모듈안에 Qux 라는 상수가 정의되어 있기때문에 상수참조를 성공한다.
문제는 참조된 상수 Qux 가 정말로 개발자가 원했던 Qux 인지는 아무도 모른다.. 그런데 진짜문제는 해당 명령어를 한번더 호출하면서 발생한다. 이제 다시한번 Foo::Bar.print_qux 를 호출해보자. 

> Foo::Bar.print_qux
I'm in Foo!
=> nil
> Foo::Bar.print_qux
NameError: uninitialized constant Foo::Bar::Qux

분명 동일한 함수를 호출했는데 이번에는 NameError 를 반환하고 있다! 대체 무슨 이유로 이런 일이 일어났을까?
문제는 위에서 언급한 프로세스 4번에 의해서 발생하게 된다.
 일단 아까와 같이 Foo:Bar::Qux 를 찾지 못한상태에서 추측을 시도하려고 하는데 추측을 위한 경우의수 중에서 Foo::Qux 가 이미 로드되어 있기 때문에 실제로 Foo::Qux 상수참조를 시도하지 못하고 NameError 를 반환하게 된다!!
이를 해결하기 위해서는 ::Qux로 상수참조를 더 명확하게 할 필요가 있다.

> Qux
=> "I'm at the root!"
> Foo::Bar.print_qux
I'm at the root!
=> nil
> Foo::Bar.print_qux
I'm at the root!
=> nil

오늘은 여기까지

누군가에게 도움이 되었길 바라면서 오늘의 포스팅 끝~