Rails 애플리케이션이 요청을 받으면 컨트롤러는 일반적으로 모델에 필요한 데이터를 조회하도록 지시합니다. 모델은 데이터베이스에서 데이터를 가져와 컨트롤러에 전달하고, 컨트롤러는 최종적으로 사람이 읽기 좋은 형태로 데이터를 표현하는 뷰를 렌더링합니다.
하지만 어떤 상황에서는 뷰를 렌더링하는 작업이 상당히 무겁습니다. 특히 스토어의 모든 상품 목록처럼 많은 양의 데이터를 한 화면에 표시해야 하는 경우가 대표적입니다. 이럴 때 반환되는 뷰의 일부를 캐싱하면 응답 속도를 크게 개선할 수 있으며, 데이터가 자주 변경되지 않는 경우 그 효과는 더욱 두드러집니다.
👋 이 글이 마음에 드셨다면 Ruby(Rails 포함) 성능 관련 글도 더 많이 준비되어 있습니다. Ruby 성능 모니터링 체크리스트도 함께 확인해 보세요.
로컬에서 캐싱 테스트하기
개발 환경에서는 기본적으로 캐싱이 비활성화되어 있습니다. 항상 애플리케이션의 최신 응답을 받을 수 있도록 하기 위함입니다. 따라서 로컬에서 캐싱을 테스트하려면 개발 환경 설정에서 캐싱을 직접 활성화해야 합니다.
Rails 5에서는 명령줄에서 간단하게 캐싱을 임시로 켤 수 있습니다. 이때 메모리 스토어가 사용되며, 캐시된 프래그먼트는 웹 서버의 Ruby 프로세스 메모리에 저장됩니다.
$ rails dev:cache
Development mode is now being cached.
같은 명령어를 다시 실행하면 캐싱이 다시 꺼집니다.
프래그먼트 캐싱
한 페이지에 스토어의 모든 상품을 표시하는 페이지가 있다고 가정해 보겠습니다. 이를 위해 상품 목록을 보여주는 index 뷰를 사용합니다.
# app/views/products/index.html.erb
<table>
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>Image url</th>
<th>Price</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<% @products.each do |product| %>
<%= render product %>
<% end %>
</tbody>
</table>각 상품마다 _product.html.erb 파셜(partial)이 렌더링되며, 이 파셜은 상품 정보가 담긴 테이블 행을 출력하는 역할을 담당합니다.
# app/views/products/_product.html.erb
<tr>
<td><%= product.title %></td>
<td><%= product.description %></td>
<td><%= product.image_url %></td>
<td><%= product.price %></td>
<td><%= link_to 'Show', product %></td>
<td><%= link_to 'Edit', edit_product_path(product) %></td>
<td><%= link_to 'Destroy', product, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>데이터베이스에 상품 25개가 있는 상태에서 개발 모드로 페이지를 요청하면 약 300밀리초가 걸립니다. 이 시간 대부분은 파셜을 렌더링하는 데 소모됩니다.
물론 프로덕션 환경에서는 에셋 번들링, 감소된 로깅, 더 빠른 웹 서버 덕분에 응답 속도가 더 나올 가능성이 높습니다. 하지만 개발 환경의 수치가 정확하지 않더라도, 요청 과정에서 어느 부분이 가장 느린지 파악하는 데는 충분히 유용합니다.
Started GET "/products" for ::1 at 2018-03-13 12:16:08 +0100
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.4ms) SELECT "products".* FROM "products"
Rendered products/_product.html.erb (1.4ms)
Rendered products/_product.html.erb (0.4ms)
Rendered products/_product.html.erb (0.4ms)
Rendered products/_product.html.erb (0.3ms)
Rendered products/_product.html.erb (0.5ms)
Rendered products/_product.html.erb (2.0ms)
Rendered products/_product.html.erb (0.9ms)
Rendered products/_product.html.erb (0.4ms)
Rendered products/_product.html.erb (0.5ms)
Rendered products/_product.html.erb (0.5ms)
Rendered products/_product.html.erb (0.6ms)
Rendered products/_product.html.erb (0.6ms)
Rendered products/_product.html.erb (0.6ms)
Rendered products/_product.html.erb (0.6ms)
Rendered products/_product.html.erb (0.7ms)
Rendered products/_product.html.erb (0.6ms)
Rendered products/_product.html.erb (0.7ms)
Rendered products/_product.html.erb (0.6ms)
Rendered products/_product.html.erb (0.5ms)
Rendered products/_product.html.erb (0.7ms)
Rendered products/_product.html.erb (0.6ms)
Rendered products/_product.html.erb (0.9ms)
Rendered products/_product.html.erb (0.6ms)
Rendered products/_product.html.erb (0.5ms)
Rendered products/_product.html.erb (0.6ms)
Rendered products/index.html.erb within layouts/application (257.5ms)
Completed 200 OK in 295ms(Views: 290.4ms | ActiveRecord: 0.4ms)
이 많은 파셜을 매번 렌더링하는 데 드는 시간을 절약하기 위해 Rails에 내장된 프래그먼트 캐싱(fragment caching)을 사용할 수 있습니다. 프래그먼트 캐싱은 렌더링된 뷰의 일부를 프래그먼트 형태로 저장해 두었다가, 이후 요청부터는 다시 렌더링하지 않고 미리 저장해 둔 프래그먼트를 재사용하는 방식입니다.
프래그먼트를 캐싱하려면 cache 헬퍼를 사용해 해당 부분을 블록으로 감싸면 됩니다.
<table>
# ...
<tbody>
<% @products.each do |product| %>
<% cache(product) do %>
<%= render product %>
<% end %>
<% end %>
</tbody>
</table>캐싱이 실제로 응답 속도를 개선했는지 확인하기 위해 페이지를 두 번 요청해 보겠습니다. 두 번째 요청은 뷰의 각 상품이 이미 렌더링되어 캐시에 저장되어 있기 때문에 훨씬 빠르게 처리되어야 합니다.
Started GET "/products" for ::1 at 2018-03-13 12:17:29 +0100
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.4ms) SELECT "products".* FROM "products"
Rendered products/index.html.erb within layouts/application (21.2ms)
Completed 200 OK in 55ms (Views: 50.8ms | ActiveRecord: 0.4ms)
성공입니다! 두 번째 요청은 첫 번째 요청보다 5배 이상 빨랐습니다. 로그에 파셜 렌더링 기록이 없는 이유는 파셜이 캐시에서 바로 불려왔기 때문입니다.
캐시 만료 처리하기
앞선 예제에서 cache 헬퍼를 호출할 때 product 객체를 캐시 의존성(cache dependency)으로 전달했습니다. 이를 통해 헬퍼는 캐시된 프래그먼트의 내용이 해당 product 객체에 의존한다는 사실을 인식하게 됩니다.
내부적으로 product 객체에는 #cache_key 메서드가 있으며, 이 메서드를 통해 캐시 프래그먼트의 키가 생성됩니다. 전체 프래그먼트의 키는 대략 다음과 같은 형태입니다.
views/products/42-20180302103130041320/75dda06d36880e8b0ae6cac0a44fb56d
이 캐시 키는 몇 가지 요소로 구성됩니다.
- "views/products"는 캐시 클래스(class)입니다.
42는 상품의 ID입니다.20180302103130041320는 상품의 updated_at 타임스탬프입니다.75dda06d36880e8b0ae6cac0a44fb56d는 템플릿 트리의 다이제스트(digest)입니다.
상품이 업데이트되거나 템플릿 내용이 변경되면 이 키도 함께 변경됩니다. 키가 변경되면 캐시 미스(cache miss)가 발생하고, 프래그먼트가 다시 렌더링된 후 새 프래그먼트가 저장됩니다. 덕분에 구성 요소가 변경되더라도 프래그먼트는 항상 최신 상태로 유지됩니다.
프래그먼트 캐싱 그 이상
이번 예제에서 살펴본 프래그먼트 캐싱만으로도 어느 정도 속도 향상을 얻을 수 있지만, 캐싱의 세계는 여기서 끝이 아닙니다. Russian doll 캐싱 같은 고급 전략이나 데이터베이스 쿼리 결과 캐싱 같은 저수준 기법을 활용하면 훨씬 더 큰 성능 향상을 달성할 수 있습니다.
이런 주제들은 AppSignal Academy의 다음 에피소드에서 자세히 다룰 예정입니다. 특별히 배우고 싶은 내용이 있다면 @AppSignal로 알려주세요. 이 글에 대한 피드백이나 추가로 다뤄주었으면 하는 주제가 있다면 언제든지 말씀해 주세요.