rubyonrails.org에서 더 보기:

Rails 초기화 프로세스

이 가이드는 Rails의 초기화 프로세스 내부를 설명합니다. 이는 매우 심도 있는 가이드이며 고급 Rails 개발자들에게 추천됩니다.

이 가이드를 읽고 나면 다음을 알 수 있습니다:

  • bin/rails server 사용 방법
  • Rails 초기화 시퀀스의 타임라인
  • 부트 시퀀스에서 다양한 파일들이 어디서 필요한지
  • Rails::Server 인터페이스가 어떻게 정의되고 사용되는지

이 가이드는 기본 Rails 애플리케이션에서 Ruby on Rails 스택을 부팅하는 데 필요한 모든 메서드 호출을 자세히 설명하며 진행됩니다. 이 가이드에서는 앱을 부팅하기 위해 bin/rails server를 실행할 때 어떤 일이 일어나는지에 초점을 맞출 것입니다.

이 가이드의 경로는 특별히 명시되지 않는 한 Rails 또는 Rails 애플리케이션을 기준으로 합니다.

Rails source code를 살펴보면서 따라가고 싶다면, GitHub에서 t 키 바인딩을 사용하여 파일 파인더를 열고 파일을 빠르게 찾는 것을 추천합니다.

1 실행!

앱을 부팅하고 초기화해보겠습니다. Rails 애플리케이션은 보통 bin/rails console 또는 bin/rails server를 실행하여 시작됩니다.

1.1 bin/rails

이 파일의 내용은 다음과 같습니다:

#!/usr/bin/env ruby
APP_PATH = File.expand_path("../config/application", __dir__)
require_relative "../config/boot"
require "rails/commands"

APP_PATH 상수는 나중에 rails/commands에서 사용될 것입니다. 여기서 참조된 config/boot 파일은 애플리케이션의 config/boot.rb 파일로, Bundler를 로드하고 설정하는 역할을 합니다.

1.2 config/boot.rb

config/boot.rb는 다음을 포함합니다:

ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)

require "bundler/setup" # Gemfile에 나열된 gem들을 설정합니다.
require "bootsnap/setup" # 비용이 많이 드는 작업을 캐싱하여 부팅 시간을 단축합니다.

표준 Rails 애플리케이션에는 애플리케이션의 모든 의존성을 선언하는 Gemfile이 있습니다. config/boot.rb는 이 파일의 위치를 ENV['BUNDLE_GEMFILE']에 설정합니다. Gemfile이 존재하면 bundler/setup이 필요합니다. require는 Bundler가 Gemfile의 의존성에 대한 load path를 구성하는 데 사용됩니다.

1.3 rails/commands.rb

config/boot.rb가 완료되면, 다음으로 필요한 파일은 alias를 확장하는데 도움이 되는 rails/commands입니다. 현재의 경우, ARGV 배열은 server만을 포함하고 있으며 이것이 전달될 것입니다:

require "rails/command"

aliases = {
  "g"  => "generate",
  "d"  => "destroy", 
  "c"  => "console",
  "s"  => "server",
  "db" => "dbconsole",
  "r"  => "runner",
  "t"  => "test"
}

command = ARGV.shift
command = aliases[command] || command

Rails::Command.invoke command, ARGV

server 대신 s를 사용했다면, Rails는 여기에 정의된 aliases를 사용하여 일치하는 명령어를 찾았을 것입니다.

1.4 rails/command.rb

Rails 명령어를 입력하면 invoke는 주어진 namespace에서 명령어를 찾고 명령어가 있다면 실행합니다.

Rails가 명령어를 인식하지 못하면, 같은 이름의 Rake task를 실행하도록 제어권을 Rake에 넘깁니다.

보시다시피, namespace가 비어있으면 Rails::Command는 자동으로 도움말을 출력합니다.

