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

Rails 컬렉션 캐싱으로 뷰 렌더링 속도 개선하기

이전에 AppSignal Academy에서 Rails의 프래그먼트 캐싱(Fragment Caching)에 대해 살펴본 적이 있습니다. 뷰를 작은 조각 단위로 캐싱하여 성능을 크게 끌어올릴 수 있는 기법인데요, 파셜(Partial)을 캐싱하면 다른 뷰에서도 추가 비용 없이 재사용할 수 있다는 장점까지 있습니다.

하지만 이 방식은 소규모 컬렉션에서만 잘 작동합니다. 컬렉션 규모가 커지면 곧바로 성능 문제가 드러납니다. 이번 글에서는 Rails 컬렉션 캐싱(Collection Caching)의 동작 원리와, 이를 활용해 대용량 컬렉션의 렌더링 속도를 개선하는 방법을 단계별로 알아보겠습니다.

👋 이 글이 유익했다면, 저희가 작성한 Ruby(Rails) 성능 관련 글들도 확인해 보세요. Ruby 성능 모니터링 체크리스트도 함께 참고하시길 권합니다.

컬렉션 렌더링의 시작

먼저 블로그 인덱스 페이지를 위해 최근 100개의 게시물을 불러오는 간단한 컨트롤러부터 살펴보겠습니다.

class PostsController < ApplicationController
  def index
    @posts = Post.all.order(:created_at => :desc).limit(100)
  end
end

뷰에서는 @posts 인스턴스 변수를 순회하며 각 게시물을 렌더링합니다.

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>
 
<div class="posts">
  <% @posts.each do |post| %>
    <div class="post">
      <h2><%= post.title %></h2>
      <small><%= post.author %></small>
 
      <div class="body">
        <%= post.body %>
      </div>
    </div>
  <% end %>
</div>

이 페이지를 요청하면 데이터베이스에서 게시물을 조회한 후 뷰가 렌더링됩니다. 뷰 계층에 32밀리초밖에 걸리지 않아 꽤 빠른 편입니다.

Started GET "/posts"
Processing by PostsController#index as HTML
  Rendering posts/index.html.erb within layouts/application
  Post Load (1.5ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
  ↳ app/views/posts/index.html.erb:4
  Rendered posts/index.html.erb within layouts/application (19.4ms)
Completed 200 OK in 37ms (Views: 32.4ms | ActiveRecord: 2.7ms)

파셜을 활용한 컬렉션 렌더링

이제 post 요소를 다른 뷰에서도 재사용하고 싶어졌다고 가정해 봅시다. 이를 위해 게시물 HTML 코드를 별도의 파셜로 분리합니다.

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>
 
<div class="posts">
  <% @posts.each do |post| %>
    <%= render post %>
  <% end %>
</div>
 
<!-- app/views/posts/_post.html.erb -->
<div class="post">
  <h2><%= post.title %></h2>
  <small><%= post.author %></small>
 
  <div class="body">
    <%= post.body %>
  </div>
</div>
Started GET "/posts"
Processing by PostsController#index as HTML
  Rendering posts/index.html.erb within layouts/application
  Post Load (1.2ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
  ↳ app/views/posts/index.html.erb:4
...
  Rendered posts/_post.html.erb (0.1ms)
  Rendered posts/_post.html.erb (0.1ms)
  Rendered posts/index.html.erb within layouts/application (205.4ms)
Completed 200 OK in 217ms (Views: 213.8ms | ActiveRecord: 1.7ms)

뷰 계층에 213밀리초가 소요되어 렌더링 시간이 크게 늘어난 것을 확인할 수 있습니다. 각 게시물마다 새로운 파일(파셜)을 로드하고, 컴파일하고, 렌더링해야 하기 때문입니다. 그렇다면 프래그먼트 캐싱으로 이 시간을 줄일 수 있을까요?

프래그먼트 캐싱 적용

프래그먼트 캐싱 방식에서는 뷰에서 render 호출 주변을 cache 헬퍼로 감쌉니다. 이렇게 하면 게시물마다 파셜의 렌더링 결과가 캐시 스토어에 저장됩니다.

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>
 
<div class="posts">
  <% @posts.each do |post| %>
    <%= cache post do %>
      <%= render post %>
    <% end %>
  <% end %>
</div>

첫 번째 요청은 크게 빨라지지 않습니다. 어차피 모든 파셜을 한 번은 렌더링해서 캐시 스토어에 저장해야 하기 때문입니다.

Started GET "/posts"
Processing by PostsController#index as HTML
  Rendering posts/index_with_partial_caching.html.erb within layouts/application
  Post Load (1.4ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
  ↳ app/views/posts/index.html.erb:4
...
Read fragment views/posts/index.1ms)
  Rendered posts/_post.html.erb (0.1ms)
Write fragment views/posts/index.1ms)
Read fragment views/posts/index.5ms)
  Rendered posts/_post.html.erb (0.1ms)
Write fragment views/posts/index.1ms)
  Rendered posts/index.html.erb within layouts/application (274.5ms)
Completed 200 OK in 286ms (Views: 281.4ms | ActiveRecord: 2.4ms)

반면 이후 요청에서는 뷰 소요 시간이 눈에 띄게 줄어듭니다. 286밀리초에서 78밀리초로 감소했습니다.

