레일즈를 이용한 조건부 HTTP 캐싱 소개

Introduction to Conditional HTTP Caching with Rails

Posted by npmachine on November 19, 2017

Damian Galarza의 Introduction to Conditional HTTP Caching with Rails를 번역한 글입니다.


HTTP는 개발자에게 응답을 캐시 할 수 있는 강력한 도구 세트를 제공한다. 클라이언트는 받은 컨텐츠를 무조건 캐시하면 안되는 경우가 종종 있다. 또한 특정한 만료 헤더(expiration headers) 설정을 이용하지 못할 수도 있다. 대신 리소스 갱신 여부를 클라이언트가 서버에 질의할 수 있는 방법이 필요하다.

HTTP는 이를 위해 조건부 캐싱을 제공한다. 클라이언트는 새로운 버전의 가용 리소스가 서버에 있는지 서버에 요청하여 알아낼 수 있다.

레일즈는 이 HTTP 기능을 활용 할 수 있는 도구를 제공한다. 레일즈를 다루기 전에, 클라이언트가 캐시의 최신 여부를 확인하는 몇 가지 일반적인 방법에 대해 살펴보자.

ETag

Entity Tag의 줄임말인 ETag는 HTTP 캐시를 조건부로 확인하는 일반적인 방법이다. ETag는 주어진 리소스의 내용을 나타내는 해시값(digest)이다.

서버는 응답할 때, 자원의 상태를 나타내는 ETag를 HTTP 응답 헤더에 포함한다. 마지막 요청 이후에 자원의 수정 여부를 확인하려면, 후속 HTTP 요청의 If-None-Match 헤더를 통해 저장된 ETag를 보낼 수 있다.

서버는 리소스에 대한 현재 ETag를 클라이언트가 제공한 ETag와 비교한다. 두 개의 ETag가 일치하면 서버는 클라이언트의 캐시가 최신(fresh)이라고 판단하여 “304 Not Modified” 상태로 응답한다.

리소스를 요청한 마지막 시간 이후로 리소스가 변경되었다면, 서버는 새 ETag 및 갱신된 응답을 보낸다.

최종수정 타임 스탬프

서버가 제공하는 다른 헤더는 Last-Modified 이다.

이름에서 알 수 있듯이 자원을 마지막으로 수정한 타임 스탬프를 반환한다. 클라이언트는 Last-Modified 타임 스탬프 값을 If-Modified-Since 헤더에 넣어, 이전 요청 이후에 리소스를 수정하였는지 여부를 서버에 질의할 수 있다.

이전과 마찬가지로, 마지막 요청 이후 리소스를 수정하지 않은 경우 서버는 “304 Not Modified” 상태로 응답한다. 리소스를 수정하였다면, 갱신된 리소스와 새로운 Last-Modified 타임 스탬프를 다음에 반환한다.

fresh_when을 사용한 캐싱

ETag와 Last-Modified HTTP 헤더에 대해 알아보았다. 이제 레일즈 애플리케이션에서 이를 어떻게 사용하는지 살펴 보자.

ActionController 는 fresh_when 이라는 강력한 메소드를 제공한다. 이 메소드는 ETag 또는 Last-Modified 타임 스탬프를 사용하여 리소스의 최신 여부를 확인한다.

다음과 같이 post를 보여주는 controller와 route를 가진 간단한 레일즈 블로그 애플리케이션을 가정하자.

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
  end
end

# config/routes.rb
Rails.application.routes.draw do
  resources :posts
end

개별 게시물에 curl 요청을 하면 다음과 같은 응답를 얻는다.

curl -i http://localhost:3000/posts/1
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Etag: "4af7e17fc369c6d1af99f8994d8fd387"
Server: WEBrick/1.3.1 (Ruby/2.1.2/2014-05-08)

<!DOCTYPE html>
<html>
  <body>
    <h1>Blog post 1</h1>
    <p>lorem ipsum</p>
  </body>
</html>

반환된 응답의 헤더를 살펴보면, 아직 특별한 작업을 하지 않았음에도 반환되는 리소스를 나타내는 ETag를 레일즈가 보냈다.

curl로 동일한 요청을 계속하면 리소스가 수정되지 않은 경우에도 다른 ETag 값이 표시된다. 이는 원하던 ETag 사용법이 아니다.

마지막 요청 이후에 게시물이 수정되지 않았다면, 요청이 최신(fresh)이라고 알려주도록 컨트롤러를 수정해야 한다.

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])

    fresh_when @post
  end
end

이제 반복되는 요청에도 ETag가 더 이상 변경되지 않는다.

curl -i http://localhost:3000/posts/1
Content-Length: 667
Content-Type: text/html; charset=utf-8
Etag: "534279bfc931d4236713095ffd3efb28"
Last-Modified: Wed, 12 Nov 2014 15:44:46 GMT
Server: WEBrick/1.3.1 (Ruby/2.1.3/2014-09-19)

반환된 응답의 갱신된 HTTP 헤더를 자세히 들여다보면, 서버에서 post 자원에 대한 Last-Modified 타임 스탬프를 반환하기 시작했다.

아직 끝나지 않았다.

캐시를 사용하려면 If-None-Match 혹은 If-Modified-Since 헤더로 ETag 나 Last-Modified 타임 스탬프 값을 서버에서 확인해야 한다.

