N+1 검출 라이브러리 비교

uchan·2023년 4월 20일
0

배경

백엔드 개발자로서 서버와 데이터베이스 간 요청하는 횟수 및 데이터를 최대한 적게 하는 것이 기본 소양이다. 레일즈에서 N+1 쿼리를 잡기위하여 개발환경에 Bullet 과 Prosopite 젬을 설치하였고 실험을 통해 어느 젬이 좋은지(?) 비교해보려고 한다.

bullet

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

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 하여 계산하는 것으로 보인다.

normalized query 로 N+1 쿼리 검출

보통 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 검출 로그가 찍힐 것이다.

prosopite 는 모든 쿼리를 잡는다.

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 보다 깐깐한 거 같다.

reference

https://github.com/flyerhzm/bullet
https://github.com/charkost/prosopite

0개의 댓글