# ActiveRecord: Ruby on Rails에서 N+1 쿼리 문제 해결하기
> ActiveRecord로 Rails의 N+1 쿼리를 탐지하고 해결하는 완전한 가이드입니다. includes, preload, eager_load와 자동 탐지 도구를 익혀보십시오.
- Published: 2026-02-23
- Updated: 2026-04-29
- Author: Anthony Fillion-Maillet
- Tags: ruby on rails, activerecord, performance, n+1 queries, sql optimization
- Reading time: 10 min
---
N+1 쿼리는 Rails 애플리케이션에서 가장 흔한 성능 문제 중 하나입니다. 레코드를 단순히 순회하는 루프 하나가 수백 개의 불필요한 SQL 쿼리를 발생시켜 응답 시간을 크게 늦출 수 있습니다. 이 가이드는 성능이 좋은 Rails 애플리케이션을 만들기 위한 탐지와 해결 기법을 다룹니다.
> **운영 환경에서의 영향**
>
> 50개의 글과 작성자를 함께 표시하는 페이지는 한 번이 아니라 51개의 SQL 쿼리를 만들 수 있습니다. 수천 명의 사용자를 가진 운영 환경에서는 이 문제가 응답 시간과 서버 부하에 큰 영향을 미칩니다.
## N+1 문제 이해하기
N+1 문제는 코드가 레코드 목록을 가져오기 위해 한 번의 쿼리(1건)를 실행한 다음, 각 레코드의 연관 관계에 접근하기 위해 추가 쿼리를 실행할 때(N건) 발생합니다. "N+1"이라는 이름은 정확히 이 패턴을 가리킵니다. 즉, 초기 쿼리 1건과 연관 관계용 쿼리 N건을 의미합니다.
글과 작성자를 활용한 구체적인 예시가 이 문제를 잘 보여줍니다. 최적화하지 않으면 글의 작성자에 접근할 때마다 새로운 SQL 쿼리가 발생합니다.
```ruby
# 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`를 호출할 때마다 데이터베이스에 추가 쿼리가 발생합니다.
```erb
<% @articles.each do |article| %>
<%= article.title %>
By <%= article.author.name %>
<% end %>
```
100개의 글에 대해 이 코드는 101개의 SQL 쿼리를 생성합니다. Rails 로그에는 반복되는 쿼리를 통해 문제가 분명히 드러납니다.
```sql
-- 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 queries
```
## includes로 해결하기
`includes` 메서드는 N+1 문제를 해결하기 위해 가장 일반적이고 권장되는 방법입니다. ActiveRecord에 연관 관계를 한두 번의 최적화된 쿼리로 미리 로드하도록 지시합니다.
```ruby
# 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
end
```
`includes`를 사용하면 ActiveRecord는 글의 개수와 관계없이 두 개의 쿼리만 실행합니다. 첫 번째 쿼리는 모든 글을, 두 번째 쿼리는 관련된 모든 작성자를 가져옵니다.
```sql
-- Rails logs with includes (only 2 queries)
SELECT "articles".* FROM "articles"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5, ...)
```
중첩된 연관 관계도 해시 문법으로 함께 미리 로드할 수 있습니다. 뷰가 여러 단계의 연관 관계에 접근할 때는 이 방법이 필수적입니다.
```ruby
# 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 조건이 없을 때 효율적으로 동작합니다.
```ruby
# 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을 사용해 단일 쿼리로 데이터를 로드합니다. 연관 관계의 컬럼으로 필터링하거나 정렬해야 할 경우에는 필수적으로 사용해야 합니다.
```ruby
# 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" ASC
```
### includes: 지능적인 동작
`includes` 메서드는 자동으로 가장 적절한 전략을 선택합니다. 기본적으로 `preload`를 사용하지만, WHERE 절이 연관 관계를 참조하면 `eager_load`로 전환됩니다.
```ruby
# 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 문제가 감지되면 알려줍니다. 적절한 수정 방법도 함께 제안합니다.
```ruby
# Gemfile
# Bullet detects N+1 in development
group :development do
gem 'bullet'
end
```
개발 환경 설정을 통해 다양한 알림 모드를 활성화할 수 있습니다.
```ruby
# 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
end
```
N+1 문제가 감지되면 Bullet은 권장 해결책과 함께 명확한 메시지를 출력합니다.
```text
# 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
```
> **CI에서의 Bullet**
>
> 지속적 통합 환경에서 `Bullet.raise = true`를 활성화하면 N+1 문제가 감지될 때 테스트가 실패합니다. 이를 통해 성능 회귀를 미리 차단할 수 있습니다.
### Prosopite: 가벼운 대안
Prosopite 젬은 Bullet에 비해 가벼운 대안으로, 최소한의 설정과 테스트와의 호환성을 제공합니다.
```ruby
# Gemfile
# Prosopite as an alternative to Bullet
group :development, :test do
gem 'prosopite'
end
```
```ruby
# 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 문제 해결을 강제합니다.
```ruby
# 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
end
```
strict loading은 특정 쿼리 단위로도 활성화할 수 있습니다.
```ruby
# 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`은 데이터베이스에서 전송되는 데이터의 양을 줄여줍니다.
```ruby
# 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는 이 카운트를 부모 테이블에 직접 저장합니다.
```ruby
# 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
```
마이그레이션은 기본값을 가진 카운트 컬럼을 추가합니다.
```ruby
# 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 쿼리 없이 컬럼을 직접 읽습니다.
```ruby
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>