curl -i -H 'If-None-Match: "534279bfc931d4236713095ffd3efb28"' http://localhost:3000/posts/1
HTTP/1.1 304 Not Modified
Etag: "534279bfc931d4236713095ffd3efb28"
Last-Modified: Wed, 12 Nov 2014 15:44:46 GMT
Server: WEBrick/1.3.1 (Ruby/2.1.3/2014-09-19)

클라이언트에게 캐신된 컨텐츠를 사용할 수 있다고 알려주는 304 Not Modified 응답을 받았다. 브라우저는 저장된 ETag와 Last-Modified 타임 스탬프를 자동으로 조건부 캐싱 헤더와 같이 보내므로 실제로 무엇을 할 필요는 없다.

If-Modified-Since 헤더로 비슷한 요청을 보내보자.

curl -i -H 'If-Modified-Since: Wed, 12 Nov 2014 15:44:46 GMT' http://localhost:3000/posts/1
HTTP/1.1 304 Not Modified
Etag: "534279bfc931d4236713095ffd3efb28"
Last-Modified: Wed, 12 Nov 2014 15:44:46 GMT
Server: WEBrick/1.3.1 (Ruby/2.1.3/2014-09-19)

동일한 결과를 얻었다. 게시물은 마지막 시간 이후 수정되지 않았으며 304 Not Modified 응답을 받았다.

결론

이런 작업이 어떤 효과가 있을까? 우리는 리소스 수정 여부를 확인하려고 서버에 여전히 요청을 보내고 있다.

위의 작업을 실제로 이해하기 위해, 캐싱하기 전 레일즈 로그를 살펴보자.

Started GET "/posts/1" for 127.0.0.1 at 2014-11-12 11:33:57 -0500
Processing by PostsController#show as */*
  Parameters: {"id"=>"1"}
  Post Load (0.3ms)  SELECT  "posts".* FROM "posts"  WHERE "posts"."id" = $1
LIMIT 1  [["id", 1]]
  Rendered posts/show.html.erb within layouts/application (0.1ms)
  Rendered application/_flashes.html.erb (0.0ms)
  Rendered application/_analytics.html.erb (0.1ms)
  Rendered application/_javascript.html.erb (16.1ms)
Completed 200 OK in 35ms (Views: 33.4ms | ActiveRecord: 0.3ms)

이 요청은 약 33ms 정도 소요되며 레이아웃 내의 posts/show 템플릿을 다른 partial과 함께 렌더링 한다. 또한 대부분의 시간이 렌더링 프로세스 내에서 소비된다는 것을 알 수 있다. ActiveRecord는 1 밀리 초 미만의 시간 동안 실행되었다.

다음으로 If-None-Match 헤더로 조건부 캐싱을 사용한 로그를 살펴보자.

Started GET "/posts/1" for 127.0.0.1 at 2014-11-12 11:39:52 -0500
Processing by PostsController#show as */*
  Parameters: {"id"=>"1"}
  Post Load (0.3ms)  SELECT  "posts".* FROM "posts"  WHERE "posts"."id" = $1
LIMIT 1  [["id", 1]]
Completed 304 Not Modified in 2ms (ActiveRecord: 0.3ms)

렌더링 프로세스를 모두 생략했다. 클라이언트의 캐시된 응답이 최신(fresh)이기 때문에 레일즈가 렌더링 프로세스를 진행할 이유가 없다. ActiveRecord를 통해 post를 로드하고 이전 요청 이후 변경되었는지 확인할 수 있다.

요청은 약 33ms에서 2ms로 줄어들었다. 대략 94%의 성능 향상이다. 게다가 렌더링 프로세스의 생략 뿐만 아니라, 반환할 본문이 없기 때문에 응답도 훨씬 가볍다.

이 사소한 예에서는 그다지 많지 않을 수도 있지만, 렌더링 과정에서 복잡한 로직을 많이 사용하는 애플리케이션의 경우 많은 시간을 절약 할 수 있다.

또한 이러한 요청은 Rack::Cache 와 같은 공개 캐시(public cache) 앞에 놓여 사용자의 브라우저에서만 사용하는 전용 캐시(private cache)와는 달리 자원에 대한 공통 캐시(common cache)를 공유 할 수 있다.

더 많은 정보

fresh_when 은 제약이 있다.

사용자별 콘텐츠는 처리 할 수 ​​없다. 사용자별로 다른 내용을 표시하면 (헤더에 이름이 있어도) 내용을 캐시 할 수 없다.

또 다른 문제는 캐시가 사용자간에 공유되지 않는다. 성능 향상은 단일 사용자가 동일한 게시물을 반복해서 볼 때만 유효하다.

마지막으로, 컨트롤러에서 단순한 render 호출로 제한된다. render json: 또는 render xml: 같이 더 커스터마이징 된 것을 원한다면 어떻게 해야할까? 다음에는 배운 내용을 바탕으로 이러한 한계를 극복하고 캐싱을 더욱 향상시킬 수 있는 내용을 다루겠다.

더 읽을거리

성능 향상을 위해 Rails JSON API를 평가하는 방법 배우기
(Learn How to Evaluate Your Rails JSON API for Performance Improvements)

“Origin Pull” CDN이 무엇인지, 그리고 그것을 DNS to CDN to Origin 와 레일즈에서 사용하는 법 배우기
(Learn about “Origin Pull” CDNs and how to use them in Rails in DNS to CDN to Origin)


npmachine

a mediocre engineer