캐싱(caching)을 일반적으로 설명하면, 어떤 코드의 실행 결과를 저장해 두었다가 나중에 빠르게 꺼내 쓰는 것을 의미합니다. 경우에 따라서는 계산된 값을 미리 저장해 두어 다시 계산할 필요가 없도록 하기도 합니다. 하지만 단순히 데이터를 메모리에 보관만 해도, 하드 디스크에서 읽거나 네트워크 요청을 수행하지 않아도 되므로 이 역시 캐싱의 한 형태입니다.
후자의 형태는 ActiveRecord에서 특히 중요합니다. 데이터베이스가 별도의 서버에서 구동되는 경우가 많기 때문입니다. 이런 환경에서는 모든 요청마다 네트워크 트래픽 오버헤드가 발생하고, 같은 쿼리를 반복 실행할 때마다 데이터베이스 서버에 부하가 걸립니다.
다행히 Rails 개발자라면 ActiveRecord가 이런 작업을 상당 부분 자동으로 처리해 줍니다. 심지어 우리가 인식하지 못한 사이에 말이죠. 생산성 측면에서는 매우 유용하지만, 때로는 보이지 않는 곳에서 무엇이 캐싱되고 있는지 파악하는 것이 중요합니다. 예를 들어 다른 프로세스(백그라운드 잡 등)가 값을 변경하고 있다는 것을 알고 있거나, 반드시 최신 값을 가져와야 하는 경우가 그렇습니다. 이럴 때를 대비해 ActiveRecord는 캐시를 무시하고 강제로 데이터를 읽어오는 몇 가지 '탈출구(escape hatch)'를 제공합니다.
ActiveRecord의 지연 평가(Lazy Evaluation)
ActiveRecord의 지연 평가는 엄밀히 말해 캐싱은 아니지만, 이후 코드 예제에서 계속 마주치게 되므로 간단히 짚고 넘어가겠습니다. ActiveRecord 쿼리를 생성할 때 많은 경우 코드가 즉시 데이터베이스를 호출하지 않습니다. 바로 이 덕분에 .where 절을 여러 번 체이닝하면서도 매번 데이터베이스에 접근하지 않아도 되는 것입니다.
@posts = Post.where(published: true)
# 아직 DB 호출 없음
@posts = @posts.where(published_at: Date.today)
# 여전히 호출 없음
@posts.count
# SELECT COUNT(*) FROM "posts" WHERE...
물론 예외도 있습니다. .find, .find_by, .pluck, .to_a, .first 등을 사용하면 추가 절을 체이닝할 수 없으며, 즉시 DB 호출이 발생합니다. 이 글의 대부분 예제에서는 DB 호출을 강제하는 간단한 방법으로 .to_a를 사용하겠습니다.
Rails 콘솔에서 직접 실험해 보려면 'echo' 모드를 꺼야 한다는 점에 유의하세요. echo 모드가 켜져 있으면 irb든 pry든 엔터 키를 누르는 순간 객체에 .inspect를 호출하여 DB 쿼리가 강제로 실행됩니다. echo 모드를 비활성화하려면 다음 코드를 사용하세요.
conf.echo = false # irb인 경우
pry_instance.config.print = proc {} # pry인 경우
ActiveRecord 관계(Relations) 캐싱
ActiveRecord의 내장 캐싱 중 첫 번째로 살펴볼 것은 관계(relation)입니다. 전형적인 User-Posts 관계를 예로 들어 보겠습니다.
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
end
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
end
이렇게 하면 연관된 레코드를 조회하는 user.posts와 post.user 메서드를 편리하게 사용할 수 있습니다. 컨트롤러와 뷰에서 이를 활용한다고 가정해 봅시다.
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@user = User.find(params[:user_id])
@posts = @user.posts
end
...
# app/views/posts/index.html.erb
...
<%= render 'shared/sidebar' %>
<% @posts.each do |post| %>
<%= render post %>
<% end %>
# app/views/shared/_sidebar.html.erb
...
<% @posts.each do |post| %>
<li><%= post.title %></li>
<% end %>
index 액션은 @user.posts를 가져옵니다. 앞서 살펴본 지연 평가와 마찬가지로, 이 시점에는 아직 데이터베이스 쿼리가 실행되지 않았습니다. Rails가 index 뷰를 렌더링하면 그 안에서 사이드바가 렌더링되고, 사이드바가 @posts.each ...를 호출하는 순간에야 ActiveRecord가 실제로 DB 쿼리를 날려 데이터를 가져옵니다.
그런 다음 index 템플릿의 나머지 부분으로 돌아가 또 다른 @posts.each를 만나게 됩니다. 그런데 이번에는 데이터베이스 호출이 발생하지 않습니다. ActiveRecord가 게시글 목록을 이미 캐싱해 두었기 때문에 굳이 DB를 다시 읽지 않는 것입니다.
탈출구(Escape Hatch)
때로는 ActiveRecord가 연관 레코드를 강제로 다시 가져오게 하고 싶을 수 있습니다. 다른 프로세스(예: 백그라운드 잡)가 해당 값을 변경하고 있다는 것을 알고 있거나, 자동화된 테스트에서 코드가 DB 값을 올바르게 수정했는지 검증하기 위해 최신 값을 확인해야 하는 경우가 대표적입니다.
상황에 따라 흔히 쓰이는 방법은 두 가지입니다. 가장 일반적인 방법은 연관 관계에 .reload를 호출하는 것입니다. 이렇게 하면 ActiveRecord가 캐시해 둔 내용을 무시하고 데이터베이스에서 최신 버전을 가져옵니다.
@user = User.find(1)
@user.posts # DB 호출
@user.posts # 캐시됨, DB 호출 없음
@user.posts.reload # DB 호출
@user.posts # 새 버전이 캐시됨, DB 호출 없음
또 다른 방법은 ActiveRecord 모델의 새 인스턴스를 얻는 것입니다(예: find를 다시 호출).
@user = User.find(1)
@user.posts # DB 호출
@user.posts # 캐시됨, DB 호출 없음
@user = User.find(1) # @user는 이제 User의 새 인스턴스
@user.posts # DB 호출, 이 인스턴스에는 캐시가 없음
관계 캐싱은 유용하지만, 단순한 관계 조회를 넘어 복잡한 .where(...) 쿼리를 사용하는 경우가 더 많습니다. 바로 이럴 때 ActiveRecord의 SQL 캐시가 역할을 합니다.
ActiveRecord의 SQL 캐시
ActiveRecord는 성능 향상을 위해 실행했던 쿼리의 내부 캐시를 유지합니다. 다만 이 캐시는 특정 액션에 묶여 있다는 점에 주의하세요. 액션이 시작될 때 생성되고 액션이 끝나면 소멸됩니다. 즉, 하나의 컨트롤러 액션 안에서 동일한 쿼리를 두 번 실행할 때만 효과를 볼 수 있으며, Rails 콘솔에서는 이 캐시가 적용되지 않습니다. 캐시 히트는 Rails 로그에서 CACHE로 표시됩니다.
class PostsController < ApplicationController
def index
...
Post.all.to_a # to_a로 DB 쿼리 강제
...
Post.all.to_a # to_a로 DB 쿼리 강제
end
end
위 코드는 다음과 같은 로그 출력을 생성합니다.
Post Load (2.1ms) SELECT "posts".* FROM "posts"
↳ app/controllers/posts_controller.rb:11:in `index'
CACHE Post Load (0.0ms) SELECT "posts".* FROM "posts"
↳ app/controllers/posts_controller.rb:13:in `index'
ActiveRecord::Base.connection.query_cache를 출력하면 해당 액션의 캐시 내부를 직접 들여다볼 수도 있습니다(SQL 쿼리만 보려면 ActiveRecord::Base.connection.query_cache.keys).
탈출구(Escape Hatch)
SQL 캐시를 우회해야 할 이유는 많지 않겠지만, 필요하다면 ActiveRecord::Base의 uncached 메서드를 사용해 캐시를 건너뛰도록 강제할 수 있습니다.
class PostsController < ApplicationController
def index
...
Post.all.to_a # to_a로 DB 쿼리 강제
...
ActiveRecord::Base.uncached do
Post.all.to_a # to_a로 DB 쿼리 강제
end
end
end
uncached는 ActiveRecord::Base의 메서드이므로, 가독성을 높이고 싶다면 모델 클래스를 통해 호출할 수도 있습니다.
Post.uncached do
Post.all.to_a
end
카운터 캐시(Counter Cache)
웹 애플리케이션에서는 관계에 속한 레코드 수를 세야 하는 경우가 매우 흔합니다(예: 어떤 사용자가 게시글 X개를 작성했다거나, 팀 계정에 사용자 Y명이 있다거나). 이런 요구가 워낙 흔하기 때문에 ActiveRecord는 카운터를 자동으로 최신 상태로 유지하는 기능을 제공하여, 수많은 .count 호출이 데이터베이스 리소스를 낭비하지 않도록 합니다. 활성화하는 데 몇 단계만 거치면 됩니다.
먼저 관계에 counter_cache 옵션을 추가하여 ActiveRecord가 카운트를 캐싱하도록 지정합니다.
class Post < ApplicationRecord
belongs_to :user, counter_cache: true
end
다음으로 카운트가 저장될 새 컬럼을 User에 추가해야 합니다. 이 예제에서는 User.posts_count가 됩니다. 필요하다면 counter_cache에 심볼을 전달해 컬럼 이름을 직접 지정할 수도 있습니다.
rails generate migration AddPostsCountToUsers posts_count:integer
rails db:migrate
새로 추가된 카운터는 기본값인 0으로 설정됩니다. 애플리케이션에 이미 게시글이 있다면 기존 값을 갱신해 주어야 합니다. ActiveRecord는 세부 작업을 처리해 주는 reset_counters 메서드를 제공하므로, ID와 갱신할 카운터만 전달하면 됩니다.
User.all.each do |user|
User.reset_counters(user.id, :posts)
end
마지막으로 이 카운트를 사용하는 곳들을 점검해야 합니다. .count를 호출하면 카운터 캐시를 무시하고 항상 COUNT() SQL 쿼리를 실행하기 때문입니다. 대신 캐시된 카운터가 존재하면 이를 활용하는 .size를 사용하세요. 덧붙이자면, .size는 이미 로드된 연관 관계를 다시 불러오지 않으므로 DB 접근을 줄일 수 있습니다. 그래서 어디서든 기본적으로 .size를 쓰는 습관을 들이는 것도 좋은 선택입니다.
마무리
대체로 ActiveRecord의 내장 캐싱은 "알아서 잘" 동작합니다. 이를 우회해야 하는 사례를 자주 본 적은 없지만, 무슨 일이든 그렇듯 "보이지 않는 곳"에서 무엇이 일어나는지 이해하고 있으면, 평소와 다른 처리가 필요한 상황에 부딪혔을 때 시간과 고생을 크게 줄일 수 있습니다.
물론 데이터베이스만 Rails가 보이지 않게 캐싱을 도와주는 곳은 아닙니다. HTTP 명세에는 클라이언트와 서버 간에 주고받는 헤더를 통해 변경되지 않은 데이터를 재전송하지 않도록 하는 규격이 포함되어 있습니다. 이 캐싱 시리즈의 다음 글에서는 304 (Not Modified) HTTP 상태 코드, Rails가 이를 어떻게 자동으로 처리해 주는지, 그리고 이 처리를 어떻게 조정할 수 있는지 살펴보겠습니다.