module Rails
  module Command
    class << self
      def invoke(full_namespace, args = [], **config)
        namespace = full_namespace = full_namespace.to_s

        if char = namespace =~ /:(\w+)$/
          command_name, namespace = $1, namespace.slice(0, char) 
        else
          command_name = namespace
        end

        # 커맨드명이 비어있거나 HELP_MAPPINGS에 포함되어 있으면 help로 설정
        command_name, namespace = "help", "help" if command_name.blank? || HELP_MAPPINGS.include?(command_name)
        # -v 또는 --version 옵션이면 version으로 설정
        command_name, namespace = "version", "version" if %w( -v --version ).include?(command_name)

        command = find_by_namespace(namespace, command_name)
        if command && command.all_commands[command_name]
          command.perform(command_name, args, config) 
        else
          find_by_namespace("rake").perform(full_namespace, args, config)
        end
      end
    end
  end
end

server 커맨드를 사용하면, Rails는 다음과 같은 코드를 추가로 실행할 것입니다:

module Rails
  module Command
    class ServerCommand < Base # :nodoc:
      def perform
        # argument에서 environment 옵션을 추출
        extract_environment_option_from_argument
        # 애플리케이션 디렉토리 설정
        set_application_directory!
        # 재시작 준비
        prepare_restart

        Rails::Server.new(server_options).tap do |server|
          # --environment 옵션을 전파하기 위해
          # 서버가 environment를 설정한 후 애플리케이션을 require
          require APP_PATH
          Dir.chdir(Rails.application.root)

          if server.serveable?
            print_boot_information(server.server, server.served_url)
            after_stop_callback = -> { say "Exiting" unless options[:daemon] }
            server.start(after_stop_callback) 
          else
            say rack_server_suggestion(using)
          end
        end
      end
    end
  end
end

이 파일은 Rails root 디렉토리(APP_PATH에서 두 단계 상위 디렉토리이며, config/application.rb를 가리킴)로 변경되지만, config.ru 파일이 발견되지 않은 경우에만 해당됩니다. 그런 다음 Rails::Server 클래스를 시작합니다.

1.5 actionpack/lib/action_dispatch.rb

Action Dispatch는 Rails 프레임워크의 routing 컴포넌트입니다. routing, session 그리고 공통 middleware와 같은 기능들을 추가합니다.

1.6 rails/commands/server/server_command.rb

이 파일에서는 Rack::Server를 상속받아 Rails::Server 클래스가 정의됩니다. Rails::Server.new가 호출되면, rails/commands/server/server_command.rb에 있는 initialize 메서드가 호출됩니다:

module Rails
  class Server < ::Rack::Server
    def initialize(options = nil)
      @default_options = options || {}
      super(@default_options) 
      set_environment
    end
  end
end

위 코드는 options 파라미터를 선택적으로 받는 initialize 메소드를 정의합니다. options가 nil일 경우 빈 해시를 기본값으로 사용하고, 상위 클래스의 initialize를 호출한 다음 환경을 설정합니다.

우선, super가 호출되어 Rack::Serverinitialize 메서드를 호출합니다.

1.7 Rack: lib/rack/server.rb

Rack::Server는 Rails가 현재 일부인 모든 Rack 기반 애플리케이션에 대한 공통 서버 인터페이스를 제공하는 역할을 합니다.

Rack::Serverinitialize 메서드는 단순히 여러 변수들을 설정합니다:

module Rack
  class Server
    def initialize(options = nil) 
      @ignore_options = []

      if options
        @use_default_options = false 
        @options = options
        @app = options[:app] if options[:app]
      else
        argv = defined?(SPEC_ARGV) ? SPEC_ARGV : ARGV
        @use_default_options = true
        @options = parse_options(argv)
      end
    end
  end
end

이 코드는 Rack::Server 클래스에 대한 initialize 메서드를 정의합니다. options 파라미터가 제공되면 그것을 사용하고, 그렇지 않으면 명령줄 인수를 파싱합니다. options 해시가 제공된 경우 @use_default_options는 false로 설정되고 options가 @options에 할당됩니다. options 해시에 :app 키가 있으면 그 값이 @app에 할당됩니다. options가 nil인 경우, argv는 SPEC_ARGV가 정의되어 있다면 그것을, 아니면 ARGV를 사용하며 @use_default_options는 true로 설정되고 argv가 parse_options 메서드로 파싱됩니다.

