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

Rails 뷰 캐싱 완벽 가이드: 알아야 할 모든 것

캐싱(Caching)은 어떤 코드의 실행 결과를 저장해 두었다가 나중에 빠르게 불러와 사용할 수 있도록 하는 일반적인 기법입니다. 예를 들어 자주 변경되지 않는 데이터를 얻기 위해 데이터베이스에 반복적으로 접근하는 것을 방지할 수 있습니다. 캐싱의 기본 개념은 모든 유형에서 동일하지만, Rails는 무엇을 캐싱하려는지에 따라 서로 다른 도구들을 제공합니다.

Rails 개발자에게 흔히 사용되는 캐싱 형태로는 메모이제이션(memoization), 로우 레벨 캐싱(low-level caching, 이 시리즈의 이전 글에서 다룸), 그리고 이번 글에서 다룰 뷰 캐싱(view caching)이 있습니다.

Ruby on Rails가 뷰를 렌더링하는 방식

먼저 약간 헷갈릴 수 있는 용어부터 짚고 넘어가겠습니다. Rails 커뮤니티에서 말하는 "뷰(views)"란 app/views 디렉터리에 위치한 파일들을 의미합니다. 일반적으로 .html.erb 파일이지만, 그 외에도 순수 .html, .js.erb, 또는 slim과 haml 같은 다른 전처리기를 사용하는 파일들도 있습니다. 많은 웹 프레임워크에서는 이러한 파일을 "템플릿(template)"이라고 부르는데, 실제 용도를 고려하면 이쪽이 더 적절한 표현이라고 생각합니다.

Rails 애플리케이션이 GET 요청을 받으면 해당 요청은 특정 컨트롤러 액션으로 라우팅됩니다. 예를 들어 UsersController#index처럼요. 이 액션은 필요한 정보를 데이터베이스에서 수집하여 뷰/템플릿 파일을 렌더링하는 데 전달하는 역할을 담당합니다. 바로 이 지점부터 우리는 "뷰 계층(view layer)"에 진입하게 됩니다.

일반적으로 뷰(템플릿)는 하드코딩된 HTML 마크업과 동적인 Ruby 코드가 섞여 있는 형태입니다:

#app/views/users/index.html.erb

<div class='user-list'>
  <% @users.each do |user| %>
    <div class='user-name'><%= user.name %></div>
  <% end %>
</div>

파일 안의 Ruby 코드는 뷰를 렌더링하기 위해 반드시 실행되어야 합니다(erb의 경우 <% %> 태그 안의 모든 내용). 페이지를 100번 새로고침하면 @users.each...도 100번 실행됩니다. 포함된 파셜(partial)도 마찬가지입니다. 프로세서는 파셜 html.erb 파일을 로드하고, 내부의 모든 Ruby 코드를 실행한 뒤, 그 결과를 하나의 HTML 파일로 합쳐 요청자에게 반환해야 합니다.

뷰가 느려지는 원인

개발 중에 페이지를 열어보면 Rails가 아래와 같은 형태의 수많은 로그 정보를 출력하는 것을 본 적이 있을 것입니다:

Processing by PagesController#home as HTML
  Rendering layouts/application.html.erb
  Rendering pages/home.html.erb within layouts/application
  Rendered pages/home.html.erb within layouts/application (Duration: 4.0ms | Allocations: 1169)
  Rendered layouts/application.html.erb (Duration: 35.9ms | Allocations: 8587)
Completed 200 OK in 68ms (Views: 40.0ms | ActiveRecord: 15.7ms | Allocations: 14307)

이 단계에서 가장 유용한 정보는 마지막 줄입니다. 시간을 왼쪽에서 오른쪽으로 따라가 보면, Rails가 브라우저에 응답을 반환하는 데 총 68ms가 걸렸으며, 그중 40ms는 erb 파일 렌더링에, 15.7ms는 ActiveRecord 쿼리 처리에 소요되었음을 알 수 있습니다.

사소한 예시이긴 하지만, 뷰 계층의 캐싱을 고려해야 하는 이유를 잘 보여줍니다. ActiveRecord 쿼리를 마법처럼 즉시 처리할 수 있다 해도, erb 렌더링에는 두 배 이상의 시간이 소요됩니다.

