상황
Rails 8 기반 블로그 애플리케이션에서 게시글 목록 페이지와 카테고리별 게시글 페이지의 성능 문제가 발견되었다. 10개의 게시글을 표시하는데 예상보다 훨씬 많은 데이터베이스 쿼리가 발생하고 있었다.
발생 증상
| 페이지 | 게시글 수 | 실제 쿼리 수 | 예상 쿼리 수 | 문제점 |
게시글 목록 (/posts) |
10개 | 11회 | 1~2회 | 10배 과다 |
카테고리 페이지 (/posts/category/AI) |
10개 | 11회 | 1~2회 | 10배 과다 |
비즈니스 영향
- 페이지 로딩 시간 증가: 데이터베이스 왕복 횟수 증가로 인하 지연 발생
- 데이터베이스 부하: 불필요한 쿼리로 인하 서버 리소스 낭비 발생
- 프로덕션 리스크: 트래픽 증가 시 데이터베이스 병목 현상 우려
원인분석
N+1 쿼리 문제란?
N+1 쿼리 문제는 ORM(Object-Relational Mapping)을 사용할 때 발생하는 전형적인 성능 문제다. 부모 레코드 N개를 조회한 후, 각 레코드의 연관 관계를 조회하기 위해 N번의 추가 쿼리가 실행되는 현상이다.
1번 쿼리: 부모 레코드 N개 조회
+ N번 쿼리: 각 부모의 자식 레코드 조회
= 총 N+1번의 쿼리 실행
코드 구조 분석
Post 모델
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user, optional: true
# User 정보를 우선 사용, 없으면 fallback
def author_display_name
user&.nickname || author_name
end
def author_display_avatar
user&.email&.then { |email|
"https://www.gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}"
} || author_avatar
end
end
문제가 있던 컨트롤러 코드
# PostsController (수정 전)
def index
@posts = Post.published.recent.to_a # ❌ User 연관 관계 로드 안 함
end
# CategoriesController (수정 전)
def show
@posts = Post.published.by_category(@category_name).recent # ❌ 동일한 문제
end
뷰에서의 사용
<% @posts.each do |post| %>
<%= render "shared/post_card", post: post %>
<% end %>
뷰나 partial에서 post.author_display_name 또는 post.author_display_avatar 같은 메서드를 호출하면, 내부적으로 user&.nickname이나 user&.email에 접근하게 된다.
쿼리 실행 흐름 분석
수정 전 (N+1 문제 발생)
-- 1. 게시글 10개 조회
SELECT * FROM posts WHERE published_at <= '2025-11-09' ORDER BY published_at DESC;
-- 2. 각 게시글마다 User 조회 (N=10)
SELECT * FROM users WHERE id = 1; -- Post #1의 user
SELECT * FROM users WHERE id = 1; -- Post #2의 user
SELECT * FROM users WHERE id = 1; -- Post #3의 user
...
SELECT * FROM users WHERE id = 2; -- Post #10의 user
-- 총 11번의 쿼리 실행 (1 + 10)
Rails는 각 post.user 접근 시마다 별도의 쿼리를 실행한다. 설령 같은 User를 조회하더라도 캐싱 없이 매번 쿼리가 발생한다.
수정 후 (Eager Loading 적용)
-- 1. 게시글 10개 조회
SELECT * FROM posts WHERE published_at <= '2025-11-09' ORDER BY published_at DESC;
-- 2. 연관된 User 일괄 조회 (IN 절 사용)
SELECT * FROM users WHERE id IN (1, 2);
-- 총 2번의 쿼리 실행 (1 + 1)
includes(:user)를 사용하면 Rails가 연관 관계를 미리(eagerly) 로드하여 추가 쿼리를 방지한다.
가설 및 해결방법
가설 설정
가설: includes(:user)를 통한 Eager Loading을 적용하면 N+1 쿼리를 제거하고 성능을 크게 개선할 수 있다.
근거:
- Rails 공식 문서에서 N+1 쿼리 해결책으로
includes권장 - 연관 관계 조회를 일괄 처리하면 데이터베이스 왕복 횟수 감소
- 네트워크 레이턴시가 제거되어 응답 속도 향상
해결 전략
flowchart TD
A[게시글 목록 요청] --> B{"includes(:user) 사용?"}
B -->|No| C[게시글 조회 1회]
C --> D[각 게시글마다 User 조회 N회]
D --> E[총 N+1회 쿼리 ❌]
B -->|Yes| F[게시글 + User 조회]
F --> G[게시글 쿼리 1회]
G --> H[User 일괄 쿼리 1회]
H --> I[총 2회 쿼리 ✅]
style E fill:#f44,color:#fff
style I fill:#4CAF50,color:#fff
ActiveRecord의 Eager Loading 전략
Rails는 세 가지 연관 관계 로딩 전략을 제공한다:
| 전략 | 메서드 | 쿼리 방식 | 사용 시점 |
| Lazy Loading | 없음 | N+1 쿼리 발생 | 연관 관계 미사용 시 |
| Eager Loading | includes |
LEFT OUTER JOIN 또는 IN 절 | N+1 방지 (권장) |
| Preloading | preload |
항상 별도 쿼리 (IN 절) |
JOIN 불가 시 |
| Eager Loading (JOIN) | eager_load |
항상 LEFT OUTER JOIN | WHERE 조건 필요 시 |
우리 케이스에서는 includes가 최적이다:
- User에 대한 WHERE 조건이 없음
- 두 개의 쿼리로 충분 (JOIN보다 간결)
- Rails가 자동으로 최적 전략 선택
해결시도 및 정량적 비교
수정 코드
PostController
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
# 수정 전
# @posts = Post.published.recent.to_a
# 수정 후: N+1 쿼리 방지를 위해 includes(:user) 사용
@posts = Post.published.includes(:user).recent.to_a
end
end
CategoriesController
# app/controllers/categories_controller.rb
class CategoriesController < ApplicationController
def show
@category_name = params[:name].upcase
# 수정 전
# @posts = Post.published.by_category(@category_name).recent
# 수정 후: N+1 쿼리 방지를 위해 includes(:user) 사용
@posts = Post.published.includes(:user).by_category(@category_name).recent
end
end
변경 사항 요약
| 파일 | 변경 내용 | 코드 라인 수 |
app/controllers/posts_controller.rb |
includes(:user) 추가 |
+1줄 |
app/controllers/categories_controller.rb |
includes(:user) 추가 |
+1줄 |
db/seeds.rb |
User 레코드 생성 및 연결 | +20줄 |
총 변경량: 단 2줄의 코드 추가로 문제 해결
정량적 성능 비교
쿼리 수 측정
Rails 로그(log/development.log)에서 실제 쿼리를 확인한 결과:
| 지표 | 수정 전 | 수정 후 | 개선율 |
| 총 쿼리 수 | 11회 | 2회 | -81.8% |
| 게시글 조회 | 1회 | 1회 | 0% |
| User 조회 | 10회 (개별) | 1회 (일괄) | -90% |
| 데이터베이스 왕복 | 11회 | 2회 | -81.8% |
쿼리 패턴 비교
수정 전 (N+1 문제)
Post Load (0.5ms) SELECT * FROM posts ORDER BY published_at DESC LIMIT 10
User Load (0.2ms) SELECT * FROM users WHERE id = 1
User Load (0.2ms) SELECT * FROM users WHERE id = 1
User Load (0.2ms) SELECT * FROM users WHERE id = 1
...
User Load (0.2ms) SELECT * FROM users WHERE id = 2
-- 총 소요 시간: 0.5ms + (0.2ms × 10) = 2.5ms
수정 후 (Eager Loading)
Post Load (0.5ms) SELECT * FROM posts ORDER BY published_at DESC LIMIT 10
User Load (0.3ms) SELECT * FROM users WHERE id IN (1, 2)
-- 총 소요 시간: 0.5ms + 0.3ms = 0.8ms (68% 개선)
실제 환경에서의 영향
| 시나리오 | 게시글 수 | 수정 전 쿼리 | 수정 후 쿼리 | 절약된 쿼리 |
| 초기 데이터 | 10개 | 11회 | 2회 | 9회 (-81.8%) |
| 성장 단계 | 50개 | 51회 | 2회 | 49회 (-96.1%) |
| 확장 단계 | 100개 | 101회 | 2회 | 99회 (-98.0%) |
핵심 인사이트
- ✅ 게시글이 늘어날수록 Eager Loading의 효과가 극대화된다.
검증 방법
Rails 콘솔에서 쿼리 확인
# N+1 문제 재현
posts = Post.published.recent.limit(10)
posts.each { |p| p.user&.nickname }
# => 11개 쿼리 발생
# Eager Loading 적용
posts = Post.published.includes(:user).recent.limit(10)
posts.each { |p| p.user&.nickname }
# => 2개 쿼리만 발생
Bullet Gem으로 자동 감지 (개발 환경)
# Gemfile
gem 'bullet', group: :development
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.console = true
end
Bullet은 N+1 쿼리를 자동으로 감지하고 경고를 표시한다.
결론
핵심 교훈
ActiveRecord에서 연관 관계를 사용할 때는 항상 Eager Loading을 고려해야 한다. 이를 단 한 줄의 코드 추가(includes)로 극적인 성능 개선을 달성할 수 있다.
N+1 쿼리 방지를 위한 체크리스트
✅ 개발 단계
- [ ]
belongs_to,has_many등 연관 관계 정의 시 Eager Loading 계획 수립 - [ ] 컨트롤러에서 컬렉션 조회 시
includes,preload,eager_load사용 - [ ] 뷰에서 연관 객체 접근 패턴 파악 (예:
post.user.name)
✅ 테스트 단계
- [ ] Bullet Gem으로 N+1 쿼리 자동 감지 활성화
- [ ] Rails 로그에서 실행 쿼리 수 확인 (
log/development.log) - [ ] 성능 프로파일링 MiniProfiler, rack-mini-profiler 사용)
✅ 프로덕션 배포 전
- [ ] 주요 페이지의 쿼리 수 벤치마크 확인
- [ ] 데이터베이스 슬로우 쿼리 로그 점검
- [ ] APM 도구(New Relic, DataDog 등)로 모니터링 설정
Rails Eager Loading 패턴 가이드
| 상황 | 사용 메서드 | 예시 |
| 단일 연관 관계 | includes(:association) |
Post.includes(:user) |
| 다중 연관 관계 | includes(:a, :b) |
Post.includes(:user, :category) |
| 중첩 연관 관계 | includes(a: :b) |
Post.includes(user: :profile) |
| 연관 관계에 조건 | eager_load(:a).where(...) |
Post.eager_load(:user).where(users: { verified: true }) |
추가 최적화 가능한 부분
이번 수정으로 N+1 쿼리 문제는 해결했지만, 추가 개선 가능한 영역:
- 데이터베이스 인덱스 추가
-posts테이블의user_id에 인덱스 생성
- JOIN 성능 향상
add_index :posts, :user_id - 캐싱 레이어 추가
- 자주 조회되는 User 정보를 Redis에 캐싱
- 아래와 같이 응답 시간 추가 단축 가능
def author_display_name Rails.cache.fetch("user:#{user_id}:nickname", expires_in: 1.hour) do user&.nickname || author_name end end - SELECT 절 최적화
- 필요한 컬럼만 조회 (select)
- 메모리 사용량 감소
결과
적용 범위
| 컨트롤러 | 영향받는 엔드포인트 | 쿼리 감소 여부 |
PostsController |
/posts |
11회 → 2회 |
CategoriesController |
/posts/category/:name |
11회 → 2회 |
성과 요약
- ✅ 데이터베이스 쿼리 81.8 감소 (11회 → 2회)
- ✅ 페이지 로딩 시간 68% 개선 (2.5ms → 0.8ms, 쿼리 실행 시간 기준)
- ✅ 코드 변경 최소화 (코드 2줄 추가)
- ✅ 확장성 확보 (게시글 100개 시 101회 → 2회 쿼리)
학습 포인트
- ORM의 특성 이해: ActiveRecord는 기본적으로 Lazy Loading을 사용하여 N+1 문제가 쉽게 발생한다.
- 미리 설계하기: 모델 설계 시점에 연관 관계 로딩 전략을 함께 고려한다.
- 측정의 중요성: 로그와 도구를 활용해 문제를 정량적으로 파악하고 개선 효과를 검증한다.
- 작은 변경, 큰 영향: 단 몇 줄의 코드가 시스템 성능을 극적으로 바꿀 수 있다.
향후 개선 방향
- [ ]
bulletgem을 CI/CD 파이프라인에 통합하여 N+1 문제를 자동 감지 - [ ] 다른 컨트롤러에도 동일한 패턴 적용 (
SearchController,HomeController등) - [ ] 복합 인덱스 추가로 쿼리 실행 시간 자체를 단축
- [ ] GraphQL 도입 검토 (연관 관계 로딩을 '선언적'으로 관리)