AppSignal은 개발자들의 애플리케이션 성능 향상을 돕는 서비스입니다. 수십억 건의 요청을 처리하는 수많은 앱을 모니터링하다 보니, Ruby와 성능에 관한 블로그 글 몇 편으로도 여러분에게 도움을 드릴 수 있겠다는 생각을 했습니다. N+1 쿼리 문제는 Rails 애플리케이션에서 흔히 발견되는 안티패턴입니다.
Rails의 ActiveRecord를 비롯한 많은 ORM은 지연 로딩(lazy loading)을 기본 내장하고 있어, 연관 관계(association) 조회를 실제로 필요한 시점까지 미룰 수 있습니다. 덕분에 어떤 연관 데이터를 로드할지 명시적으로 결정하지 않아도 되며, 그 판단을 뷰(view)에 맡길 수 있습니다.
N+1 쿼리 문제는 흔하지만 비교적 쉽게 발견할 수 있는 성능 안티패턴으로, 각 연관 레코드마다 별도의 쿼리를 실행하게 만듭니다. 그结果 데이터베이스에서 다수의 연관 데이터를 가져올 때 상당한 오버헤드가 발생합니다.
👋 참고로, 이 글이 마음에 드셨다면 Ruby(Rails) 성능에 관한 글을 더 많이 준비해 두었습니다. Ruby 성능 모니터링 체크리스트도 꼭 확인해 보세요.
ActiveRecord의 지연 로딩
ActiveRecord는 암묵적 지연 로딩을 활용해 관계(relation)를 더 쉽게 다룰 수 있게 해줍니다. 간단한 쇼핑몰 예제를 살펴보겠습니다. 여기서 각 Product(상품)은 색상이나 사이즈 같은 정보를 담은 Variant(옵션)를 여러 개 가질 수 있습니다.
# app/models/product.rb
class Product < ActiveRecord::Base
has_many :variants
endProductsController#show, 즉 상품 상세 페이지에서는 Product.find(params[:id])로 해당 상품을 조회해 @product 변수에 할당합니다.
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
end
end이 액션의 뷰에서는 컨트롤러에서 전달받은 @product 객체의 variants 메서드를 호출하며 상품 옵션들을 순회합니다.
# app/views/products/show.html.erb
<h1><%= @product.title %></h1>
<ul>
<%= @product.variants.each do |variant| %>
<li><%= variant.name %></li>
<% end %>
</ul>뷰에서 @product.variants를 호출하면 Rails가 데이터베이스에 쿼리를 날려 옵션 목록을 가져와 순회할 수 있게 해줍니다. 컨트롤러에서 실행한 명시적 쿼리 외에, 요청 로그를 확인하면 옵션을 가져오기 위한 추가 쿼리가 하나 더 실행된 것을 볼 수 있습니다.
Started GET "/products/1" for 127.0.0.1 at 2018-04-19 08:49:13 +0200
Processing by ProductsController#show as HTML
Parameters: {"id"=>"1"}
Product Load (1.1ms) SELECT "products".* FROM "products" WHERE "products"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Rendering products/show.html.erb within layouts/application
Variant Load (1.1ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ? [["product_id", 1]]
Rendered products/show.html.erb within layouts/application (4.4ms)
Completed 200 OK in 64ms (Views: 56.4ms | ActiveRecord: 2.3ms)
이 요청은 상품과 모든 옵션을 화면에 표시하기 위해 두 번의 쿼리를 실행했습니다.
SELECT "products".* FROM "products" WHERE "products"."id" = 1 LIMIT 1SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1
반복문 속 지연 로딩
여기까지만 보면 지연 로딩은 훌륭합니다. 암묵적인 쿼리를 사용하면, 예를 들어 나중에 이 뷰에서 옵션을 더 이상 보여주지 않기로 결정했을 때 컨트롤러 코드를 일일이 정리할 필요가 없으니까요.
이번에는 ProductsController#index에서 모든 상품과 각 상품의 옵션을 함께 보여주는 목록을 만든다고 가정해 봅시다. 지연 로딩으로 앞서와 동일한 방식으로 구현할 수 있습니다.
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def index
@products = Product.all
end
end# app/views/products/index.html.erb
<h1>Products</h1>
<% @products.each do |product| %>
<article>
<h1><%= product.title %></h1>
<ul>
<% product.variants.each do |variant| %>
<li><%= variant.description %></li>
<% end %>
</ul>
</article>
<% end %>첫 번째 예제와 달리 이번에는 단일 상품이 아닌 상품 목록을 컨트롤러에서 전달합니다. 뷰는 각 상품을 순회하면서 상품마다 옵션을 지연 로딩합니다.
동작은 하지만 한 가지 함정이 숨어 있습니다. 쿼리 수가 N+1이 되어 버리는 것입니다.
N+1 쿼리 문제란?
첫 번째 예제에서는 단일 상품과 그 옵션을 보여주는 뷰를 렌더링했고, 두 번의 쿼리를 실행했으므로 쿼리 수는 2였습니다. 반면 이번 요청은 데이터베이스에서 모든 상품(이 예제에서는 3개)과 각 상품의 옵션을 반환하며, 쿼리를 두 번이 아니라 네 번 실행했습니다.
Started GET "/products" for 127.0.0.1 at 2018-04-19 09:49:02 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.3ms) SELECT "products".* FROM "products"
Variant Load (0.2ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ? [["product_id", 1]]
Variant Load (0.2ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ? [["product_id", 2]]
Variant Load (0.1ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = ? [["product_id", 3]]
Rendered products/index.html.erb within layouts/application (5.6ms)
Completed 200 OK in 36ms (Views: 32.6ms | ActiveRecord: 0.8ms)
SELECT "products".* FROM "products"SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 1SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 2SELECT "variants".* FROM "variants" WHERE "variants"."product_id" = 3
첫 번째 쿼리는 컨트롤러의 Product.all 호출로 실행된 것으로, 모든 상품을 조회합니다. 이후 쿼리들은 뷰에서 각 상품을 순회하는 과정에서 지연 실행됩니다.
이 예제의 쿼리 수는 N+1입니다. 여기서 N은 상품 수이고, +1은 모든 상품을 가져온 명시적 쿼리입니다. 다시 말해, 처음 한 번의 쿼리를 실행한 뒤 그 결과 각각에 대해 추가 쿼리가 하나씩 실행되는 구조입니다. 이 예제에서 N=3이므로 쿼리 수는 N + 1 = 3 + 1 = 4가 됩니다.
상품이 세 개뿐일 때는 큰 문제가 아니어 보이지만, 쿼리 수는 상품 수에 비례해 늘어납니다. 이 요청의 쿼리 수가 N+1이라는 점을 알고 있으므로, 예컨대 상품이 100개라면 쿼리 수가 101개(N + 1 = 100 + 1 = 101)가 될 것임을 예측할 수 있습니다.
즉시 로딩(Eager Loading)으로 연관 데이터 미리 불러오기
지금처럼 상품 수가 늘어날수록 쿼리 수가 증가하는 대신, 이 뷰에서는 고정된 쿼리 수를 유지하는 것이 바람직합니다. 뷰를 렌더링하기 전에 컨트롤러에서 옵션을 명시적으로 미리 로딩(preload)하면 됩니다.
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def index
@products = Product.all.includes(:variants)
end
endActiveRecord의 includes 쿼리 메서드는 연관된 옵션들이 상품과 함께 로드되도록 보장합니다. 어떤 옵션을 미리 로드해야 하는지 알고 있기 때문에, 요청된 모든 상품의 옵션을 단 한 번의 쿼리로 가져올 수 있습니다.
Started GET "/products" for 127.0.0.1 at 2018-04-19 10:33:59 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.3ms) SELECT "products".* FROM "products"
Variant Load (0.4ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (?, ?, ?) [["product_id", 1], ["product_id", 2], ["product_id", 3]]
Rendered products/index.html.erb within layouts/application (5.9ms)
Completed 200 OK in 45ms (Views: 40.8ms | ActiveRecord: 0.7ms)
옵션을 미리 로드하면 앞으로 상품 수가 늘어나더라도 쿼리 수는 2로 유지됩니다.
SELECT "products".* FROM "products"SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (1, 2, 3)
지연 로딩 vs 즉시 로딩, 무엇을 선택할까?
대부분의 상황에서 연관 레코드를 단일 쿼리로 한꺼번에 가져오는 것이 지연 로딩보다 훨씬 빠릅니다.
이 예제 애플리케이션에서는 상품 3개, 각 상품당 옵션 10개만으로도 데이터베이스 성능 차이를 측정할 수 있습니다. 평균적으로 상품 목록을 즉시 로딩하는 것이 지연 로딩보다 약 12.5% 빠릅니다(0.7ms vs 0.8ms). 상품이 10개가 되면 차이는 59%(1.22ms vs 2.98ms)로 벌어집니다. 상품 1000개 기준으로는 거의 80% 차이가 나는데, 즉시 로딩은 58.4ms인 반면 지연 로딩은 약 290.12ms가 걸립니다.
지연 로딩된 연관 데이터는 컨트롤러를 수정하지 않고도 뷰에서 더 유연하게 사용할 수 있다는 장점이 있지만, 좋은 경험 법칙은 데이터 로딩을 컨트롤러가 담당한 뒤 뷰에 넘겨주는 것입니다.
뷰에서의 지연 로딩은 하나의 모델 객체와 그 연관 데이터를 보여주는 뷰(첫 번째 예제의 ProductsController#show처럼)에는 적합하며, 예를 들어 같은 컨트롤러를 공유하는 여러 뷰가 서로 다른 데이터를 필요로 할 때 유용할 수 있습니다.
마무리: 고양이와 인형
고양이들은 동의하지 않겠지만, 때로는 게으르기보다 적극적인(eager) 것이 이득입니다. 이 글에서는 ActiveRecord의 지연 로딩을 자세히 살펴보고, 지연 로딩이 성능 문제로 이어질 수 있는 상황, 특히 N+1 쿼리 문제를 유발하는 경우를 예제로 통해 확인했습니다.
요약하면: 개발 로그나 AppSignal의 이벤트 타임라인을 항상 주시하여 지연 로딩으로 처리할 수 있는 쿼리를 남발하고 있지 않은지 점검하고, 특히 처리하는 데이터 양이 늘어나는 시점에 응답 시간을 꾸준히 추적하세요.
이 글이 도움이 되었다면 성능과 모니터링에 관한 다른 글들도 확인해 보세요. Russian Doll Caching에 관한 인기 글이나 Conditional Get Requests에 관한 글을 추천합니다.