[RoR] Rails Autoloading 알아보기
오늘의 포스팅 내용은 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 과정을 수행한다. 그렇다면 우선은 지난번 포스팅에서 배웠던 루비의 상수참조 규칙에 대해 다시한번 떠올려보자.
루비의 상수참조 규칙
지난번 포스팅에서 배웠듯이 루비의 상수참조는 다음과 같은 순서로 진행된다.
- Module.nesting 을 통해서 상수참조를 시도한다.
- Module.nesting 의 첫번째 대상의 Ancestor chain 을 통해서 상수참조를 시도한다.
- 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 에서 상수를 찾기위한 프로세스를 보면 다음과 같다.
- Module#const_missing 이 호출될경우 AS::D 가 Dependencies.load_missing_constant(from_mod, const_name) 를 실행시킨다.
- 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
- 만약 상수와 일치하는 파일이 존재할경우 require_or_load 를 통해서 해당 파일을 load 혹은 require 한다.
그 후 로드된 파일을 대상으로 상수 참조를 시도하게 된다.
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
만약 상수와 일치하는 파일이 존재하지 않고 상수와 동일한 명칭의 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
그리고 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
마지막으로 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번까지 갔다는것은 현재상태가 다음과 같음을 의미한다.
- 루비의 상수참조로 문제를 해결하지 못했다 ( 상수가 아직 로드되지 못했다. )
- 찾고자하는 상수를 파일경로로 변환했을때 일치하는 파일(.rb)이 존재하지 않는다.
- 찾고자하는 상수에 부합하는 폴더도 존재하지 않는다.
기본적으로 주어진 정보내에서 확인해볼수 있는것들을 다 확인해봤는데 원하는 상수를 찾지 못했을경우 Rails 는 가능한 많은 경우의수를 대상으로 상수를 찾기위해 탐색하기 시작한다.
만약 Foo::Bar::Baz를 참조하면, Rails는 이미 로드된 상수를 찾을 때까지 다음 상수를 차례로 로드하려고 시도한다.
- Foo::Bar::Baz
- Foo::Baz
- Baz
그런데 만약 이미 로드된 상수 Baz가 발견된다면 Rails는 즉시 이것이 찾고 있는 Baz가 될 수 없다는 것을 알고(상태 1번을 통해 추측할 수 있다) 탐색을 하지 않고 결과적으로 NameError 를 반환하게 된다.
종합해보면
이제 우리는 Rails 의 Autoloading 이 대략적으로 어떻게 동작하는지 알게되었다. 이를 예제와 함께 다시한번 확인해보자.
- 아직 로드되지 않은 상수 Foo::Bar::Baz 가 참조 시도됨
- 루비의 상수규칙을 통해서 검사해봤지만 해당상수를 차지 못하고 Module#const_missing 을 호출함
- Rails 의 Autoloading 조건으로 상수참조를 시도
- foo/bar/baz.rb에 대한 autoload_paths 내의 검색
- 일치하는 파일이 있을경우 파일로드 후 상수참조를 시도
- 상수가 정의되어 있으면 반환
- 상수가 정의되어 있지않으면 LoadError 반환
- 상수와 일치하는 폴더가 있으면 상수 이름의 Module 생성후 반환
- 4~6이 실패할경우 가능한 많은 경우의 수로 상수를 찾기위한 추측탐색
- 모두 실패할경우 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
오늘은 여기까지
누군가에게 도움이 되었길 바라면서 오늘의 포스팅 끝~