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

Rails ActiveRecord 카운터 캐시로 연관 레코드 수 효율적으로 관리하기

페이지가 로드될 때마다 데이터베이스에서 연관된 레코드를 일일이 집계하는 대신, ActiveRecord의 카운터 캐시(counter caching) 기능을 활용하면 카운터 값을 저장해 두고 연관 객체가 생성되거나 삭제될 때마다 자동으로 갱신할 수 있습니다. 이번 AppSignal Academy 에피소드에서는 ActiveRecord에서 카운터를 캐싱하는 방법을 모두 살펴보겠습니다.

글(article)과 댓글(response)로 구성된 전형적인 블로그 예제를 사용해 보겠습니다. 각 글에는 여러 개의 댓글이 달릴 수 있고, 우리는 블로그 인덱스 페이지에서 각 글 제목 옆에 댓글 수를 표시해서 해당 글의 인기를 보여주고 싶습니다.

문제 상황: N+1 쿼리

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.all
  end
 
  # ...
end

인덱스 페이지에서는 댓글의 실제 데이터를 보여주지 않으므로 댓글을 미리 로딩(preload)할 필요가 없습니다. 우리는 단순히 댓글 개수만 필요합니다. 컨트롤러는 모든 글을 조회한 뒤 @articles 변수에 담아 뷰에 전달합니다.

<!-- app/views/articles/index.html.erb -->
<h1>Articles</h1>
 
<% @articles.each do |article| %>
<article>
  <h1><%= article.title %></h1>
  <p><%= article.description %></p>
  <%= article.responses.size %> responses
</article>
<% end %>

뷰는 각 글을 순회하면서 제목, 설명, 그리고 받은 댓글 수를 렌더링합니다. 뷰에서 article.responses.size를 호출하면 ActiveRecord는 각 댓글 레코드를 통째로 불러오는 대신 연관 관계의 개수만 세어야 한다는 것을 알아차립니다.

팁: 댓글 수를 세는 데는 #count가 더 직관적일 것 같지만, 이 예제에서는 #size를 사용합니다. #count는 항상 COUNT 쿼리를 실행하지만, #size는 댓글이 이미 로딩되어 있는 경우 쿼리를 생략하기 때문입니다.

Started GET "/articles" for 127.0.0.1 at 2018-06-14 16:25:36 +0200
Processing by ArticlesController#index as HTML
  Rendering articles/index.html.erb within layouts/application
  Article Load (0.2ms)  SELECT "articles".* FROM "articles"
  ↳ app/views/articles/index.html.erb:3
  (0.2ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 2]]
  ↳ app/views/articles/index.html.erb:7
  (0.3ms)  SELECT COUNT(*) FROM "responses" WHERE "responses"."article_id" = ?  [["article_id", 3]]
  ↳ app/views/articles/index.html.erb:7
  ...
  Rendered articles/index.html.erb within layouts/application (23.1ms)
Completed 200 OK in 52ms (Views: 45.7ms | ActiveRecord: 1.6ms)

블로그 인덱스 페이지를 요청하면 ActiveRecord가 글마다 댓글 수를 별도의 쿼리로 지연 로딩(lazy-load)하기 때문에 N+1 쿼리 문제가 발생합니다.

쿼리 내에서 COUNT() 사용하기

글 하나당 추가 쿼리가 실행되는 것을 막으려면, articles 테이블과 responses 테이블을 조인(join)해서 단일 쿼리로 연관된 댓글 수를 세면 됩니다.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.
      joins(:responses).
      select("articles.*", 'COUNT("responses.id") AS responses_count').
      group('articles.id')
  end
 
  # ...
end

이 예제에서는 글 조회 쿼리에 댓글을 조인하고 COUNT("responses.id")를 select하여 댓글 수를 셉니다. 글 ID로 그룹핑(group by)하면 글별로 댓글 수를 집계할 수 있습니다. 뷰에서는 responses 연관 관계에 size를 호출하는 대신 responses_count를 사용해야 합니다.

이 방식은 첫 번째 쿼리를 더 느리고 복잡하게 만드는 대신 추가 쿼리를 방지합니다. 이 페이지의 성능 최적화를 위한 좋은 첫걸음이지만, 한 단계 더 나아가 카운터를 캐싱하면 매 페이지 조회 시마다 댓글을 셀 필요 자체가 없어집니다.

카운터 캐시(Counter Cache)

블로그의 글은 (바라건대) 수정되는 것보다 읽히는 빈도가 훨씬 높기 때문에, 카운터 캐시는 이 페이지의 조회 속도를 빠르고 단순하게 만들어 주는 훌륭한 최적화 방법입니다.

카운터 캐시는 글이 표시될 때마다 댓글 수를 새로 세는 대신, 각 글의 데이터베이스 행(row)에 별도의 댓글 카운터를 저장해 둡니다. 그리고 댓글이 추가되거나 삭제될 때마다 이 카운터가 갱신됩니다.

덕분에 글 목록은 responses 테이블과 조인 없이 딱 한 번의 데이터베이스 쿼리로 렌더링할 수 있습니다. 설정 방법은 belongs_to 관계에 counter_cache 옵션을 지정하는 것뿐입니다.

# app/models/response.rb
class Response
  belongs_to :article, counter_cache: true
end

이 설정을 위해서는 Article 모델에 responses_count라는 이름의 필드가 필요합니다. counter_cache 옵션은 댓글이 추가되거나 삭제될 때마다 해당 필드의 값이 자동으로 갱신되도록 보장합니다.