이 경우, Rails::Command::ServerCommand#server_options의 반환값이 options에 할당됩니다. if 구문 내부의 코드가 평가될 때, 몇 개의 인스턴스 변수들이 설정됩니다.

Rails::Command::ServerCommandserver_options 메서드는 다음과 같이 정의됩니다:

module Rails
  module Command
    class ServerCommand  
      no_commands do
        def server_options
          {
            user_supplied_options: user_supplied_options,  # 사용자가 제공한 옵션 
            server:                using,                  # 사용할 서버
            log_stdout:            log_to_stdout?,        # stdout으로 로그 출력 여부
            Port:                  port,                  # 포트
            Host:                  host,                  # 호스트
            DoNotReverseLookup:    true,                 # 역방향 DNS 조회 비활성화
            config:                options[:config],      # 설정
            environment:           environment,           # 환경
            daemonize:             options[:daemon],      # 데몬 실행 여부 
            pid:                   pid,                   # PID
            caching:               options[:dev_caching], # 개발 캐싱 사용 여부
            restart_cmd:           restart_command,       # 재시작 명령어
            early_hints:           early_hints           # Early Hints 활성화 여부
          }
        end
      end
    end
  end
end

이 값은 인스턴스 변수 @options에 할당됩니다.

Rack::Server에서 super가 완료되면, rails/commands/server/server_command.rb로 돌아갑니다. 이 시점에서 set_environmentRails::Server 객체의 컨텍스트 내에서 호출됩니다.

module Rails
  module Server
    def set_environment
      ENV["RAILS_ENV"] ||= options[:environment]  
    end
  end 
end

ENV["RAILS_ENV"]가 설정되어 있지 않은 경우에 options[:environment]의 값을 할당합니다.

initialize가 완료된 후, APP_PATH(이전에 설정됨)가 필요한 server 명령으로 다시 돌아갑니다.

1.8 config/application

require APP_PATH가 실행되면 config/application.rb가 로드됩니다(APP_PATHbin/rails에 정의되어 있다는 점을 기억하세요). 이 파일은 애플리케이션에 존재하며, 필요에 따라 자유롭게 변경할 수 있습니다.

1.9 Rails::Server#start

config/application이 로드된 후, server.start가 호출됩니다. 이 메서드는 다음과 같이 정의되어 있습니다:

module Rails
  class Server < ::Rack::Server
    def start(after_stop_callback = nil)
      trap(:INT) { exit }
      create_tmp_directories
      setup_dev_caching
      log_to_stdout if options[:log_stdout]

      super() 
      # ...
    end

    private
      # development 환경에서 캐싱을 설정합니다
      def setup_dev_caching
        if options[:environment] == "development"
          Rails::DevCaching.enable_by_argument(options[:caching])
        end
      end

      # tmp 디렉토리들을 생성합니다
      def create_tmp_directories
        %w(cache pids sockets).each do |dir_to_make|
          FileUtils.mkdir_p(File.join(Rails.root, "tmp", dir_to_make))
        end
      end

      # STDOUT으로 로그를 출력합니다
      def log_to_stdout
        wrapped_app # 로거가 설정되도록 앱을 터치합니다

        console = ActiveSupport::Logger.new(STDOUT)
        console.formatter = Rails.logger.formatter
        console.level = Rails.logger.level

        unless ActiveSupport::Logger.logger_outputs_to?(Rails.logger, STDOUT)
          Rails.logger.extend(ActiveSupport::Logger.broadcast(console))
        end
      end
  end
end