뷰 렌더링이 느려지는 데에는 몇 가지 이유가 있습니다. 예를 들어 뷰 안에서 비용이 큰 DB 쿼리를 호출하거나, 루프 안에서 많은 작업을 수행하는 경우입니다. 제가 가장 자주 목격한 상황은 단순히 많은 수의 파셜을 렌더링하는 경우, 특히 여러 단계로 중첩된 경우였습니다.

이메일 수신함을 상상해 보세요. 개별 행(row)을 담당하는 파셜이 있을 수 있습니다:

# app/views/emails/_email.html.erb

<li class="email-line">
  <div class="email-sender">
    <%= email.from_address %>
  </div>
  <div class="email-subject">
    <%= email.subject %>
  </div>
</div>

그리고 메인 수신함 페이지에서 각 이메일마다 이 파셜을 렌더링합니다:

# app/views/emails/index.html.erb

...
<% @emails.each do |email| %>
  <%= render email %>
<% end %>

수신함에 메시지가 100통 있다면 _email.html.erb 파셜을 100번 렌더링하게 됩니다. 이 정도의 간단한 예시라면 크게 문제가 되지 않습니다. 제 머신에서는 전체 인덱스를 렌더링하는 데 겨우 15ms밖에 걸리지 않았습니다. 물론 실제 환경의 예시는 훨씬 복잡하고, 파셜 안에 또 다른 파셜이 포함될 수도 있습니다. 렌더링 시간이 늘어나기는 어렵지 않습니다. _email 파셜 하나를 렌더링하는 데 1~2ms만 걸린다 해도, 컬렉션 전체를 처리하려면 100~200ms가 필요합니다.

다행히 Rails에는 이 문제를 손쉽게 해결할 수 있는 캐싱 기능이 내장되어 있습니다. _email 파셜만 캐싱하든, index 페이지만 캐싱하든, 아니면 둘 다 캐싱하든 말이죠.

뷰 캐싱이란?

Ruby on Rails의 뷰 캐싱이란 뷰가 생성한 HTML을 저장해 두었다가 나중에 재사용하는 것을 의미합니다. Rails는 이를 파일시스템에 기록하거나 메모리에 보관하는 기능을 지원하지만, 프로덕션 환경에서는 Memcached나 Redis 같은 독립적인 캐싱 서버를 사용하는 것이 거의 필수적입니다. Rails의 memory_store는 개발 환경에서는 유용하지만 여러 프로세스 간에 공유할 수 없습니다(예: 여러 서버/dyno 또는 unicorn 같은 포킹 서버). 마찬가지로 file_store도 해당 서버에 국한되므로 여러 장비 간 공유가 불가능하며, 만료된 항목을 자동으로 삭제하지 않기 때문에 서버 디스크가 가득 차지 않도록 주기적으로 Rails.cache.clear를 호출해 주어야 합니다.

캐시 스토어는 환경 설정 파일(예: config/environments/production.rb)에서 활성화할 수 있습니다:

  # memory store는 개발 중 테스트에는 편리하지만
  # 프로덕션에는 권장되지 않습니다
  config.cache_store = :memory_store

기본 설치 상태에서 development.rb에는 이미 로컬에서 캐싱을 손쉽게 켜고 끌 수 있는 설정이 포함되어 있습니다. rails dev:cache 명령어를 실행하기만 하면 캐싱을 토글할 수 있습니다.

Rails에서 뷰를 캐싱하는 것은 놀랄 만큼 간단합니다. 성능 차이를 보여드리기 위해 인위적인 지연을 만드는 sleep(5)를 사용해 보겠습니다:

<% cache do %>
  <div>
    <p>Hi <%= @user.name %>
    <% sleep(5) %>
  </div>
<% end %>

이 뷰를 처음 렌더링할 때는 예상대로 5초가 걸립니다. 하지만 두 번째 로딩부터는 몇 밀리초 만에 완료됩니다. cache do 블록 안의 모든 내용이 캐시에서 가져오기 때문입니다.

예제로 살펴보는 뷰 캐싱 적용

작은 예제 뷰를 통해 캐싱 옵션들을 하나씩 살펴보겠습니다. 이 뷰가 실제로 성능 문제를 일으키고 있다고 가정합니다:

# app/views/user/show.html.erb
<div>
  Hi <%= @user.name %>!
<div>

<div>
  Here's your list of posts,
  you've written
  <%= @user.posts.count %> so far
  <% @user.posts.each do |post|
    <div><%= post.body %></div>
  <% end %>
</div>

<% sleep(5) #artificial delay %>

