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

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

상황

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 쿼리 문제는 해결했지만, 추가 개선 가능한 영역:

  1. 데이터베이스 인덱스 추가
    - posts 테이블의 user_id에 인덱스 생성
    - JOIN 성능 향상
    add_index :posts, :user_id
  2. 캐싱 레이어 추가
    - 자주 조회되는 User 정보를 Redis에 캐싱
    - 아래와 같이 응답 시간 추가 단축 가능
    def author_display_name
      Rails.cache.fetch("user:#{user_id}:nickname", expires_in: 1.hour) do
        user&.nickname || author_name
      end
    end
  3. 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회 쿼리)

학습 포인트

  1. ORM의 특성 이해: ActiveRecord는 기본적으로 Lazy Loading을 사용하여 N+1 문제가 쉽게 발생한다.
  2. 미리 설계하기: 모델 설계 시점에 연관 관계 로딩 전략을 함께 고려한다.
  3. 측정의 중요성: 로그와 도구를 활용해 문제를 정량적으로 파악하고 개선 효과를 검증한다.
  4. 작은 변경, 큰 영향: 단 몇 줄의 코드가 시스템 성능을 극적으로 바꿀 수 있다.

향후 개선 방향

  • [ ] bullet gem을 CI/CD 파이프라인에 통합하여 N+1 문제를 자동 감지
  • [ ] 다른 컨트롤러에도 동일한 패턴 적용 (SearchController, HomeController 등)
  • [ ] 복합 인덱스 추가로 쿼리 실행 시간 자체를 단축
  • [ ] GraphQL 도입 검토 (연관 관계 로딩을 '선언적'으로 관리)

참고 자료

Previous Post

브라우저별 HTTP 캐싱과 세션 상태 불일치 문제 해결하기

Rails 8의 HTTP 캐싱(fresh_when/stale?)과 브라우저 캐시가 세션 상태 변경을 반영하지 못해 발생한 로그인 후 UI 불일치 문제를 해결한 과정을 다룹니다.

브라우저별 HTTP 캐싱과 세션 상태 불일치 문제 해결하기

Next Post

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

Concern 패턴과 Rails.cache를 활용하여 중복 쿼리를 제거하고 페이지 로딩 속도를 10-15% 개선한 최적화 과정에 대해 이야기합니다.

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