이번 글에서는 Rails에 내장된 프래그먼트 캐싱(fragment caching)을 한 단계 더 발전시킬 수 있는 기법인 러시아 인형 캐싱(Russian Doll Caching)에 대해 자세히 알아보겠습니다.
프래그먼트 캐싱이란?
Rails의 내장 프래그먼트 캐싱을 사용하면 렌더링된 뷰의 일부가 '뷰 프래그먼트' 형태로 저장되고, 동일한 요청이 다시 들어오면 이를 재사용합니다. 캐시된 프래그먼트는 stale(만료) 상태가 될 때까지 계속 재사용됩니다. 여기서 stale란 프래그먼트 생성 이후 화면에 표시되는 데이터가 변경되어 더 이상 유효하지 않은 상태를 의미합니다.
👋 캐싱뿐 아니라 전반적인 성능 최적화에 관심이 있다면 Ruby 성능 모니터링 체크리스트도 확인해 보세요.
특히 복잡한 뷰나 다수의 파셜(partial)을 렌더링하는 뷰에서는 프래그먼트 캐싱만으로도 눈에 띄는 속도 향상을 얻을 수 있습니다. 하지만 여기서 한 걸음 더 나아가, 러시아 인형 캐싱이라 불리는 방식을 적용하면 성능을 더욱 개선할 수 있습니다.
러시아 인형 캐싱은 이름 그대로 마트료시카 인형처럼 뷰 프래그먼트를 서로 안에 중첩시키는 방식입니다. 캐시 조각을 작은 단위로 나누면, 중첩된 프래그먼트 중 하나만 변경되었을 때에도 외부 캐시를 훨씬 빠르게 재구성할 수 있습니다.
러시아 인형 캐싱 예제
예제로 상품을 판매하는 스토어를 가정해 보겠습니다. 각 상품(product)은 여러 개의 변형(variant)을 가질 수 있으며, 예컨대 하나의 아이템을 여러 색상으로 판매하는 경우가 이에 해당합니다. 상품 목록(index) 페이지에는 판매 중인 모든 상품과 각 상품의 변형들을 함께 표시할 것입니다.
상품 목록 페이지에서는 각 상품 파셜을 cache 블록으로 감쌌습니다. 캐시 키(cache key)는 product 객체로부터 생성되며, 이 키가 캐시된 프래그먼트를 무효화하는 데 사용됩니다. 캐시 키는 객체의 id, updated_at 시각, 그리고 템플릿 트리의 digest로 구성되므로, 객체가 변경되거나 템플릿 내용이 수정되면 자동으로 만료(stale) 처리됩니다.
# app/views/products/index.html.erb
<h1>Products</h1>
<% @products.each do |product| %>
<% cache product do %>
<%= render product %>
<% end %>
<% end %>
팁: 설명을 위해 코드를 전부 작성했지만, 실제로는 <%= render partial: 'products/product', collection: @products, cached: true %>처럼 한 줄로 컬렉션을 캐시하며 렌더링할 수도 있습니다.
상품 파셜에서는 해당 상품의 각 변형(variant)마다 하나의 행을 렌더링합니다.
# app/views/products/_product.html.erb
<article>
<h1><%= product.title %></h1>
<ul>
<% product.variants.each do |variant| %>
<%= render variant %>
<% end %>
</ul>
</article>
캐시 무효화(Cache Invalidation)
Rails 프래그먼트 캐싱의 캐시 키 덕분에 캐시 무효화가 한결 쉬워졌지만, 컴퓨터 과학의 유명한 '가장 어려운 두 가지' 중 하나인 캐시 검증 문제에서 완전히 자유로울 수는 없습니다.
위 예제에서 우리는 상품의 변형 목록을 포함하는 상품 파셜을 캐시했습니다. 그런데 캐시 키에는 변형에 관한 어떤 정보도 포함되어 있지 않기 때문에, 상품 자체가 변경되지 않는 한 새로 추가된 변형은 화면에 나타나지 않습니다.
이 문제를 해결하려면 변형에 변경이 생길 때마다 상품 역시 변경되도록 만들어야 합니다. 즉, 변형이 수정될 때 상품의 updated_at 속성을 함께 갱신하면 됩니다. 이런 요구 사항이 매우 흔하기 때문에 belongs_to(및 ActiveModel의 다른 연관 메서드)에는 :touch 옵션이 제공되며, 이를 사용하면 부모 객체의 updated_at을 자동으로 갱신해 줍니다.
class Variant < ApplicationRecord
belongs_to :product, touch: true
end
중첩된 프래그먼트(Nested Fragments)
이제 변형이 변경될 때 상품 프래그먼트도 갱신되도록 확실히 했으니, 변형 자체도 캐시해 보겠습니다. 앞서와 마찬가지로 각 변형 주위에 cache 블록을 추가합니다.
<article>
<h1><%= product.title %></h1>
<ul>
<% product.variants.each do |variant| %>
<% cache(variant) do %>
<%= render variant %>
<% end %>
<% end %>
</ul>
</article>
팁: 여기서도 전체 블록을 작성했지만, <%= render partial: 'variants/variant', collection: product.variants, cached: true %>를 사용하면 더 간결하게 처리할 수 있습니다.
캐시가 비어 있는 상태(rake tmp:cache:clear 명령으로 캐시를 비울 수 있습니다)에서 첫 번째 요청이 들어오면 각 상품 파셜이 렌더링됩니다.
이후 페이지를 다시 요청하면(개발 환경에서는 rails dev:cache 명령으로 캐싱을 활성화하는 것을 잊지 마세요), 각 상품 파셜이 캐시에 저장되고 두 번째 요청부터는 캐시된 프래그먼트가 반환됩니다.
Started GET "/products" for 127.0.0.1 at 2018-03-30 14:51:38 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.2ms) SELECT "products".* FROM "products"
Variant Load (0.9ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51)
Rendered variants/_variant.html.erb (0.5ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.0ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered products/_product.html.erb (44.8ms) [cache miss]
...
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered products/_product.html.erb (46.2ms) [cache miss]
Rendered products/index.html.erb within layouts/application (1378.6ms)
Completed 200 OK in 1414ms (Views: 1410.5ms | ActiveRecord: 1.1ms)
Started GET "/products" for 127.0.0.1 at 2018-03-30 14:51:41 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.3ms) SELECT "products".* FROM "products"
Variant Load (12.7ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51)
Rendered products/index.html.erb within layouts/application (48.1ms)
Completed 200 OK in 76ms (Views: 59.0ms | ActiveRecord: 13.0ms)
로그를 보면 첫 요청은 1414ms가 걸린 반면, 캐시가 적용된 두 번째 요청은 76ms로 단축된 것을 확인할 수 있습니다.
러시아 인형 캐싱의 마법
러시아 인형 캐싱의 진짜 마법은 변형 중 하나를 변경했을 때 드러납니다. 변형이 변경된 후 목록 페이지를 다시 요청하면, 해당 상품의 updated_at 속성이 바뀌었기 때문에 캐시된 상품 프래그먼트는 다시 렌더링됩니다.
상품 파셜은 상품의 모든 변형을 포함합니다. 방금 변경한 변형의 캐시 프래그먼트는 만료되어 재생성이 필요하지만, 나머지 변형들은 변경되지 않았으므로 캐시된 프래그먼트가 그대로 재사용됩니다. 로그에서 변형 파셜과 상품 파셜이 각각 한 번만 렌더링된 것을 확인할 수 있습니다.
Started GET "/products" for 127.0.0.1 at 2018-03-30 14:52:04 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.3ms) SELECT "products".* FROM "products"
Variant Load (1.2ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51)
Rendered variants/_variant.html.erb (0.5ms)
Rendered products/_product.html.erb (13.3ms) [cache miss]
Rendered products/index.html.erb within layouts/application (45.9ms)
Completed 200 OK in 78ms (Views: 73.5ms | ActiveRecord: 1.5ms)
최종 결과
이처럼 캐시 프래그먼트를 중첩해서 사용하면, 캐시가 완전히 비어 있는 경우가 아니라면 뷰 전체를 처음부터 렌더링하는 일은 거의 없습니다. 데이터가 변경되더라도 페이지 대부분은 캐시에서 바로 제공됩니다.
이 글이 여러분의 애플리케이션 성능 개선에 새로운 통찰을 주기를 바랍니다. 캐싱 관련 글이 도움이 되었다면, ActiveRecord의 카운터 캐시(Counter Cache), Rails의 캐시 스토어(Cache Stores), 컬렉션 캐싱(collection caching), 그리고 앞서 언급한 프래그먼트 캐싱에 대한 글들도 함께 읽어보세요.