인위적인 5초 지연과 함께 작업할 기본 골격이 준비되었습니다. 먼저 앞서 설명한 대로 show.html.erb 파일 전체를 cache do 블록으로 감쌀 수 있습니다. 이제 캐시가 한 번 생성되면 빠른 렌더링 속도를 누릴 수 있습니다. 하지만 이 방식에는 금방 문제점이 드러납니다.

첫째, 사용자가 이름을 변경하면 어떻게 될까요? 우리는 Rails에게 캐시된 페이지를 언제 만료시켜야 하는지 알려주지 않았기 때문에, 사용자는 업데이트된 버전을 영원히 보지 못할 수도 있습니다. 간단한 해결책은 @user 객체를 cache 메서드에 전달하는 것입니다:

<% cache(@user) do %>
<div>
  Hi <%= @user.name %>!
</div>
...
<% sleep(5) #artificial delay %>
<% end %>

이 시리즈의 로우 레벨 캐싱 편에서 캐시 키에 대한 세부 사항을 다루었으므로 여기서 반복하지 않겠습니다. 지금은 모델을 cache()에 전달하면 해당 모델의 updated_at 속성을 사용해 캐시 조회용 키를 생성한다는 점만 알면 충분합니다. 즉, @user가 업데이트될 때마다 캐시된 페이지가 만료되고 Rails가 HTML을 다시 렌더링합니다.

사용자가 이름을 변경하는 경우는 처리했지만, 게시글(posts)은 어떨까요? 기존 게시글을 수정하거나 새 게시글을 작성해도 Userupdated_at 타임스탬프는 변경되지 않으므로 캐시된 페이지가 만료되지 않습니다. 게다가 사용자가 이름을 변경하면 게시글이 변경되지 않았음에도 모든 게시글을 다시 렌더링하게 됩니다. 이 두 문제를 모두 해결하려면 "러시안 돌 캐싱(Russian doll caching)", 즉 캐시 안에 캐시를 넣는 방식을 사용할 수 있습니다:

<% cache(@user) do %>
  <div>
    Hi <%= @user.name %>!
  <div>

  <div>
    Here's your list of posts,
    you've written
    <%= @user.posts.count %> so far<br>
    <% @user.posts.each do |post| %>
      <% cache(post) do %>
        <div><%= post.body %></div>
      <% end %>
    <% end %>
  </div>

  <% sleep(5) #artificial delay %>
<% end %>

이제 각각의 post 렌더링 결과가 개별적으로 캐싱됩니다(실무에서는 아마 파셜이겠죠). 따라서 @user가 업데이트되더라도 게시글을 다시 렌더링할 필요 없이 캐시된 값을 그대로 사용할 수 있습니다. 하지만 아직 한 가지 문제가 남아 있습니다. post가 변경되어도 @user.updated_at은 변하지 않기 때문에 cache(@user) do 블록 내부가 실행되지 않아 업데이트 내용이 반영되지 않습니다.

이 문제를 해결하려면 Post 모델에 touch: true를 추가해야 합니다:

class Post < ApplicationRecord
  belongs_to :user, touch: true
end

여기에 touch: true를 추가하면 ActiveRecord에게 게시글이 업데이트될 때마다 해당 게시글이 "속한(belongs to)" 사용자의 updated_at 타임스탬프도 함께 업데이트하도록 지시하는 것입니다.

덧붙이자면, Rails는 파셜 컬렉션을 렌더링하는 경우가 매우 흔하기 때문에 이를 위한 전용 헬퍼도 제공합니다:

  <%= render partial: 'posts/post',
       collection: @posts, cached: true %>

이것은 다음 코드와 기능적으로 동일합니다:

<% @posts.each do |post| %>
  <% cache(post) do %>
    <%= render post %>
  <% end %>
<% end %>

render partial: ... cached: true 형태는 코드가 더 간결할 뿐만 아니라 추가적인 효율성도 제공합니다. Rails는 컬렉션의 각 항목마다 캐시 스토어에 접근하는 대신 멀티겟(multiget, 한 번의 왕복으로 여러 키/값 쌍을 읽는 방식)을 수행할 수 있기 때문입니다.

동적 페이지 콘텐츠

어떤 페이지들은 페이지의 나머지 부분보다 훨씬 빠르게 변하는 '동적' 콘텐츠를 일부 포함하는 경우가 흔합니다. 특히 활동/뉴스 피드가 있을 수 있는 홈페이지나 대시보드에서 그렇습니다. 이런 콘텐츠를 캐시된 페이지에 포함하면 캐시를 자주 무효화해야 할 수 있고, 이는 캐싱으로 얻을 수 있는 이점을 스스로 제한하는 결과를 낳습니다.