Started GET "/posts"
Processing by PostsController#index as HTML
  Rendering posts/index.html.erb within layouts/application
  Post Load (2.2ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
  ↳ app/views/posts/index.html.erb:4
...
Read fragment views/posts/index.1ms)
Read fragment views/posts/index.1ms)
  Rendered posts/index.html.erb within layouts/application (63.8ms)
Completed 200 OK in 78ms (Views: 75.5ms | ActiveRecord: 2.2ms)

그런데 흥미로운 점은, 이 결과조차 캐싱을 적용하지 않은 원래 코드보다 여전히 약 두 배 느리다는 사실입니다. 캐시 스토어에 접근하는 오버헤드가 생각보다 큽니다.

참고: 로그에서 "Read/Write fragment" 항목이 보이지 않는다면 개발 환경에서 프래그먼트 캐시 로깅을 활성화해야 합니다. Rails 5.1 이상에서는 기본값이 false로 설정되어 있습니다.

# config/environments/development.rb
config.action_controller.enable_fragment_cache_logging = true

컬렉션 캐싱으로 최적화하기

Rails 5에서는 컬렉션 캐싱을 더 빠르게 만들기 위한 대대적인 개선 작업이 이루어졌습니다. 이 개선 사항을 활용하려면 뷰 코드를 수정해야 합니다. cache 헬퍼를 직접 호출하는 대신, Rails에게 전체 컬렉션을 렌더링하면서 동시에 캐싱하도록 지시하는 방식입니다.

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>
 
<div class="posts">
  <%= render partial: :post, collection: @posts, cached: true %>
</div>

주의: render @collection, cached: true 축약형 문법은 이번에 소개한 캐싱 성능 개선 효과를 얻지 못하므로, 반드시 위와 같이 partial:collection: 옵션을 명시해야 합니다.

첫 번째 요청에서도 이미 뷰 계층 소요 시간이 크게 개선된 것을 볼 수 있습니다.

Started GET "/posts"
Processing by PostsController#index as HTML
  Rendering posts/index.html.erb within layouts/application
  Post Load (1.4ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
  ↳ app/views/posts/index.html.erb:4
  Rendered collection of posts/_post.html.erb [0 / 100 cache hits] (28.2ms)
  Rendered posts/index.html.erb within layouts/application (46.6ms)
Completed 200 OK in 64ms (Views: 59.9ms | ActiveRecord: 2.0ms)

이것이 가능한 이유는 Rails가 이제 게시물마다 개별적으로 파셜을 준비하는 것이 아니라, 전체 컬렉션에 사용될 파셜을 미리 한꺼번에 준비하기 때문입니다.

이후 요청에서는 성능이 더욱 향상됩니다.

Started GET "/posts"
Processing by PostsController#index as HTML
  Rendering posts/index.html.erb within layouts/application
  Post Load (1.3ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT ?  [["LIMIT", 100]]
  ↳ app/views/posts/index.html.erb:4
  Rendered collection of posts/_post.html.erb [100 / 100 cache hits] (19.2ms)
  Rendered posts/index.html.erb within layouts/application (26.5ms)
Completed 200 OK in 37ms (Views: 35.7ms | ActiveRecord: 1.3ms)

64밀리초에서 약 35밀리초로, 원래 코드와 거의 동등한 수준까지 도달했습니다. 이런 큰 폭의 속도 향상이 가능한 핵심은 Rails의 컬렉션 최적화에 있습니다. 프래그먼트 캐싱처럼 파셜마다 캐시 존재 여부를 하나씩 확인하는 대신, Rails는 컬렉션의 모든 캐시 키를 한 번에 조회하여 캐시 스토어 접근 비용을 크게 줄입니다.

또한 이 캐싱 헬퍼는 컬렉션 로깅이 깔끔하게 요약된다는 부가적인 장점도 있습니다. 첫 번째 요청에서는 캐시 키를 하나도 찾지 못했고([0 / 100 cache hits]), 두 번째 요청에서는 전부 캐시 히트가 발생했습니다([100 / 100 cache hits]).

심지어 데이터베이스의 일부 객체를 업데이트한 뒤에는 얼마나 많은 캐시 키가 만료(Stale)되었는지도 로그에서 바로 확인할 수 있습니다.

Rendered collection of posts/_post.html.erb [88 / 100 cache hits] (13.4ms)

정리

최적화된 컬렉션 렌더링과 캐싱을 활용하면 상당한 성능 향상을 얻을 수 있습니다. 특히 컬렉션 규모가 클수록 그 차이는 더욱 벌어집니다. 컬렉션 항목별로 개성화된 뷰가 필요한 경우가 아니라면, 이 최적화된 전략이 Rails 애플리케이션에서 가장 좋은 선택입니다. 실제로 AppSignal에서도 수천 건의 레코드를 렌더링하던 관리자 화면을 이 방법으로 크게 개선한 사례가 있습니다.

Rails 컬렉션 캐싱에 대해 궁금한 점이 있다면 @AppSignal로 언제든 문의해 주세요. 이 글에 대한 피드백이나 다뤄주었으면 하는 주제가 있다면 편하게 연락 주시기 바랍니다.