이 메서드는 INT 신호에 대한 트랩을 생성하므로, 서버를 CTRL-C로 종료하면 프로세스가 종료됩니다. 여기의 코드에서 볼 수 있듯이, tmp/cache, tmp/pids, tmp/sockets 디렉토리를 생성합니다. 그런 다음 bin/rails server--dev-caching 옵션과 함께 호출된 경우 개발 환경에서 캐싱을 활성화합니다. 마지막으로, Rack 앱을 생성하는 역할을 하는 wrapped_app을 호출한 다음, ActiveSupport::Logger의 인스턴스를 생성하고 할당합니다.

super 메서드는 Rack::Server.start를 호출하며, 이는 다음과 같이 정의됩니다:

module Rack
  class Server
    def start(&blk)
      if options[:warn]
        $-w = true
      end

      if includes = options[:include]
        $LOAD_PATH.unshift(*includes) 
      end

      if library = options[:require]
        require library
      end

      if options[:debug]
        $DEBUG = true
        p options[:server]
        pp wrapped_app
        pp app
      end

      check_pid! if options[:pid]

      # daemonization(chdir 등) 이전에 config.ru가 로드되도록 
      # wrapped app을 먼저 건드립니다.
      handle_profiling(options[:heapfile], options[:profile_mode], options[:profile_file]) do
        wrapped_app
      end

      daemonize_app if options[:daemonize]

      write_pid if options[:pid]

      trap(:INT) do
        if server.respond_to?(:shutdown)
          server.shutdown
        else
          exit
        end
      end

      server.run wrapped_app, options, &blk
    end
  end
end

Rails 앱에서 흥미로운 부분은 마지막 줄인 server.run입니다. 여기서 우리는 다시 wrapped_app 메서드를 만나게 되는데, 이번에는 이것을 더 자세히 살펴볼 것입니다 (비록 이전에 실행되었고 이미 memoize되어 있지만요).

module Rack
  class Server
    def wrapped_app
      @wrapped_app ||= build_app app
    end
  end
end

여기서 app 메서드는 다음과 같이 정의됩니다:

module Rack
  class Server
    def app
      # :builder 옵션이 있으면 문자열로부터 앱을 빌드하고 그렇지 않으면 config 파일로부터 앱과 옵션을 빌드합니다 
      @app ||= options[:builder] ? build_app_from_string : build_app_and_options_from_config
    end

    # ...

    private
      def build_app_and_options_from_config
        if !::File.exist? options[:config]
          abort "구성 파일 #{options[:config]}을 찾을 수 없습니다"
        end

        app, options = Rack::Builder.parse_file(self.options[:config], opt_parser)
        @options.merge!(options) { |key, old, new| old }
        app
      end

      def build_app_from_string
        Rack::Builder.new_from_string(self.options[:builder])
      end
  end
end

options[:config] 값은 기본적으로 다음 내용을 포함하는 config.ru로 설정됩니다:

# 이 파일은 Rack 기반 서버가 애플리케이션을 시작하는 데 사용됩니다.

require_relative "config/environment"

run Rails.application

Rack::Builder.parse_file 메서드는 이 config.ru 파일의 내용을 가져와서 해당 코드를 사용하여 파싱합니다:

module Rack
  class Builder
    def self.load_file(path, opts = Server::Options.new)
      # ...
      app = new_from_string cfgfile, config 
      # ...  
    end

    # ...

    def self.new_from_string(builder_script, file = "(rackup)")
      eval "Rack::Builder.new {\n" + builder_script + "\n}.to_app",
        TOPLEVEL_BINDING, file, 0
    end
  end
end

위 코드는 파일에서 configuration을 읽어들이고, 그 문자열을 eval하여 Rack application으로 변환합니다. 이 코드에는 보안 취약점이 있는데, eval이 임의의 Ruby 코드를 실행할 수 있기 때문입니다.

Rack::Builderinitialize 메서드는 여기서 블록을 가져와서 Rack::Builder 인스턴스 내에서 실행합니다. 이것이 Rails의 초기화 프로세스의 대부분이 일어나는 곳입니다. config.ruconfig/environment.rb에 대한 require 라인이 처음으로 실행됩니다:

