ActiveRecord: Ruby on Rails에서 N+1 쿼리 문제 해결하기
ActiveRecord로 Rails의 N+1 쿼리를 탐지하고 해결하는 완전한 가이드입니다. includes, preload, eager_load와 자동 탐지 도구를 익혀보십시오.

N+1 쿼리는 Rails 애플리케이션에서 가장 흔한 성능 문제 중 하나입니다. 레코드를 단순히 순회하는 루프 하나가 수백 개의 불필요한 SQL 쿼리를 발생시켜 응답 시간을 크게 늦출 수 있습니다. 이 가이드는 성능이 좋은 Rails 애플리케이션을 만들기 위한 탐지와 해결 기법을 다룹니다.
50개의 글과 작성자를 함께 표시하는 페이지는 한 번이 아니라 51개의 SQL 쿼리를 만들 수 있습니다. 수천 명의 사용자를 가진 운영 환경에서는 이 문제가 응답 시간과 서버 부하에 큰 영향을 미칩니다.
N+1 문제 이해하기
N+1 문제는 코드가 레코드 목록을 가져오기 위해 한 번의 쿼리(1건)를 실행한 다음, 각 레코드의 연관 관계에 접근하기 위해 추가 쿼리를 실행할 때(N건) 발생합니다. "N+1"이라는 이름은 정확히 이 패턴을 가리킵니다. 즉, 초기 쿼리 1건과 연관 관계용 쿼리 N건을 의미합니다.
글과 작성자를 활용한 구체적인 예시가 이 문제를 잘 보여줍니다. 최적화하지 않으면 글의 작성자에 접근할 때마다 새로운 SQL 쿼리가 발생합니다.
# app/controllers/articles_controller.rb
# Example code generating an N+1 problem
class ArticlesController < ApplicationController
def index
# 1 query: SELECT * FROM articles
@articles = Article.all
end
end뷰에서는 article.author를 호출할 때마다 데이터베이스에 추가 쿼리가 발생합니다.
<!-- app/views/articles/index.html.erb -->
<!-- This view generates N additional queries -->
<% @articles.each do |article| %>
<div class="article">
<h2><%= article.title %></h2>
<!-- Each call generates: SELECT * FROM users WHERE id = ? -->
<p>By <%= article.author.name %></p>
</div>
<% end %>100개의 글에 대해 이 코드는 101개의 SQL 쿼리를 생성합니다. Rails 로그에는 반복되는 쿼리를 통해 문제가 분명히 드러납니다.
-- Rails logs showing the N+1 problem
-- 1 initial query
SELECT "articles".* FROM "articles"
-- N queries for authors (repeated for each article)
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
-- ... 97 more queriesincludes로 해결하기
includes 메서드는 N+1 문제를 해결하기 위해 가장 일반적이고 권장되는 방법입니다. ActiveRecord에 연관 관계를 한두 번의 최적화된 쿼리로 미리 로드하도록 지시합니다.
# app/controllers/articles_controller.rb
# Solution with includes - preloading authors
class ArticlesController < ApplicationController
def index
# Preloads authors with articles
# Generates only 2 queries instead of N+1
@articles = Article.includes(:author).all
end
endincludes를 사용하면 ActiveRecord는 글의 개수와 관계없이 두 개의 쿼리만 실행합니다. 첫 번째 쿼리는 모든 글을, 두 번째 쿼리는 관련된 모든 작성자를 가져옵니다.
-- Rails logs with includes (only 2 queries)
SELECT "articles".* FROM "articles"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5, ...)중첩된 연관 관계도 해시 문법으로 함께 미리 로드할 수 있습니다. 뷰가 여러 단계의 연관 관계에 접근할 때는 이 방법이 필수적입니다.
# app/controllers/articles_controller.rb
# Preloading nested associations
class ArticlesController < ApplicationController
def index
# Preloads author -> company and all comments
@articles = Article.includes(author: :company, comments: :user)
end
end뷰가 루프 안에서 연관 관계에 접근한다면, 그 연관 관계는 컨트롤러에서 includes로 미리 로드해야 합니다. 뷰에서의 연관 관계 접근 패턴은 항상 확인하는 것이 중요합니다.
includes, preload, eager_load의 차이
Rails는 연관 관계의 미리 로딩을 위해 세 가지 메서드를 제공합니다. 각각은 서로 다른 SQL 전략을 사용하며, 적합한 활용 사례도 다릅니다.
preload: 별도 쿼리
preload 메서드는 연관 관계마다 항상 별도의 쿼리를 실행합니다. 연관 관계에 대한 WHERE 조건이 없을 때 효율적으로 동작합니다.
# app/models/article.rb
# preload always uses separate queries
class Article < ApplicationRecord
scope :with_authors, -> { preload(:author) }
end
# Usage in controller
@articles = Article.with_authors.limit(20)
# SQL generated:
# SELECT "articles".* FROM "articles" LIMIT 20
# SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...)eager_load: LEFT OUTER JOIN
eager_load 메서드는 LEFT OUTER JOIN을 사용해 단일 쿼리로 데이터를 로드합니다. 연관 관계의 컬럼으로 필터링하거나 정렬해야 할 경우에는 필수적으로 사용해야 합니다.
# app/controllers/articles_controller.rb
# eager_load allows filtering on associations
class ArticlesController < ApplicationController
def verified_authors
# Filters articles by author status
# Requires eager_load because WHERE references users
@articles = Article.eager_load(:author)
.where(users: { verified: true })
.order("users.name ASC")
end
end
# SQL generated (single query with JOIN):
# SELECT "articles"."id", "articles"."title", ...
# FROM "articles"
# LEFT OUTER JOIN "users" ON "users"."id" = "articles"."author_id"
# WHERE "users"."verified" = TRUE
# ORDER BY "users"."name" ASCincludes: 지능적인 동작
includes 메서드는 자동으로 가장 적절한 전략을 선택합니다. 기본적으로 preload를 사용하지만, WHERE 절이 연관 관계를 참조하면 eager_load로 전환됩니다.
# app/controllers/articles_controller.rb
# includes adapts automatically to context
class ArticlesController < ApplicationController
def index
# No condition on association: uses preload (2 queries)
@articles = Article.includes(:author).all
end
def by_verified_authors
# With condition on association: uses eager_load (JOIN)
@articles = Article.includes(:author)
.where(users: { verified: true })
end
end다음 표는 세 메서드의 차이를 정리한 것입니다.
| 메서드 | SQL 전략 | 활용 사례 |
|--------|----------|-----------|
| preload | 별도 쿼리 | 단순한 사전 로딩, 필터링 없음 |
| eager_load | LEFT OUTER JOIN | 연관 관계 기반 필터링/정렬 |
| includes | 자동 | 일반 용도, 권장되는 기본값 |
자동화된 N+1 탐지
수작업으로 N+1 문제를 찾는 것은 번거롭고 실수가 잦습니다. 여러 도구가 개발 환경과 CI에서 이러한 탐지를 자동화해 줍니다.
Bullet: 실시간 탐지
Bullet 젬은 SQL 쿼리를 실시간으로 분석해 N+1 문제가 감지되면 알려줍니다. 적절한 수정 방법도 함께 제안합니다.
# Gemfile
# Bullet detects N+1 in development
group :development do
gem 'bullet'
end개발 환경 설정을 통해 다양한 알림 모드를 활성화할 수 있습니다.
# config/environments/development.rb
# Bullet configuration to detect N+1
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
# Display JavaScript alert in browser
Bullet.alert = true
# Add footer with details
Bullet.bullet_logger = true
# Display in Rails logs
Bullet.rails_logger = true
# Raise exception (useful in CI)
Bullet.raise = false
end
endN+1 문제가 감지되면 Bullet은 권장 해결책과 함께 명확한 메시지를 출력합니다.
# Example Bullet alert in logs
USE eager loading detected
Article => [:author]
Add to your query: .includes([:author])
Call stack:
/app/views/articles/index.html.erb:5지속적 통합 환경에서 Bullet.raise = true를 활성화하면 N+1 문제가 감지될 때 테스트가 실패합니다. 이를 통해 성능 회귀를 미리 차단할 수 있습니다.
Prosopite: 가벼운 대안
Prosopite 젬은 Bullet에 비해 가벼운 대안으로, 최소한의 설정과 테스트와의 호환성을 제공합니다.
# Gemfile
# Prosopite as an alternative to Bullet
group :development, :test do
gem 'prosopite'
end# config/environments/development.rb
# Prosopite configuration
Rails.application.configure do
config.after_initialize do
Prosopite.rails_logger = true
Prosopite.raise = Rails.env.test?
end
end고급 최적화 기법
기본적인 메서드 외에도 ActiveRecord 쿼리 최적화를 세밀하게 조정할 수 있는 여러 기법이 있습니다.
Strict Loading: 기본값으로의 예방
Rails 6.1 이상에서는 미리 로드되지 않은 연관 관계에 접근하면 예외를 발생시키는 strict loading 모드를 제공합니다. 이 예방적 접근은 개발 단계에서 N+1 문제 해결을 강제합니다.
# app/models/article.rb
# Enable strict loading by default on the model
class Article < ApplicationRecord
# Any non-preloaded association access raises an exception
self.strict_loading_by_default = true
belongs_to :author
has_many :comments
endstrict loading은 특정 쿼리 단위로도 활성화할 수 있습니다.
# app/controllers/articles_controller.rb
# Strict loading on a specific query
class ArticlesController < ApplicationController
def index
# Raises StrictLoadingViolationError if a non-included
# association is accessed
@articles = Article.strict_loading.includes(:author)
end
end부분 데이터를 위한 Select와 Pluck
특정 컬럼만 필요할 때 select와 pluck은 데이터베이스에서 전송되는 데이터의 양을 줄여줍니다.
# app/controllers/reports_controller.rb
# Optimization with select and pluck
class ReportsController < ApplicationController
def titles_only
# select returns Article objects with only id and title
@articles = Article.select(:id, :title)
end
def title_array
# pluck returns an Array of values, not AR objects
# More performant when only values are needed
@titles = Article.pluck(:title)
# => ["First article", "Second article", ...]
end
end카운트를 위한 Counter Cache
연관 관계의 카운트(article.comments.count)는 호출할 때마다 SQL 쿼리를 발생시킵니다. Counter cache는 이 카운트를 부모 테이블에 직접 저장합니다.
# app/models/comment.rb
# Counter cache configuration
class Comment < ApplicationRecord
# Rails automatically maintains the counter in articles.comments_count
belongs_to :article, counter_cache: true
end마이그레이션은 기본값을 가진 카운트 컬럼을 추가합니다.
# db/migrate/20260223_add_comments_count_to_articles.rb
# Migration to add counter cache
class AddCommentsCountToArticles < ActiveRecord::Migration[7.1]
def change
add_column :articles, :comments_count, :integer, default: 0, null: false
# Initialize counters for existing data
Article.find_each do |article|
Article.reset_counters(article.id, :comments)
end
end
end이 설정 후에는 article.comments_count가 추가 SQL 쿼리 없이 컬럼을 직접 읽습니다.
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>
<p><%= article.title %> - <%= article.comments_count %> comments</p>
<% end %>Ruby on Rails 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
모범 사례와 체크리스트
체계적인 접근은 신규 개발에서 N+1 문제를 예방하고 기존 코드를 점진적으로 개선합니다.
코드 작성 전 뷰 분석
컨트롤러 코드를 작성하기 전에 뷰를 분석해 접근하는 모든 연관 관계를 파악하는 것이 좋습니다. 이러한 사전 점검은 누락을 방지합니다.
# app/controllers/articles_controller.rb
# Pre-analyze view to identify required includes
class ArticlesController < ApplicationController
def show
# View accesses: author, author.company, comments, comments.user
# All these must be preloaded
@article = Article.includes(
author: :company,
comments: :user
).find(params[:id])
end
end재사용 가능한 스코프
자주 사용하는 includes를 스코프에 모아두면 유지보수가 쉬워지고 일관성이 보장됩니다.
# app/models/article.rb
# Reusable scopes for preloading
class Article < ApplicationRecord
# Scope for list display
scope :with_author, -> { includes(:author) }
# Scope for detailed display
scope :with_full_details, -> {
includes(
author: :company,
comments: { user: :avatar_attachment },
tags: []
)
}
# Scope for admin with all relations
scope :for_admin, -> {
includes(:author, :comments, :tags, :category)
.with_attached_cover_image
}
end예방 체크리스트
다음 체크리스트는 N+1 문제를 피하기 위한 핵심 점검 사항을 정리한 것입니다.
- 개발 환경에서 Bullet 또는 Prosopite를 설치하고 설정
- CI에서 Bullet.raise를 활성화해 회귀를 차단
- 컨트롤러를 작성하기 전에 뷰를 분석해 연관 관계를 식별
- 기본적으로
includes사용, 연관 관계 필터링 시eager_load사용 - 자주 사용되는 사전 로딩 패턴은 재사용 가능한 스코프로 작성
- 중요한 모델에는 strict loading 적용
- 빈번한 카운트에는 counter cache 추가
- 개발 환경에서 SQL 로그를 정기적으로 확인
너무 많은 연관 관계를 미리 로딩하면 메모리를 불필요하게 소비합니다. 뷰가 실제로 사용하는 연관 관계만 미리 로드해야 합니다. Bullet 같은 도구는 "unused eager loading"도 함께 감지합니다.
결론
N+1 쿼리는 Rails 애플리케이션에서 큰 성능 문제이지만, 충분히 예방할 수 있습니다. 자동 탐지 도구와 좋은 개발 관행을 결합하면 이 문제를 효과적으로 제거할 수 있습니다.
핵심 정리:
includes는 연관 관계를 미리 로드해 대부분의 N+1 사례를 해결합니다- 연관 관계로 필터링하거나 정렬할 때는
eager_load가 필요합니다 - Bullet과 Prosopite는 개발 환경에서 자동으로 문제를 탐지합니다
- strict loading은 예외를 발생시켜 N+1을 방지합니다
- counter cache는 빈번한 카운트를 최적화합니다
- 컨트롤러 작성 전 뷰 분석은 누락을 줄여줍니다
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

Ruby on Rails 면접 질문: 2026년 Top 25
가장 자주 묻는 Ruby on Rails 면접 질문 25선. MVC 아키텍처, Active Record, 마이그레이션, RSpec 테스트, REST API에 대한 자세한 답변과 코드 예제.

Ruby on Rails 7: 리액티브 애플리케이션을 위한 Hotwire와 Turbo
Rails 7에서 Hotwire와 Turbo 완벽 가이드. Turbo Drive, Frames, Streams로 JavaScript 없이 리액티브 애플리케이션을 구축하는 방법.

Action Cable과 WebSocket 완벽 가이드: 2026년 Ruby on Rails 기술 면접 대비
Ruby on Rails Action Cable과 WebSocket 심층 분석. 커넥션, 채널, 브로드캐스팅, Rails 8 Solid Cable, Redis 스케일링, 테스트 패턴까지 기술 면접에서 자주 출제되는 핵심 내용을 코드 예제와 함께 정리합니다.