Rails의 프래그먼트 캐싱(fragment caching)은 페이지에서 캐시되는 비중이 클수록 더 큰 성능 향상을 제공합니다. 하지만 동적인 콘텐츠나 사용자별 콘텐츠가 많은 페이지에는 적용하기가 쉽지 않습니다. 이때 유용한 해결책이 바로 "자바스크립트 스프링클스(JavaScript Sprinkles)"입니다. 네덜란드식 빵 토핑인 하겔슬라흐(hagelslag)처럼, 페이지 전체를 캐시에서 바로 제공하고 사용자별 콘텐츠만 자바스크립트로 추가 요청해 얹어 주는 방식입니다.
프래그먼트 캐싱
프래그먼트 캐싱 같은 기법은 렌더링된 페이지의 일부 조각을 캐시에 저장해 Rails 애플리케이션의 응답 속도를 높입니다. 스마트한 캐시 키를 활용하면 뷰에 표시되는 데이터가 갱신될 때 해당 조각이 자동으로 무효화되므로, 항상 최신 상태를 유지할 수 있습니다.
캐시된 프래그먼트가 재사용될수록 작은 캐시로도 더 큰 속도 향상을 얻을 수 있습니다. 반면 페이지 콘텐츠가 현재 로그인한 사용자에 따라 달라진다면 프래그먼트 캐싱이 훨씬 어려워집니다.
읽지 않은 응답(Unread Responses)
예시로 Rails 기반 블로그를 살펴보겠습니다. 이 블로그의 각 글에는 여러 개의 댓글(응답)이 달릴 수 있고, 인증 시스템을 통해 사용자가 계정으로 로그인해 댓글을 작성할 수 있습니다. 각 글 페이지에는 댓글 목록이 표시되며, 사용자 편의를 위해 새로운 댓글에는 아이콘과 다른 배경색으로 표시해 줍니다.
여기서 각 글을 cache 블록으로 감싸면 잘못된 댓글을 '읽지 않음'으로 표시할 위험이 있습니다. 첫 번째 사용자가 페이지를 요청하면 응답이 캐시되고, 이후 다른 사용자가 같은 페이지를 요청하면 캐시된 조각을 그대로 받게 되는데, 여기에는 첫 번째 사용자 기준의 읽지 않은 댓글 표시가 포함되어 있기 때문입니다.
# app/views/articles/show.html.erb
<%= cache(@article) do %>
<h1><%= @article.title %></h1>
<%= simple_format(@article.content) %>
<section id="responses">
<h2>Responses</h2>
<% @article.responses.each do |response| %>
<div class="<%= response.read_by?(@current_user) ? 'read' : 'unread' %>">
<%= time_tag(response.created_at) %>
<strong><%= response.name %></strong>: <%= response.content %>
</div>
<% end %>
</section>
<% end %>
이 문제를 해결하는 한 가지 방법은 cache 헬퍼 메서드에 전달하는 인자를 단순히 @article 대신 [@article, @current_user]로 만들어 현재 로그인한 사용자를 캐시 키에 포함하는 것입니다.
# app/views/articles/show.html.erb
<%= cache([@article, @current_user]) do %>
<h1><%= @article.title %></h1>
# ...
<% end %>
이렇게 하면 각 사용자가 자신의 읽지 않은 댓글을 정확하게 보게 되지만, 글 조각이 사용자마다 별도로 캐시되기 때문에 캐싱으로 얻을 수 있던 대부분의 속도 향상 효과가 사라져 버립니다.
자바스크립트 스프링클스
페이지의 대부분은 모든 사용자에게 동일하므로, 캐시된 글 조각을 모든 방문자에게 재활용하고 싶습니다. 이를 위해 페이지를 캐시에서 불러온 뒤, 페이지가 로드된 후 자바스크립트 요청으로 사용자별 콘텐츠를 추가로 가져오는 방식을 사용할 수 있습니다. 자바스크립트 스프링클스 기법의 장점은 다음과 같습니다.
- 페이지를 한 번만 캐시하고 비로그인 게스트는 물론 다른 사용자에게도 재사용할 수 있어, 사용자마다 조각을 따로 캐시할 필요가 없습니다.
- 가장 중요한 콘텐츠가 먼저 로드되어 응답 시간이 빨라지고, 읽지 않은 개수 같은 부가 기능은 나중에 로드됩니다.
- 추가 요청이 자바스크립트로 처리되므로, 페이지 전체를 CDN에서 엣지 캐싱해 성능을 더욱 끌어올릴 수 있습니다.
정리하기(Cleanup)
먼저 페이지에서 동적 콘텐츠를 제거해 캐시하기 쉽게 만들겠습니다. cache 블록의 캐시 키에서 @current_user를 다시 빼서 사용자별로 캐시되지 않도록 하고, 컨트롤러에서 읽지 않은 개수를 조회하는 쿼리를 제거한 뒤, 뷰에서도 CSS 클래스명을 삭제합니다.
# app/views/articles/show.html.erb
<%= cache(@article) do %>
<h1><%= @article.title %></h1>
<%= simple_format(@article.content) %>
<section id="responses">
<h2>Responses</h2>
<% @article.responses.each do |response| %>
<div data-response-id="<%= response.id %>">
<%= time_tag(response.updated_at) %>
<strong><%= response.name %></strong>: <%= response.content %>
</div>
<% end %>
</section>
<% end %>
결과적으로 남는 것은 캐시하기 훨씬 쉬운 범용 페이지지만, 읽지 않은 댓글 표시 기능이 사라졌습니다. 이제 이 기능을 다시 추가해 보겠습니다.
엔드포인트(The Endpoint)
먼저 특정 사용자의 읽지 않은 댓글을 찾아주는 엔드포인트를 만들겠습니다. 이미 렌더링된 페이지의 상태를 이후에 변경해야 하므로, JSON 형식으로 데이터를 요청합니다.
# app/controllers/unread_responses_controller.rb
class UnreadResponsesController < ApplicationController
def index
@article = Article.find(params[:article_id])
@responses = @article.unread_responses_for(@current_user)
end
end
# app/views/unread_responses/index.json.jbuilder
json.array! @responses do |response|
json.extract! response, :id
end
# config/routes.rb
Rails.application.routes.draw do
resources :articles do
resources :responses
resources :unread_responses
end
end
이 엔드포인트는 읽지 않은 댓글의 ID 목록을 반환합니다.
# GET /articles/1/unread_responses.json
[{"id":1},{"id":2},{"id":3}]
팁: 서버에서 미리 렌더링할 수 있는 동적 컴포넌트를 불러올 때는 HTML 렌더링을 서버 측에서 처리한 후, 자바스크립트로 완성된 HTML을 페이지에 직접 삽입하는 것이 대체로 더 빠릅니다.
읽지 않은 댓글 표시하기
자바스크립트 코드에 읽지 않은 댓글 엔드포인트 URL을 하드코딩하는 대신, 뷰의 data 속성에 담아 두었다가 나중에 참조하겠습니다.
# app/views/articles/show.html.erb
<section id="responses" data-url="<%= article_unread_responses_path(@article, json: true) %>">
# ...
</section>
페이지 로딩이 완료되면 새로 만든 엔드포인트에 읽지 않은 댓글 ID 목록을 요청하고, 받은 데이터를 이용해 각 댓글 요소에 CSS 클래스를 추가해 '읽지 않음'으로 표시합니다.
// app/assets/javascripts/application.js
document.addEventListener("turbolinks:load", function () {
responses = document.getElementById("responses");
if (!responses.dataset.loaded) {
Rails.ajax({
url: responses.dataset.url,
type: "GET",
success: function (data) {
responses.dataset.loaded = true;
data.forEach(function (response) {
element = document.querySelector(
"[data-response-id='" + response.id + "']"
);
element.classList.add("unread");
});
},
});
}
});
Rails 애플리케이션이 Turbolinks를 사용하므로, turbolinks:load 이벤트를 리스닝해 페이지 로드를 기다립니다. 이벤트가 발생하면 ID로 responses 영역을 찾습니다.
그다음 responses 요소에 loaded data 속성이 설정되어 있는지 확인합니다. 이 속성은 잠시 후 읽지 않은 댓글을 갱신하면서 설정하는데, 이렇게 해 두면 브라우저의 뒤로 가기 버튼 등으로 페이지가 다시 표시될 때 불필요한 추가 요청을 막을 수 있습니다.
첫 로드 시에는 loaded 속성이 아직 없으므로 엔드포인트로 요청을 진행합니다. 요청이 성공하면 반환된 결과의 각 항목을 순회하며 ID로 해당 댓글 요소를 찾아 "unread" CSS 클래스를 추가합니다.
스프링클스!
재사용 가능한 콘텐츠를 프래그먼트 캐싱하고, 페이지 위에 자바스크립트를 살짝 뿌려(스프링클) 사용자별 동적 요소를 나중에 덧붙이면, 중요한 콘텐츠 대부분을 캐시에서 바로 제공해 앱의 초기 응답 시간을 크게 단축할 수 있습니다. 메인 콘텐츠 로드 후 추가 요청과 화면 갱신에 시간이 걸리긴 하지만, 동적 콘텐츠를 지연 로딩하면 전체 요청 시간이 캐시에서 사용자별 부분까지 모두 제공하는 경우보다 오래 걸리더라도 앱이 훨씬 빠르게 느껴집니다.
더 정교한 구현을 원한다면 Stimulus를 살펴보세요. Stimulus는 HTML 뷰와 자바스크립트를 연결해 주는 프레임워크 형태로 스프링클스 패턴을 구현한 자바스크립트 라이브러리입니다.
Rails 애플리케이션의 자바스크립트 스프링클스 소개가 도움이 되었기를 바랍니다. 이 글이나 블로그, 또는 다루었으면 하는 주제에 대한 의견이 있다면 @AppSignal로 알려주세요.