백엔드 개발자로서 서버와 데이터베이스 간 요청하는 횟수 및 데이터를 최대한 적게 하는 것이 기본 소양이다. 레일즈에서 N+1 쿼리를 잡기위하여 개발환경에 Bullet 과 Prosopite 젬을 설치하였고 실험을 통해 어느 젬이 좋은지(?) 비교해보려고 한다.
Bullet 의 좋은 점은 어느 코드에서 N+1 이 검출됐는지 알려주고 또 어떤 코드를 넣어야 N+1 을 해결할 수 있는지 알려준다.
ref: https://github.com/flyerhzm/bullet
2009-08-25 20:40:17[INFO] USE eager loading detected:
Post => [:comments]·
Add to your query: .includes([:comments])
2009-08-25 20:40:17[INFO] Call stack
/Users/richard/Downloads/test/app/views/posts/index.html.erb:8:in `each'
/Users/richard/Downloads/test/app/controllers/posts_controller.rb:7:in `index'
그리고 간단하게 config 설정해주면 위 로그를 레일즈 로그 뿐만 아니라 Slack 에도 전송할 수 있다
Bullet.slack = { webhook_url: 'http://some.slack.url', channel: '#default', username: 'notifier' }
또한 Bullet.unused_eager_loading_enable = true
옵션을 통해 남용된 includes
가 있을 경우 이를 검출해주기도 한다.
prosopite 는 다음과 같이 bullet 보다 나은 점을 공식 레포에서 소개한다.
ref: https://github.com/charkost/prosopite
## Compared to Bullet
## Prosopite can auto-detect the following extra cases of N+1 queries:
### N+1 queries after record creations (usually in tests)
FactoryBot.create_list(:leg, 10)
Leg.last(10).each do |l|
l.chair
end
### Not triggered by ActiveRecord associations
Leg.last(4).each do |l|
Chair.find(l.chair_id)
end
### First/last/pluck of collection associations
Chair.last(20).each do |c|
c.legs.first
c.legs.last
c.legs.pluck(:id)
end
### Changing the ActiveRecord class with #becomes
Chair.last(20).map{ |c| c.becomes(ArmChair) }.each do |ac|
ac.legs.map(&:id)
end
### Mongoid models calling ActiveRecord
class Leg::Design
include Mongoid::Document
...
field :cid, as: :chair_id, type: Integer
...
def chair
@chair ||= Chair.where(id: chair_id).first!
end
end
Leg::Design.last(20) do |l|
l.chair
end
개인적으로 prosopite 가 맘에 들었던 이유는 bullet 에서는 더 정확한 쿼리 감지를 하기 때문이다.
prosopite 에 나온 문구를 따르면 "Prosopite is able to auto-detect Rails N+1 queries with zero false positives / false negatives".
여기서 "zero false prositives / false negatives" 는 정확한 N+1 를 검출한다는 뜻이다.
실제로 bullet 과 비교하였을 때 훨씬 더 N+1 쿼리를 잘잡는다. 그 이유로 정확한 건 아니지만 prosopite 의 경우 액션 호출 시점으로부터 완료 시점까지 발생된 모든 쿼리를 normalized 하여 계산하는 것으로 보인다.
보통 N+1 쿼리를 검출할 때 호출 시점으로 응답까지 발생한 쿼리들을 정규화(normalized) 하여 중복 쿼리를 잡아낸다. 예를 들어, 다음과 같이 쿼리가 발생했다면 N+1 로 검출될 가능성이 크다.
SELECT "users".* FROM "users" WHERE "users"."deleted_at" IS NULL AND "users"."id" = 2500 ORDER BY "users"."id" ASC LIMIT 1,
SELECT "users".* FROM "users" WHERE "users"."deleted_at" IS NULL AND "users"."id" = 3654 ORDER BY "users"."id" ASC LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."deleted_at" IS NULL AND "users"."id" = 1673 ORDER BY "users"."id" ASC LIMIT 1
이를 정규화하면 다음과 같을 것이다.
SELECT "users".* FROM "users" WHERE "users"."deleted_at" IS NULL AND "users"."id" = $1 ORDER BY "users"."id" ASC LIMIT $2
위 쿼리를 3번 호출했으니 특정 액션에서 위와 같이 쿼리가 호출됐다면 N+1 검출 로그가 찍힐 것이다.
bullet 은 테이블 간 관계에 따른 includes
를 알려주는 거처럼 보인다. 만약 다음과 같은 코드가 있다고 가정하자
# store model
class Store < ApplicationRecord
has_one book
...
def recommended
book.comments.where(status: 1).order(:created_at).limit(10)
end
end
# specific action of controller
class BooksController < BaseContoller
def sell
stores = Store.all.includes(book: :comments)
stores.each do |store|
store.recommended
end
end
end
BooksController
에서는 아무리 includes
를 잘 해주더라도 Store
모델의 recommended
메서드에서는 무조건 쿼리를 호출할 수 밖에 없기 때문에 중복쿼리가 호출될 것이다.
그러나 Bullet 젬에서는 이를 검출하지 못한다. 아마 테이블 간 관계에 한해서만 검출을 해주기 때문인거 같다(개인적 추측). 반대로 prosopite 젬의 경우 액션이 호출된 시점부터 완료되는 시점까지 발생한 모든 쿼리를 대상으로 계산하기 때문에 이를 검출해준다.
Prosopite 가 Bullet 보다 깐깐한 거 같다.
https://github.com/flyerhzm/bullet
https://github.com/charkost/prosopite