본문으로 건너뛰기
김신건의 로그

[Rails] Caching: fragment, action, low-level, Russian Doll

· 수정 · 📖 약 2분 · 588자/단어 #rails #cache #performance #redis #solid-cache
rails caching, fragment cache, russian doll caching, Rails.cache, solid_cache

정의

Rails 캐싱은 fragment, action, page, HTTP, low-level (Rails.cache) 5단계. 모델·뷰 단위로 자동 무효화 (Russian Doll Caching)가 핵심. Rails 8부터 Solid Cache (DB 백엔드) 기본.

백엔드

# config/environments/production.rb
config.cache_store = :solid_cache_store
# 또는
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }
config.cache_store = :memory_store, { size: 64.megabytes }
config.cache_store = :file_store, "/tmp/rails-cache"
config.cache_store = :null_store    # 비활성
옵션특징
:solid_cache_store (8.0+)DB, Redis 불필요
:redis_cache_storeRedis (빠름, 분산)
:memcache_storeMemcached
:mem_cache_store동일
:memory_store프로세스 메모리 (개발)
:file_store파일

캐시 활성화 (development)

기본은 dev에서 캐싱 OFF:

bin/rails dev:cache    # tmp/caching-dev.txt 토글
# 다시 끄려면 같은 명령

low-level: Rails.cache

Rails.cache.write("key", value, expires_in: 1.hour)
Rails.cache.read("key")
Rails.cache.fetch("key", expires_in: 1.hour) { expensive_computation }
Rails.cache.delete("key")
Rails.cache.delete_matched("prefix:*")    # 와일드카드
Rails.cache.exist?("key")
Rails.cache.clear    # 위험 (전체 비움)

# 다중
Rails.cache.write_multi({ "a" => 1, "b" => 2 })
Rails.cache.read_multi("a", "b")

# 증감
Rails.cache.increment("counter", 1)
Rails.cache.decrement("counter")

fetch 패턴

def top_posts
  Rails.cache.fetch("top_posts", expires_in: 5.minutes) do
    Post.order(views: :desc).limit(10).to_a
  end
end

캐시 hit → 즉시 반환. miss → 블록 실행 + 저장.

fragment cache

<% cache @post do %>
  <h1><%= @post.title %></h1>
  <p><%= @post.body %></p>
<% end %>

<% cache [@post, "v2"] do %>
  <%# 버전 추가 - 코드 변경 시 캐시 무효화 #}
<% end %>

<% cache_if condition, @post do %>
  ...
<% end %>

키 자동 생성: views/posts/123-20260620143000. 모델의 cache_key_with_version.

컬렉션

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

각 post에 대해 fragment cache. 첫 요청 후 hit 비율 매우 높음.

Russian Doll Caching

부모 + 자식 nested 캐시. 자식 변경 시 부모도 자동 무효화 (updated_at 변경).

<% cache @post do %>
  <h1><%= @post.title %></h1>

  <% @post.comments.each do |comment| %>
    <% cache comment do %>
      <%= render comment %>
    <% end %>
  <% end %>
<% end %>
class Comment < ApplicationRecord
  belongs_to :post, touch: true    # 댓글 저장 시 post.updated_at 갱신
end

touch: true로 부모 cache_key 자동 갱신 → fragment 무효화. 매우 효율적.

HTTP 캐싱

Conditional GET

def show
  @post = Post.find(params[:id])
  if stale?(@post)    # ETag/Last-Modified 자동
    render
  end
end

# 또는
def show
  @post = Post.find(params[:id])
  fresh_when(@post)    # stale 시 render, fresh 시 304
end

브라우저가 If-None-Match 헤더로 다시 요청 → 변경 없으면 304 Not Modified.

Cache-Control

def index
  @posts = Post.featured
  expires_in 5.minutes, public: true
  render
end

Cache-Control: public, max-age=300 응답.

Cache key

post.cache_key                  # "posts/123"
post.cache_key_with_version     # "posts/123-20260620143000"
post.cache_version              # "20260620143000"

# 커스텀
class Post < ApplicationRecord
  def cache_key
    "post/#{id}/#{updated_at.to_i}"
  end
end

cache_key + cache_version 자동 결합.

자주 보는 패턴

비싼 쿼리

def expensive_stats
  Rails.cache.fetch("stats", expires_in: 1.hour) do
    {
      users: User.count,
      posts: Post.count,
      active: User.active.count,
    }
  end
end

외부 API 결과

def github_user(username)
  Rails.cache.fetch("github:#{username}", expires_in: 1.day) do
    Net::HTTP.get(URI("https://api.github.com/users/#{username}"))
  end
end

API rate limit + 응답 속도 둘 다 향상.

컬렉션 페이지네이션

<% cache [@posts, params[:page]] do %>
  <%= render @posts %>
<% end %>

페이지별 캐시. 페이지 안의 post 변경 시 자동 무효화.

사용자별

<% cache [@post, current_user] do %>
  <%# 사용자별 다른 내용 (좋아요 여부 등) %>
<% end %>

각 사용자마다 별도 키.

Solid Cache (Rails 8)

# Gemfile (8.0+ 자동)
gem "solid_cache"
# config/cache.yml
production:
  database: cache
  store_options:
    max_age: 14.days
    max_size: 256.megabytes

DB 기반 → Redis 불필요. LRU eviction. SQL 인덱스로 빠름.

함정

1. 캐시 무효화

“There are only two hard things in CS: cache invalidation and naming things.”

touch: true로 자동 무효화 권장. 수동 Rails.cache.delete는 누락 위험.

2. fetch 블록 안 예외

Rails.cache.fetch("key") do
  raise "fail"
end
# 다음 호출도 매번 fail (캐시 안 됨)

블록은 정상 반환해야 캐시. nil 반환은 캐시되지 않음 (skip_nil 옵션 X).

3. cache stampede

만료 직후 동시 다수 요청 → 모두 DB. 해결:

  • race_condition_ttl로 약간의 grace
  • 미리 갱신 (TTL 도달 전)
  • lock-based 갱신
Rails.cache.fetch("key", expires_in: 5.minutes, race_condition_ttl: 30.seconds) do
  ...
end

4. Cache.clear in production

Rails.cache.clear 전체 비움. 다른 앱이 같은 store 공유 시 영향. namespace 사용:

config.cache_store = :redis_cache_store, { namespace: "myapp:" }

5. 키 너무 길거나 dict 직렬화

Rails.cache.fetch([@user, @post, params])    # params hash → 직렬화 비싼

명시 키 권장.

모니터링

  • 캐시 hit/miss ratio
  • 메모리 사용량
  • key 분포 (redis-cli --bigkeys)

Skylight, NewRelic, AppSignal 같은 APM이 자동 측정.

캐시 vs 다른 최적화

  • DB 인덱스 추가가 보통 첫 번째
  • N+1 해결
  • 그 다음 캐시

캐시는 정합성 비용 + 디버깅 복잡. 진짜 필요할 때만.

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기