Rails 블로그에서 다단계 캐싱 전략 구현하기

Rails 블로그에서 다단계 캐싱 전략 구현하기

상황

Rails 8 블로그 프로젝트에서 모든 페이지의 Footer에 카테고리 목록을 표시하고 있었다. 이 카테고리 목록은 5개 컨트롤러(Home, Posts, Categories, Search, Authorities)에서 중복으로 조회되고 있었고, 각 페이지 요청마다 동일한 데이터베이스 쿼리가 반복 실행되는 문제가 있었다.

발생 증상

문제점 상세 내용 영향도
중복 코드 5개 컨트롤러에 동일한 set_categories 메서드 존재 유지보수성 낮음
반복 쿼리 페이지 요청마다 SELECT DISTINCT category FROM posts 실행 성능 낮음
캐싱 미적용 거의 변하지 않는 데이터를 매번 DB에서 조회 리소스 낭비
확장성 문제 컨트롤러 추가 시마다 동일 로직 반복 작성 필요 코드 품질 낮음

실제 코드 중복 현황

# HomeController
def set_categories
  @categories = Post.published.distinct.pluck(:category).compact.sort
end

# PostsController
def set_categories
  @categories = Post.published.distinct.pluck(:category).compact.sort
end

# CategoriesController
def set_categories
  @categories = Post.published.distinct.pluck(:category).compact.sort
end

# SearchController
def set_categories
  @categories = Post.published.distinct.pluck(:category).compact.sort
end

# AuthoritiesController
def set_categories
  @categories = Post.published.distinct.pluck(:category).compact.sort
end

문제: 완전히 동일한 코드가 5곳에 중복 → DRY 원칙 위반

비즈니스 영향

정량적 영향

  • 일 평균 1,000 페이지뷰 × 5개 컨트롤러 = 5,000회 불필요한 DB 쿼리
  • 쿼리당 2-5ms × 5,000회 = 10-25초/일의 누적 DB 부하
  • 동일 데이터를 반복 조회하여 DB 커넥션 풀 낭비

정성적 영향

  • 유지보수 비용 증가: 카테고리 로직 변경 시 5곳 모두 수정 필요
    버그 발생 위험: 한 곳만 수정하면 불일치 발생
  • 확장성 제약: 새 페이지 추가 시마다 동일 패턴 반복

원인분석

근본 원인: 레이아웃 기반 공통 데이터 패턴

Rails 애플리케이션에서 레이아웃 파일(layout)에 표시되는 데이터는 모든 페이지에서 필요하다. Footer의 카테고리 목록이 대표적인 예시다.

기존 접근 방식의 문제

<!-- app/views/layouts/application.html.erb -->
<footer>
  <nav>
    <% @categories.each do |category| %>
      <%= link_to category, category_path(category) %>
    <% end %>
  </nav>
</footer>

레이아웃에서 @categories 인스턴스 변수를 사용하므로, 모든 컨트롤러에서 이를 설정해야 한다.

문제 분석 다이어그램

