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

Sinatra 작동 원리 완벽 분석: 초기화부터 요청·응답 처리까지

Sinatra는 Ruby로 만들어진 가볍고 우아한 웹 프레임워크입니다.

Rails의 '막내 동생'이라 불릴 만큼 단순하면서도 강력한 기능을 제공합니다…

이번 글에서는 Sinatra의 작동 방식을 자세히 살펴보겠습니다:

  • 프로젝트에서 Sinatra를 require하면 어떤 일이 일어날까요?
  • 라우트 매칭은 어떻게 이루어질까요?
  • 요청과 응답은 어떻게 처리될까요?

궁금한 점은 많은데 시간은 부족하죠…

걱정 마세요!

제가 직접 소스 코드를 파헤쳐 이 질문들의 답을 한곳에 모았습니다. 이 글 하나로 더 빠르게 학습하세요!

Sinatra 초기화 과정

모든 것은 sinatra.rb 파일 하나에서 시작됩니다.

이 파일이 하는 일은 main.rb를 require하는 것뿐입니다. 별거 없죠?

이제부터 조금 더 흥미로워집니다!

main.rb 안에는 base.rb를 require하는 코드가 있고, 옵션 파싱(포트, 환경, 조용한 모드 등)을 담당하는 코드도 들어 있습니다.

Sinatra는 Ruby 표준 라이브러리의 optparse를 활용합니다.

여기서 또 무엇을 발견할 수 있을까요?

다음 at_exit 블록을 살펴보세요:

at_exit { Application.run! if $!.nil? && Application.run? }

이 코드는 프로그램이 종료될 때 실행됩니다.

실제 동작 흐름은 이렇습니다. Ruby가 여러분이 작성한 모든 코드를 읽지만, 루프나 sleep 같은 코드가 없다면 프로그램은 곧바로 자연 종료됩니다.

…그런데 바로 종료되기 직전에 at_exit 블록이 트리거됩니다!

그러면 Sinatra가 제어권을 넘겨받아 웹 서버를 구동하고 요청을 받을 준비를 합니다.

그 역할을 하는 코드가 여기 있습니다:

begin
  start_server(handler, server_settings, handler_name, &block)
rescue Errno::EADDRINUSE
  $stderr.puts "== Someone is already performing on port #{port}!"
  raise
end

# base.rb `run!` 메서드의 일부

그리고 여기서 또 하나 중요한 일이 벌어집니다:

extend Sinatra::Delegator

Sinatra::Delegatorget, post, set 같은 Sinatra DSL 메서드를 정의하는 모듈입니다.

그렇기 때문에 다음과 같은 코드를 작성할 수 있는 것입니다:

get '/' do
  puts "Hello World!"
end

Sinatra는 이 모듈로 전역 main 객체를 확장(extend)합니다.

요청 및 응답 처리

자, 이 시점에서 우리는 새로운 연결을 받아들일 준비가 된 서버를 갖추게 되었습니다.

하지만 새로운 연결이 들어오면 어떤 일이 발생할까요?

Sinatra는 Rails를 비롯한 다른 Ruby 웹 프레임워크와 마찬가지로 저수준 작업을 모두 Rack에 위임합니다.

Rack은 애플리케이션이 call 메서드를 구현하고 있기를 기대합니다. 이는 Rack 초기화 시 전달하는 객체일 뿐입니다.

Sinatra의 경우 이 객체가 바로 Sinatra::Base 클래스입니다.

해당 메서드는 다음과 같습니다:

# Rack call interface.

def call!(env)
  @env      = env
  @request  = Request.new(env)
  @response = Response.new

  invoke { dispatch! }
  invoke { error_block!(response.status) } unless @env['sinatra.error']

  @response.finish
end

# Sinatra의 call 메서드를 수정한 버전 (명확성을 위해)

요청이 어떻게 처리되는지 알아보려면 다음으로 dispatch! 메서드를 살펴봐야 합니다.

바로 그 메서드입니다:

def dispatch!
  invoke do
    static! if settings.static? && (request.get? || request.head?)
    filter! :before
    route!
  end
rescue ::Exception => boom
  invoke { handle_exception!(boom) }
ensure
  filter! :after unless env['sinatra.static_file']
end

# 중요한 부분만 편집함

요청은 4단계로 나누어 처리됩니다:

  1. 먼저 정적 파일(css, js, 이미지 등)을 확인합니다. "public"이라는 이름의 디렉터리가 존재하면 이 설정은 기본적으로 활성화됩니다.
  2. before 필터가 실행됩니다.
  3. 라우트 매칭이 이루어집니다.
  4. after 필터가 실행됩니다.

이제 각 단계를 하나씩 자세히 들여다보겠습니다.

정적 파일 서빙

static! 메서드는 꽤 단순합니다:

def static!(options = {})
  return if (public_dir = settings.public_folder).nil?
  path = File.expand_path("#{public_dir}#{URI_INSTANCE.unescape(request.path_info)}" )
  return unless File.file?(path)

  cache_control(*settings.static_cache_control) if settings.static_cache_control?
  send_file(path, options)
end

이 코드는 요청된 파일이 존재하는지 확인한 뒤, "Cache Control" HTTP 헤더를 설정합니다.

