상황
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
왜 캐싱이 필요한가?
카테고리 목록의 특성:
- 변경 빈도 낮음: 새 게시글 추가 시에만 변경 가능
- 조회 빈도 높음: 모든 페이지 요청마다 필요
- 데이터 크기 작음: 보통 5-10개 카테고리 (수십 바이트)
- 계산 비용: 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
무효화 조건:
after_save+if: :saved_change_to_category?
- 게시글 저장 시 카테고리가 실제로 변경된 경우만
- 제목 등 다른 필드 변경 시에는 무효화하지 않음 (효율적)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% |
시나리오 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를 사용하여 여러 프로세스 간 캐시 공유
결론
핵심 교훈
- DRY 원칙과 성능 최적화의 조화
- Concern 패턴으로 중복 제거 → 유지보수성 향상
- Rails.cache로 메모리 캐싱 → 성능 향상
- 두 마리 토끼를 모두 잡은 리팩토링 - 적절한 캐싱 레이어 선택
- 데이터 특성 분석 (변경 빈도, 크기, 중요도)
- Rails가 제공하는 다양한 캐싱 도구 이해
- 과도한 캐싱은 오히려 복잡성 증가 - 캐시 무효화 전략의 중요성
"There are only two hard things in Computer Science: cache invalidation and naming things." - Phil Karlton - 점진적 개선
- 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한 줄로 해결 - ✅ 데이터 일관성: 자동 캐시 무효화로 최신 데이터 보장
학습 포인트
- 성능 최적화의 80/20 법칙
- 20%의 노력(Concern + Rails.cache)으로 80%의 효과 달성
- 복잡한 분산 캐싱보다 간단한 메모리 캐싱이 효과적일 수 있음 - 캐싱의 트레이드오프
- 성능 ↑ vs. 데이터 일관성 ↓
- TTL 설정과 무효화 전략으로 균형 유지 - Rails Concern의 힘
- 단순한 중복 제거 이상의 가치
- 테스트 가능한 모듈화된 코드 - 점진적 개선의 중요성
- 한 번에 모든 것을 완벽하게 만들 필요 없음
- 측정 → 개선 → 재측정의 반복
실제 운영 결과
=== 캐시 통계 (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 활용)