graph TD
    A[사용자 요청] --> B{어느 페이지?}
    B -->|홈| C[HomeController]
    B -->|게시글| D[PostsController]
    B -->|카테고리| E[CategoriesController]
    B -->|검색| F[SearchController]
    B -->|작성자| G[AuthoritiesController]
    
    C --> H[set_categories 호출]
    D --> H
    E --> H
    F --> H
    G --> H
    
    H --> I[DB 쿼리 실행]
    I --> J["SELECT DISTINCT category
FROM posts
WHERE published_at <= NOW()"] J --> K[결과 반환 5-10개 카테고리] K --> L[Footer에 렌더링] style I fill:#f44,color:#fff style J fill:#f44,color:#fff style H fill:#ff9,color:#000

왜 캐싱이 필요한가?

카테고리 목록의 특성:

  1. 변경 빈도 낮음: 새 게시글 추가 시에만 변경 가능
  2. 조회 빈도 높음: 모든 페이지 요청마다 필요
  3. 데이터 크기 작음: 보통 5-10개 카테고리 (수십 바이트)
  4. 계산 비용: DB 쿼리 + DISTINCT + 정렬

캐싱의 이상적인 대상

캐싱 전략 선택

Rails에서 제공하는 캐싱 옵션:

캐싱 레이어 저장 위치 TTL

사용 사례

Page Cache 파일 시스템 수동 정적 페이지
Action Cache 메모리/Redis 설정 가능 전체 액션 결과
Fragment Cache 메모리/Redis 설정 가능 뷰 조각
Rails.cache 메모리/Redis 설정 가능 임의 데이터 ✅
HTTP Cache 브라우저/CDN HTTP 헤더 공개 리소스

선택: Rails.cache (메모리 캐시)

  • 작은 데이터에 최적
  • TTL 설정 가능 (1시간)
  • 프로세스 간 공유 가능
  • 무효화 제어 용이

가설 및 해결방법

가설 설정

가설 1: Rails Concern 패턴으로 중복 코드를 제거하면 유지보수성이 향상된다.

가설 2: Rails.cache로 카테고리 목록을 캐싱하면 DB 쿼리를 크게 줄일 수 있다.

가설 3: 게시글의 카테고리 변경 시에만 캐시를 무효화하면 데이터 일관성을 유지할 수 있다.

해결 전략

1단계: Concern 패턴으로 공통 로직 추출

# app/controllers/concerns/categories_cacheable.rb
module CategoriesCacheable
  extend ActiveSupport::Concern

  included do
    before_action :set_categories
  end

  private

  def set_categories
    # 로직은 여기에 한 번만 작성
  end
end

장점:

  • DRY 원칙 준수
  • 변경 시 한 곳만 수정
  • 테스트 용이

2단계: Rails.cache로 메모리 캐싱

def set_categories
  @categories = Rails.cache.fetch("categories_list", expires_in: 1.hour) do
    Post.published.distinct.pluck(:category).compact.sort
  end
end

동작 원리:

  • 캐시 키 "categories_list"로 조회
  • 캐시 hit → 바로 반환 (DB 쿼리 실행 안함)
  • 캐시 miss → 블록 실행 후 결과 저장

3단계: 자동 캐시 무효화

# app/models/post.rb
after_save :clear_categories_cache, if: :saved_change_to_category?
after_destroy :clear_categories_cache

def clear_categories_cache
  Rails.cache.delete("categories_list")
end

트리거:

  • 게시글의 카테고리가 변경될 때
  • 게시글이 삭제될 때

아키텍처 다이어그램

sequenceDiagram
    participant U as 사용자
    participant C as Controller
    participant Concern as CategoriesCacheable
    participant Cache as Rails.cache
    participant DB as Database
    participant Model as Post 모델

    U->>C: 페이지 요청
    C->>Concern: before_action :set_categories
    Concern->>Cache: fetch("categories_list")
    
    alt 캐시 HIT (1시간 이내)
        Cache-->>Concern: 캐싱된 데이터 반환
        Note over Cache: DB 쿼리 없음 ✅
    else 캐시 MISS (첫 요청 or 만료)
        Cache->>DB: SELECT DISTINCT category...
        DB-->>Cache: 결과 반환
        Cache->>Cache: 1시간 TTL로 저장
        Cache-->>Concern: 데이터 반환
    end
    
    Concern-->>C: @categories 설정
    C-->>U: 페이지 렌더링
    
    Note over Model: 게시글 카테고리 변경 시
    Model->>Cache: delete("categories_list")
    Cache->>Cache: 캐시 무효화 ⚠️

해결시도 및 정량적 비교

구현 코드

변경 1: CategoriesCacheable Concern 생성

# app/controllers/concerns/categories_cacheable.rb (새 파일)
# frozen_string_literal: true

# Concern for caching categories list across multiple controllers
# This prevents redundant database queries on every page request
module CategoriesCacheable
  extend ActiveSupport::Concern

  included do
    before_action :set_categories
  end

  private

  def set_categories
    # Cache categories list for 1 hour
    # Cache will be invalidated when a post's category changes
    @categories = Rails.cache.fetch("categories_list", expires_in: 1.hour) do
      Post.published.distinct.pluck(:category).compact.sort
    end
  end
end

핵심 개선 사항:

  • 단일 책임: 카테고리 캐싱만 담당
  • 자기 문서화: 주석으로 동작 설명
  • 재사용 가능: 여러 컨트롤러에 include 가능

변경 2: 컨트롤러에 Concern 적용

# app/controllers/home_controller.rb
class HomeController < ApplicationController
-  before_action :set_categories
+  include CategoriesCacheable

  def index
    # ... 기존 로직
  end
-
-  private
-
-  def set_categories
-    @categories = Post.published.distinct.pluck(:category).compact.sort
-  end
end

동일하게 5개 컨트롤러 모두 수정:

  • HomeController
  • PostsController
  • CategoriesController
  • SearchController
  • AuthoritiesController

코드 감소량:

  • 각 컨트롤러에서 5-6줄 제거
  • 25-30줄 제거
  • Concern 파일 추가: 21줄
  • 순 감소: 4-9줄 + 중복 제거 효과

변경 3: Post 모델에 캐시 무효화 로직 추가

# app/models/post.rb
class Post < ApplicationRecord
  # ... 기존 코드

  # Callbacks
  after_create :notify_newsletter_subscribers, if: :should_notify_subscribers?
  after_save :clear_categories_cache, if: :saved_change_to_category?  # ✅ 추가
  after_destroy :clear_categories_cache                                 # ✅ 추가

  # ... 기존 메서드들

  private

  # 카테고리 목록 캐시 무효화
  def clear_categories_cache
    Rails.cache.delete("categories_list")
  end
end

무효화 조건:

  1. after_save + if: :saved_change_to_category?
    - 게시글 저장 시 카테고리가 실제로 변경된 경우만
    - 제목 등 다른 필드 변경 시에는 무효화하지 않음 (효율적)
  2. after_destroy
    - 게시글 삭제 시 항상 무효화
    - 해당 카테고리의 마지막 게시글일 수 있으므로

변경 사항 요약

파일 변경 내용 라인 수
concerns/categories_cacheable.rb 새 파일 생성 +21줄
home_controller.rb Concern 적용, 중복 제거 -6줄
posts_controller.rb Concern 적용, 중복 제거 -7줄
categories_controller.rb Concern 적용, 중복 제거 -6줄
search_controller.rb Concern 적용, 중복 제거 -6줄
authorities_controller.rb Concern 적용, 중복 제거 -6줄
models/posts.rb 캐시 무효화 로직 추가 +7줄
총계   -3줄 + 중복 제거

정량적 성능 비교

시나리오 1: 일반적인 트래픽 패턴

가정:

  • 일 평균 1,000 페이지 뷰
  • 5개 컨트롤러에 균등 분포 (약 200회)
  • 카테고리 쿼리 실행 시간: 평균 3ms
지표 수정 전 수정 후 개선율
일일 쿼리 수 1,000회 ~24회1 -97.6%
일일 DB 시간 3,000ms (3초) 72ms -97.6%
월간 쿼리 수 30,000회 ~720회 -97.6%
월간 DB 시간 90,000ms (90초) 2,160ms (2.2초) -97.6%
1 캐시 TTL 1시간 기준, 24시간 x 1회 = 24회 (첫 요청만 DB 조회)

시나리오 2: 실제 캐시 hit/miss 패턴

00:00 - 첫 요청 → DB 쿼리 실행 → 캐시 저장 (1시간 TTL)
00:01~00:59 - 모든 요청 → 캐시에서 반환 (DB 쿼리 0회)
01:00 - TTL 만료
01:00 - 다음 요청 → DB 쿼리 실행 → 캐시 재저장
01:01~01:59 - 모든 요청 → 캐시에서 반환
... 반복

캐시 효율:

  • 시간당 첫 요청 1회만 DB 접근
  • 나머지 수백~수천 요청은 메모리에서 즉시 반환
  • 평균 캐시 hit rate: 99%+

시나리오 3: 게시글 작성/수정 시

# 게시글 카테고리 변경
post = Post.find(1)
post.update(category: "NEW_CATEGORY")
# → after_save 콜백 실행
# → clear_categories_cache 호출
# → Rails.cache.delete("categories_list")
# → 다음 요청 시 자동으로 새 데이터 로드

무효화 타이밍:

  • 게시글 저장 후 즉시 (수 ms)
  • 다음 페이지 요청 시 자동 갱신
  • 사용자에게 최신 데이터 보장

페이지 로딩 속도 개선

페이지 수정 전 (평균) 수정 후 (평균) 개선
145ms 132ms -13ms (-9%)
게시글 목록 168ms 152ms -16ms (-9.5%)
카테고리 페이지 156ms 140ms -16ms (-10.3%)
검색 결과 178ms 160ms -18ms (-10.1%)
평균 161.8ms 146ms -15.8ms (-9.8%)

측정 방법: Rails 로그의 Completed in XXms 값 50회 평균

메모리 사용량 분석

캐시 데이터 크기

# 실제 데이터 예시
categories = ["AI", "CS", "WEB", "MOBILE", "DATABASE"]

# 메모리 사용량
ObjectSpace.memsize_of(categories)
# => 약 200-300 bytes (문자열 + 배열 오버헤드)

결론: 메모리 사용량은 무시할 수 있는 수준 (수백 바이트)

캐시 저장소 설정

# config/environments/development.rb
config.cache_store = :memory_store, { size: 64.megabytes }

# config/environments/production.rb
config.cache_store = :redis_cache_store, {
  url: ENV['REDIS_URL'],
  expires_in: 1.hour
}

프로덕션 권장: Redis를 사용하여 여러 프로세스 간 캐시 공유

결론

핵심 교훈

  1. DRY 원칙과 성능 최적화의 조화
    - Concern 패턴으로 중복 제거 → 유지보수성 향상
    - Rails.cache로 메모리 캐싱 → 성능 향상
    - 두 마리 토끼를 모두 잡은 리팩토링
  2. 적절한 캐싱 레이어 선택
    - 데이터 특성 분석 (변경 빈도, 크기, 중요도)
    - Rails가 제공하는 다양한 캐싱 도구 이해
    - 과도한 캐싱은 오히려 복잡성 증가
  3. 캐시 무효화 전략의 중요성
    "There are only two hard things in Computer Science: 
    cache invalidation and naming things."
    - Phil Karlton
  4. 점진적 개선
    - 1단계: 중복 제거 (Concern)
    - 2단계: 기본 캐싱 (Rails.cache)
    - 3단계: 자동 무효화 (콜백)
    - (선택) 4단계: HTTP 캐싱, CDN 등

Rails 캐싱 베스트 프랙티스

✅ DO

# 1. 명확한 캐시 키 네이밍
Rails.cache.fetch("categories_list")  # Good
Rails.cache.fetch("cats")             # Bad

# 2. TTL 설정
Rails.cache.fetch("data", expires_in: 1.hour)  # Good
Rails.cache.fetch("data")                      # Bad (무한정 저장)

# 3. 조건부 무효화
after_save :clear_cache, if: :relevant_field_changed?  # Good
after_save :clear_cache                                # Bad (불필요한 무효화)

# 4. Concern으로 캐싱 로직 분리
include CategoriesCacheable  # Good (재사용 가능)
# 각 컨트롤러에 직접 구현  # Bad (중복)

❌ DON'T

# 1. 너무 긴 TTL
expires_in: 1.year  # Bad (데이터 불일치 위험)

# 2. 캐시 키에 동적 데이터 포함 안 함
fetch("posts")  # Bad (사용자별 다른 데이터를 같은 키로)
fetch("posts_user_#{user.id}")  # Good

# 3. 무효화 없이 캐싱만
fetch("data", expires_in: 1.week)  # Bad
# after_save 콜백 없음  # Bad (오래된 데이터 제공 가능)

# 4. 캐시 의존도 과다
# 모든 DB 쿼리를 캐싱 → 오히려 관리 복잡도 증가

추가 최적화 기회

1. HTTP 캐싱 레이어 추가 (브라우저/CDN)

# app/controllers/categories_controller.rb
def show
  @category_name = params[:name]
  @posts = Post.by_category(@category_name).recent
  
  # HTTP 캐싱 헤더 설정
  expires_in 30.minutes, public: true
  fresh_when(etag: [@category_name, @posts.maximum(:updated_at)])
end

효과:

  • 브라우저가 304 Not Modified 응답 받음 (대역폭 절약)
  • CDN에서 캐싱 가능 (서버 부하 완전 제거)

2. Fragment 캐싱 (뷰 레벨)

<!-- app/views/layouts/application.html.erb -->
<footer>
  <% cache "footer_categories", expires_in: 1.hour do %>
    <nav>
      <% @categories.each do |category| %>
        <%= link_to category, category_path(category) %>
      <% end %>
    </nav>
  <% end %>
</footer>

효과: HTML 렌더링까지 캐싱 (추가 2-3ms 절약)

3. Russian Doll 캐싱

# 중첩 캐싱으로 세밀한 무효화 제어
cache ["v1", "posts", @category, Post.by_category(@category).maximum(:updated_at)] do
  # ...
end

결과

적용 범위

컨트롤러 영향받는 페이지 쿼리 감소
HomeController / (홈) 매 요청 → 1시간 1회
PostsController /posts 매 요청 → 1시간 1회
CategoriesController /posts/category/:name 매 요청 → 1시간 1회
SearchController /search 매 요청 → 1시간 1회
AuthoritiesController /authors/:name 매 요청 → 1시간 1회

성과 요약

정량적 성과

  • DB 쿼리 97.6% 감소 (1,000회/일 → 24회/일)
  • 페이지 로딩 속도 평균 9.8% 개선 (161.8ms → 146ms)
  • 코드 중복 100% 제거 (5곳 → 1곳)
  • 월간 DB 부하 87.8초 절감 (90초 → 2.2초)

정성적 성과

  • ✅ 유지보수성 향상: 변경 시 한 곳만 수정
  • 테스트 용이성: Concern을 독립적으로 테스트 가능
  • 확장성 확보: 새 컨트롤러 추가 시 include CategoriesCacheable 한 줄로 해결
  • 데이터 일관성: 자동 캐시 무효화로 최신 데이터 보장

학습 포인트

  1. 성능 최적화의 80/20 법칙
    - 20%의 노력(Concern + Rails.cache)으로 80%의 효과 달성
    - 복잡한 분산 캐싱보다 간단한 메모리 캐싱이 효과적일 수 있음
  2. 캐싱의 트레이드오프
    - 성능 ↑ vs. 데이터 일관성 ↓
    - TTL 설정과 무효화 전략으로 균형 유지
  3. Rails Concern의 힘
    - 단순한 중복 제거 이상의 가치
    - 테스트 가능한 모듈화된 코드
  4. 점진적 개선의 중요성
    - 한 번에 모든 것을 완벽하게 만들 필요 없음
    - 측정 → 개선 → 재측정의 반복

실제 운영 결과

=== 캐시 통계 (1주일) ===
총 요청 수: 7,000건
캐시 히트: 6,832건 (97.6%)
캐시 미스: 168건 (2.4%)
평균 캐시 조회 시간: 0.1ms
평균 DB 쿼리 시간: 3.2ms

절약된 DB 시간: (7,000 - 168) × 3.2ms = 21,862ms (약 22초)

향후 개선 방향

  • [ ] Redis로 전환하여 프로세스 간 캐시 공유 (수평 확장 대비)
  • [ ] Fragment 캐싱 추가로 HTML 렌더링까지 캐싱
  • [ ] 캐시 워밍 전략 구현 (서버 시작 시 주요 캐시 미리 로드)
  • [ ] 캐시 모니터링 대시보드 구축 (hit rate, 메모리 사용량 등)
  • [ ] HTTP 캐싱 레이어 추가 (브라우저/CDN 활용)

참고 자료

Rails 공식 문서

캐싱 전략 참고 자료

관련 커밋

Previous Post

Rails 기반의 블로그에서 N+1 쿼리 문제 해결하기

ActiveRecord의 Eager Loading을 활용하여 데이터베이스 쿼리를 81.8% 감소시킨 최적화 과정에 대해 이야기합니다.

Rails 기반의 블로그에서 N+1 쿼리 문제 해결하기

Next Post

블로그 서버 장애 회고

로컬 서버의 한계와 데이터베이스 복구기에 대해 간단히 이야기합니다.

블로그 서버 장애 회고
scroll to top