require_relative "config/environment"

"config/environment"를 연관된 위치에서 불러옵니다.

1.10 config/environment.rb

이 파일은 config.ru (bin/rails server)와 Passenger에서 공통적으로 필요로 하는 파일입니다. 이는 서버를 실행하는 두 가지 방법이 만나는 지점입니다. 이 지점 이전의 모든 것은 Rack과 Rails 설정이었습니다.

이 파일은 config/application.rb를 require하는 것으로 시작합니다:

require_relative "application"

위 코드는 프로젝트의 application.rb 파일을 상대 경로로 불러옵니다.

1.11 config/application.rb

이 파일은 config/boot.rb를 require합니다:

require_relative "boot"

이 파일의 상대 경로에 있는 "boot" 파일을 불러옵니다.

하지만 이전에 require되지 않은 경우에만 해당되며, bin/rails server에서는 해당되지만 Passenger에서는 해당되지 않습니다.

이제 재미있는 부분이 시작됩니다!

2 Rails 로딩하기

config/application.rb의 다음 줄은 다음과 같습니다:

require "rails/all"

전체 Rails 프레임워크를 로드합니다.

2.1 railties/lib/rails/all.rb

이 파일은 Rails의 개별 framework들을 require하는 역할을 합니다:

require "rails"

%w(
  active_record/railtie
  active_storage/engine
  action_controller/railtie
  action_view/railtie
  action_mailer/railtie
  active_job/railtie
  action_cable/engine
  action_mailbox/engine
  action_text/engine
  rails/test_unit/railtie
).each do |railtie|
  begin
    require railtie 
  rescue LoadError
  end
end

이렇게 각각의 railtie와 engine을 로드하려고 시도하며, 로드에 실패하면 예외를 발생시키는 대신 건너뛰게 됩니다.

여기서는 모든 Rails framework가 로드되어 애플리케이션에서 사용할 수 있게 됩니다. 각 framework 내부에서 어떤 일이 일어나는지 자세히 다루지는 않겠지만, 직접 탐색해보시는 것을 권장합니다.

지금은 Rails engine, I18n, Rails configuration과 같은 공통 기능들이 여기서 정의된다는 점만 기억하시면 됩니다.

2.2 config/environment.rb로 돌아가기

config/application.rb의 나머지 부분은 애플리케이션이 완전히 초기화된 후에 사용될 Rails::Application에 대한 설정을 정의합니다. config/application.rb가 Rails를 로딩하고 애플리케이션 네임스페이스를 정의하면, 다시 config/environment.rb로 돌아갑니다. 여기서 애플리케이션은 rails/application.rb에 정의된 Rails.application.initialize!로 초기화됩니다.

2.3 railties/lib/rails/application.rb

initialize! 메서드는 다음과 같습니다:

def initialize!(group = :default) # :nodoc:
  raise "애플리케이션이 이미 초기화되었습니다." if @initialized
  run_initializers(group, self)
  @initialized = true
  self
end

앱은 한 번만 초기화할 수 있습니다. Railtie initializersrailties/lib/rails/initializable.rb에 정의된 run_initializers 메서드를 통해 실행됩니다:

def run_initializers(group = :default, *args)
  return if instance_variable_defined?(:@ran) # 이미 실행되었으면 리턴
  initializers.tsort_each do |initializer| # initializer들을 위상 정렬된 순서로 반복
    initializer.run(*args) if initializer.belongs_to?(group) # 해당 그룹에 속한 initializer만 실행
  end
  @ran = true # 실행 완료 표시
end

run_initializers 코드 자체는 까다롭습니다. Rails는 여기서 initializers 메서드에 응답하는 모든 클래스 조상들을 탐색합니다. 그런 다음 조상들을 이름별로 정렬하고 실행합니다. 예를 들어, Engine 클래스는 엔진들에 initializers 메서드를 제공함으로써 모든 엔진을 사용 가능하게 합니다.

