N+1 쿼리는 ActiveRecord 모델 간의 연관 관계에서 발생합니다. 예를 들어, Author가 여러 Post를 가지고, 각 Post가 여러 Tag를 가지는 상황을 가정해봅시다. 저자 목록을 표시하면서 각 저자의 게시물과 태그를 함께 보여주려 할 때, 연관된 데이터를 로드하는 방식에 따라 N+1 쿼리가 발생할 수 있습니다.
N+1 쿼리 문제 이해
Author, Post, Tag 모델이 다음과 같이 정의되어 있다고 가정합니다:
ruby
class Author < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :author
has_many :tags
end
class Tag < ApplicationRecord
belongs_to :post
end
이러한 모델 관계에서 저자의 게시물과 태그를 반복적으로 접근할 때, Rails는 기본적으로 각 연관 레코드에 대해 별도의 쿼리를 실행하게 됩니다. 이는 데이터가 많아질수록 쿼리 수가 기하급수적으로 증가하는 N+1 문제를 야기합니다.
최적화 전: 비효율적인 쿼리
최적화되지 않은 상태에서 저자, 게시물, 태그를 모두 가져오는 코드는 다음과 같습니다. ```ruby # AuthorsController
index
@authors = Author.all.order(:name) # app/views/authors/index.html.erb <% @authors.each do |author| %>
<%= author.name %>
-
<% author.posts.each do |post| %>
- <%= post.title %> - <%= post.tags.map(&:name).join(", ") %> <% end %>
<% end %> ``` 이 경우, 3명의 저자가 각각 3개의 게시물을 가지고 각 게시물이 3개의 태그를 가진다고 할 때, 총 13개의 쿼리가 발생합니다. 만약 100명의 저자, 각 10개의 게시물, 각 5개의 태그가 있다면 총 1,101개의 쿼리가 발생하며, 페이지 로드 시간이 1.007초까지 지연될 수 있습니다.
부분 최적화: includes(:posts)
Rails는 N+1 쿼리 문제를 해결하기 위해 includes 메서드를 제공합니다. 먼저 게시물만 미리 로드하여 부분적으로 최적화할 수 있습니다.
ruby
class AuthorsController < ApplicationController
def index
@authors = Author.all.includes(:posts).order(:name)
end
end
이 변경으로 저자와 게시물은 단일 쿼리로 미리 로드되지만, 태그에 대한 N+1 쿼리는 여전히 남아있습니다. 100명의 저자 예시에서 총 1,002개의 쿼리로 줄어들고, 페이지 로드 시간은 1.007초에서 0.678초로 32% 개선됩니다.
완전 최적화: includes(posts: [:tags])
includes 메서드는 해시를 사용하여 중첩된 연관 관계도 미리 로드할 수 있습니다. 게시물뿐만 아니라 태그까지 함께 미리 로드하여 완벽하게 최적화할 수 있습니다.
ruby
class AuthorsController < ApplicationController
def index
@authors = Author.all.includes(posts: [:tags]).order(:name)
end
end
이 코드를 사용하면 저자, 게시물, 태그 모두 단일 쿼리로 대량 선택되어 총 3개의 쿼리만 발생합니다. 페이지 로드 시간은 0.678초에서 0.126초로 81% 추가 개선되어, 초기 상태 대비 87%의 성능 향상을 이룰 수 있습니다.