마지막 줄에서 호출하는 send_file은 이름 그대로 파일을 전송합니다 🙂

Before 필터

before 필터를 사용하면 일치하는 라우트를 찾기 전에 특정 코드를 먼저 실행할 수 있습니다.

필터가 추가되는 방식은 다음과 같습니다:

# Define a before filter.

# Runs before all requests within the same context as route handlers
# and may access/modify the request and response.

@filters = {:before => [], :after => []}

def before(path = /.*/, **options, &block)
  add_filter(:before, path, options, &block)
end

def after(path = /.*/, **options, &block)
  add_filter(:after, path, options, &block)
end

def add_filter(type, path = /.*/, **options, &block)
  filters[type] << compile!(type, path, block, options)
end

보시다시피 filters는 필터 타입별로 키를 하나씩 갖는 해시일 뿐입니다.

그런데 compile!은 대체 무엇일까요?

이 메서드는 패턴, 조건 배열, 래퍼(wrapper)라는 3개 요소를 담은 배열을 반환합니다.

흥미롭게도 같은 메서드가 라우트 생성(get 또는 post 블록 사용 시)에도 활용됩니다:

def get(path, opts = {}, &block)
  route('GET', path, opts, &block)
end

def route(verb, path, options = {}, &block)
  signature = compile!(verb, path, block, options)

  (@routes[verb] ||= []) << signature

  signature
end

# 명확성을 위해 편집한 메서드

이를 통해 Sinatra의 필터가 라우트와 동일한 방식으로 작동한다는 사실을 알 수 있습니다.

라우트 매칭

요청 처리 주기의 다음 단계는 라우트 매칭입니다:

def route!(base = settings, pass_block = nil)
  routes = base.routes[@request.request_method]

  routes.each do |pattern, conditions, block|
    process_route(pattern, conditions)
    route_eval
  end

  route_missing
end

# 편집된 메서드

이 코드는 요청 메서드(get, post 등)와 일치하는 모든 라우트를 순회합니다.

실질적인 라우트 매칭은 process_route 메서드 안에서 일어납니다:

def process_route(pattern, keys, conditions, block = nil, values = [])
  route = @request.path_info
  route = '/' if route.empty? and not settings.empty_path_info?

  return unless match = pattern.match(route)
end

여기서 pattern은 정규표현식입니다.

경로와 조건이 모두 일치하는 라우트를 찾으면 route_eval이 호출되어 블록(get/post 라우트의 본문)을 평가하고 라우트 매칭 프로세스를 종료합니다.

# Run a route block and throw :halt with the result.
def route_eval
  throw :halt, yield
end

여기서 흔치 않은 catch/throw 메커니즘이 흐름 제어용으로 사용됩니다.

코드 흐름을 따라가기 매우 혼란스러울 수 있어 실무에서는 권장하지 않지만, 이 기능이 실제로 활용되는 흥미로운 사례로 볼 수 있습니다.

응답 생성

요청 주기의 마지막 단계는 응답을 준비하는 것입니다.

그렇다면 응답은 어디로 가는 걸까요?

invoke 메서드는 다음과 같이 응답을 수집합니다:

res = catch(:halt) { yield }

이 결과는 body 메서드를 통해 응답 본문에 할당됩니다:

body(res)

이제 처음 출발점이었던 call 메서드로 돌아가 보면 다음 코드 줄을 찾을 수 있습니다:

@response.finish

이 코드는 Rack::Response 객체인 @response에 대해 finish 메서드를 호출합니다.

다시 말해, 실제로 클라이언트에게 응답이 전송되는 지점입니다.

보너스: set 메서드의 작동 원리

set 메서드는 Sinatra DSL(도메인 특화 언어)의 일부로, Sinatra 애플리케이션 어디에서나 설정 옵션을 지정할 수 있게 해줍니다.

예시:

set :public_folder, '/var/www'

set을 사용할 때마다 Sinatra는 메타프로그래밍을 통해 3개의 메서드를 생성합니다:

define_singleton("#{option}=", setter) if setter
define_singleton(option, getter)       if getter
define_singleton("#{option}?", "!!#{option}") unless method_defined? "#{option}?"

생성되는 3개의 메서드는 (public_folder를 예로 들면):

  • public_folder
  • public_folder=
  • public_folder?

또한 이 메서드는 세터(public_folder=)가 이미 존재하면 해당 세터를 호출합니다:

if respond_to?("#{option}=") && !ignore_setter
  return __send__("#{option}=", value)
end

단, 메타프로그래밍에는 비용이 따른다는 점을 기억하세요. 저라면 그냥 options 해시를 사용하는 편이 낫다고 생각합니다. 굳이 화려한 메서드가 필요하지 않으니까요.

정리

이번 글에서는 Sinatra가 어떻게 초기화되는지, 요청을 어떻게 처리하는지, 그리고 응답이 생성되기까지 어떤 단계를 거치는지 살펴보았습니다. 이 과정에서 몇 가지 유용한 Ruby 트릭도 배우고 Sinatra에 대한 이해도 한층 깊어졌기를 바랍니다!

다른 Ruby 개발자들에게도 이 글을 공유해서 함께 성장할 수 있기를 바랍니다 🙂