railties/lib/rails/application.rb에 정의된 Rails::Application 클래스는 bootstrap, railtie, finisher initializer들을 정의합니다. bootstrap initializer들은 애플리케이션을 준비하고(logger 초기화와 같은), finisher initializer들은(미들웨어 스택 구축과 같은) 마지막에 실행됩니다. railtie initializer들은 Rails::Application 자체에 정의된 initializer들이며 bootstrapfinisher 사이에 실행됩니다.

Railtie initializer들을 load_config_initializers initializer 인스턴스나 config/initializers의 관련 config initializer들과 혼동하지 마세요.

이 작업이 완료되면 우리는 Rack::Server로 돌아갑니다.

2.4 Rack: lib/rack/server.rb

마지막으로 우리는 app 메서드가 정의되던 부분에서 멈췄습니다:

module Rack
  class Server
    def app
      # builder option이 있다면 문자열로부터 app을 생성하고, 
      # 그렇지 않다면 config로부터 app과 option을 생성합니다
      @app ||= options[:builder] ? build_app_from_string : build_app_and_options_from_config
    end

    # ...

    private
      def build_app_and_options_from_config
        if !::File.exist? options[:config]
          abort "설정 파일 #{options[:config]}을(를) 찾을 수 없습니다"
        end

        app, options = Rack::Builder.parse_file(self.options[:config], opt_parser)
        @options.merge!(options) { |key, old, new| old }
        app
      end

      def build_app_from_string
        Rack::Builder.new_from_string(self.options[:builder])
      end
  end
end

이 시점에서 app은 Rails 앱 자체(미들웨어)이며, 그다음으로 Rack은 제공된 모든 미들웨어들을 호출합니다:

module Rack
  class Server
    private
      def build_app(app)
        # 설정된 environment에 대한 모든 middleware를 app에 적용합니다
        # middleware는 역순으로 순회하며 각각의 middleware는 하나씩 호출됩니다
        # middleware는 호출 가능한 객체인 경우 실행됩니다
        # 그 다음 app에 middleware를 wrapping하고 새로운 app을 반환합니다
        middleware[options[:environment]].reverse_each do |middleware|
          middleware = middleware.call(self) if middleware.respond_to?(:call)
          next unless middleware
          klass, *args = middleware
          app = klass.new(app, *args)
        end
        app
      end
  end
end

build_app은 (마지막 줄에서 wrapped_app에 의해) Rack::Server#start에서 호출되었음을 기억하세요. 이전에 살펴봤던 내용은 다음과 같습니다:

server.run wrapped_app, options, &blk

server가 wrapped_app과 options를 실행하고 블록을 yield합니다.

이 시점에서 server.run 구현은 사용하는 서버에 따라 달라집니다. 예를 들어, Puma를 사용하는 경우 run 메서드는 다음과 같이 보일 것입니다:

module Rack
  module Handler
    module Puma
      # ...
      def self.run(app, options = {})
        conf   = self.config(app, options) 

        events = options.delete(:Silent) ? ::Puma::Events.strings : ::Puma::Events.stdio

        launcher = ::Puma::Launcher.new(conf, events: events)

        yield launcher if block_given?
        begin
          launcher.run
        rescue Interrupt
          puts "* 안전하게 종료하는 중이며, 요청이 완료되기를 기다리는 중입니다"
          launcher.stop
          puts "* 안녕히 가세요!"
        end
      end
      # ...
    end
  end
end

서버 설정 자체에 대해서는 자세히 다루지 않겠지만, 이것이 Rails 초기화 프로세스 여정의 마지막 부분입니다.

이러한 상위 수준의 개요는 여러분의 코드가 언제, 어떻게 실행되는지 이해하는 데 도움이 되며, 전반적으로 더 나은 Rails 개발자가 되는데 도움이 될 것입니다. 더 자세히 알고 싶다면, Rails 소스 코드 자체가 아마도 다음으로 살펴볼 가장 좋은 곳일 것입니다.



맨 위로