팁: counter_cache 옵션 값으로 true 대신 심볼(symbol)을 사용하면 필드 이름을 임의로 변경할 수 있습니다.

이제 카운트를 저장할 새 컬럼을 데이터베이스에 생성합니다.

$ rails generate migration AddResponsesCountToArticles responses_count:integer
      invoke  active_record
      create    db/migrate/20180618093257_add_responses_count_to_articles.rb
$ rake db:migrate
== 20180618093257 AddResponsesCountToArticles: migrating ======================
-- add_column(:articles, :responses_count, :integer)
  -> 0.0016s
== 20180618093257 AddResponsesCountToArticles: migrated (0.0017s) =============

이제 댓글 수가 articles 테이블에 캐싱되므로, 글 조회 쿼리에서 댓글을 조인할 필요가 없습니다. 컨트롤러에서는 Article.all로 모든 글을 가져오면 됩니다.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.all
  end
 
  # ...
end

뷰는 변경할 필요가 없습니다. Rails는 #size 메서드 호출 시 카운터 캐시를 사용해야 한다는 것을 알아서 처리해 줍니다.

<!-- app/views/articles/index.html.erb -->
<h1>Articles</h1>
 
<% @articles.each do |article| %>
<article>
  <h1><%= article.title %></h1>
  <p><%= article.description %></p>
  <%= article.responses.size %> responses
</article>
<% end %>

인덱스 페이지를 다시 요청하면 단 하나의 쿼리만 실행되는 것을 확인할 수 있습니다. 각 글이 자신의 댓글 수를 이미 알고 있기 때문에 responses 테이블을 전혀 조회할 필요가 없습니다.

Started GET "/articles" for 127.0.0.1 at 2018-06-14 17:15:23 +0200
Processing by ArticlesController#index as HTML
  Rendering articles/index.html.erb within layouts/application
  Article Load (0.2ms)  SELECT "articles".* FROM "articles"
  ↳ app/views/articles/index.html.erb:3
  Rendered articles/index.html.erb within layouts/application (3.5ms)
Completed 200 OK in 42ms (Views: 36.5ms | ActiveRecord: 0.2ms)

스코프가 적용된 연관 관계에 대한 카운터 캐시

ActiveRecord의 카운터 캐시 콜백은 레코드가 생성되거나 삭제될 때만 실행됩니다. 따라서 스코프(scope)가 적용된 연관 관계에 카운터 캐시를 적용하는 것은 동작하지 않습니다. 예를 들어 '발행(published)된' 댓글만 세는 것 같은 고급 요구 사항이 있다면 counter_culture 젬(gem)을 확인해 보세요.

카운터 캐시 값 채우기(Backfill)

카운터 캐시 도입 이전부터 존재했던 글들은 카운터가 기본값인 0이므로 실제 값과 어긋나 있을 수 있습니다. 객체의 .reset_counters 메서드에 객체의 ID와 갱신할 연관 관계 이름을 전달하면 카운터를 '리셋'할 수 있습니다.

Article.reset_counters(article.id, :responses)

배포 시 운영 환경에서 이 작업이 반드시 실행되도록 하려면, 앞선 마이그레이션에서 컬럼을 추가한 직후에 실행되는 마이그레이션에 넣어 두면 됩니다.

$ rails generate migration PopulateArticleResponsesCount --force
      invoke  active_record
      create    db/migrate/20180618093443_populate_article_responses_count.rb

마이그레이션에서는 각 글마다 Article.reset_counters를 호출하고, 글의 ID와 연관 관계 이름인 :responses를 전달합니다.

# db/migrate/20180618093443_populate_article_responses_count.rb
class PopulateArticleResponsesCount < ActiveRecord::Migration[5.2]
  def up
    Article.find_each do |article|
      Article.reset_counters(article.id, :responses)
    end
  end
end

이 마이그레이션은 카운터 캐시 도입 이전부터 존재했던 글들을 포함해 데이터베이스의 모든 글에 대해 카운트를 갱신합니다.

주의사항: 콜백(Callbacks)

카운터 캐시는 콜백을 통해 카운터를 갱신하기 때문에, SQL 명령을 직접 실행하는 메서드(예: #destroy 대신 #delete를 사용하는 경우)는 콜백을 우회하므로 카운터가 갱신되지 않습니다.

어떤 이유로든 이런 상황이 발생한다면, 주기적으로 카운트를 동기화해 주는 Rake 태스크나 백그라운드 잡(background job)을 추가하는 것이 좋습니다.

namespace :counters do
  task update: :environment do
    Article.find_each do |article|
      Article.reset_counters(article.id, :responses)
    end
  end
end

마치며: 캐싱된 카운터

쿼리 안에서 연관 객체를 집계하는 방식으로 N+1 쿼리를 방지하는 것도 도움이 되지만, 대부분의 애플리케이션에서는 카운터를 캐싱하는 것이 훨씬 빠른 방법입니다. ActiveRecord의 내장 카운터 캐시만으로도 큰 도움이 되며, 더 복잡한 요구 사항에는 counter_culture 같은 젬을 활용할 수 있습니다.

ActiveRecord의 카운터 캐시에 대해 궁금한 점이 있다면 언제든 @AppSignal로 알려주세요. 이 글이 유용했다거나, 더 알고 싶은 주제가 있다면 소개해 주셔도 좋습니다.