# 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| %>

<%= article.title %> - <%= article.comments_count %> comments

<% end %> ``` ## 모범 사례와 체크리스트 체계적인 접근은 신규 개발에서 N+1 문제를 예방하고 기존 코드를 점진적으로 개선합니다. ### 코드 작성 전 뷰 분석 컨트롤러 코드를 작성하기 전에 뷰를 분석해 접근하는 모든 연관 관계를 파악하는 것이 좋습니다. 이러한 사전 점검은 누락을 방지합니다. ```ruby # 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를 스코프에 모아두면 유지보수가 쉬워지고 일관성이 보장됩니다. ```ruby # 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는 빈번한 카운트를 최적화합니다 - 컨트롤러 작성 전 뷰 분석은 누락을 줄여줍니다 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/ruby-on-rails/activerecord-fixing-n-plus-1-query-problems