간단한 예로, 뷰에 오늘 날짜를 추가해 보겠습니다:

<% cache(@user) do %>
  <div>
    Hi <%= @user.name %>,
    hope you're having a great
    <%= Date.today.strftime("%A") %>!
  <div>

  ...
<% end %>

매일 캐시를 무효화할 수도 있지만, 당연한 이유로 그다지 실용적이지 않습니다. 한 가지 옵션은 플레이스홀더 값(또는 그냥 빈 <span>)을 사용하고 JavaScript로 채우는 것입니다. 이런 접근 방식은 흔히 "JavaScript 스프링클(javascript sprinkles)"이라고 불리며, Rails 코어 코드의 상당 부분이 개발된 Basecamp에서 선호하는 방식이기도 합니다. 결과는 다음과 같습니다:

<% cache(@user) do %>
  <div>
    Hi <%= @user.name %>,
    hope you're having a great
    <span id='greeting-day-name'>Day</span>!
  <div>

  ...
<% end %>

<script>
 // assuming you're using vanilla JS with turbolinks
 document.addEventListener(
   "turbolinks:load", function() {
   weekdays = new Array('Sunday', 'Monday',
     'Tuesday', 'Wednesday', 'Thursday',
     'Friday', 'Saturday');
     today = weekdays[new Date().getDay()];
   document.getElementById("greeting-day-name").textContent=today;
 });
</script>

또 다른 접근 방식은 뷰의 일부분만 캐싱하는 것입니다. 이 예제에서 인사말은 페이지 상단에 있으므로, 그 뒤에 오는 내용만 캐싱하는 것은 비교적 간단합니다:

<div>
  Hi <%= @user.name %>,
  hope you're having a great
  <%= Date.today.strftime("%A") %>!
<div>

<% cache(@user) do %>
  ...
<% end %>

물론 실제 환경의 레이아웃에서는 이렇게 단순하지 않은 경우가 많으므로, 캐싱을 어디에 어떻게 적용할지 신중하게 고민해야 합니다.

주의 사항

뷰 캐싱을 성능 문제에 대한 빠르고 쉬운 만능 해결책으로 여기기 쉽습니다. 실제로 Rails는 깊게 중첩된 경우에도 뷰와 파셜을 캐싱하는 것을 믿을 수 없을 만큼 쉽게 만들어 줍니다. 이 시리즈의 첫 번째 글에서 캐싱을 시스템에 도입할 때 발생할 수 있는 문제들을 설명했지만, 뷰 레벨 캐싱에서는 이러한 문제가 특히 두드러집니다.

그 이유는 뷰가 본질적으로 시스템의 기반 데이터와 더 많은 상호작용을 하는 경향이 있기 때문입니다. Rails에서 메모이제이션이나 로우 레벨 캐싱을 적용할 때는 보통 현재 작업 중인 파일 밖을 살펴볼 필요 없이 캐시된 값을 언제, 왜 갱신해야 하는지 판단할 수 있습니다. 반면 뷰는 여러 다른 모델을 호출할 수 있으며, 의도적인 설계가 없다면 어떤 모델이 뷰의 어떤 부분을 언제 다시 렌더링하게 만들어야 하는지 파악하기 어려울 수 있습니다.

로우 레벨 캐싱과 마찬가지로 가장 좋은 조언은 어디에, 언제 사용할지 전략적으로 접근하는 것입니다. 허용 가능한 수준의 성능을 달성하기 위해 최대한 적은 캐싱을, 최소한의 장소에만 적용하세요.

Rails의 기본 캐싱

지금까지 이 캐싱 시리즈에서는 수동으로 캐싱하는 방법들을 다루었지만, 별도의 설정 없이도 ActiveRecord는 이미 내부적으로 쿼리 속도를 높이거나(혹은 아예 생략하도록) 몇 가지 캐싱을 수행하고 있습니다. 이 시리즈의 다음 글에서는 ActiveRecord가 어떤 것들을 캐싱해 주는지 살펴보고, 약간의 작업만으로 "카운터 캐시(counter cache)"를 유지하여 thing.children.size 같은 코드가 최신 카운트를 얻기 위해 데이터베이스에 접근할 필요조차 없도록 만드는 방법을 알아보겠습니다.