QueryRecorder
GitLab v19.1QueryRecorder는 테스트에서 N+1 쿼리 문제를 감지하기 위한 도구입니다. spec/support/query_recorder.rb에 9c623e3e를 통해 구현되어 있습니다. 원칙적으로, 머지 리퀘스트는 쿼리 수를 증가시켜서는 안 됩니다.
QueryRecorder는 테스트에서 N+1 쿼리 문제를 감지하기 위한 도구입니다.
spec/support/query_recorder.rb에 9c623e3e를 통해 구현되어 있습니다.
원칙적으로, 머지 리퀘스트는 쿼리 수를 증가시켜서는 안 됩니다. N+1 쿼리를 방지하기 위해 .includes(:author, :assignee) 같은 코드를 추가하게 된다면, QueryRecorder를 사용해 테스트로 이를 강제하는 것을 고려하세요. 이를 적용하지 않으면, 추가 모델에 접근하는 새로운 기능이 해당 문제를 조용히 재발시킬 수 있습니다.
QueryRecorder의 동작 방식#
이 방식의 테스트는 ActiveRecord가 실행하는 SQL 쿼리 수를 세는 방식으로 동작합니다. 먼저 제어 카운트(control count)를 측정한 다음, 데이터베이스에 새 레코드를 추가하고 다시 카운트를 실행합니다. 쿼리 수가 크게 증가했다면 N+1 쿼리 문제가 존재하는 것입니다.
예를 들어, 카운트 사이에 이슈를 5개 생성할 경우, N+1 문제가 있다면 쿼리 수가 5만큼 증가합니다.
it "avoids N+1 database queries", :request_store, :use_sql_query_cache do
visit_some_page # warm-up
control = ActiveRecord::QueryRecorder.new(skip_cached: false) { visit_some_page }
create_list(:issue, 5)
expect { visit_some_page }.to issue_same_number_of_queries_as(control)
end
기댓값과 제어값 모두 QueryRecorder 인스턴스로 사용할 수도 있습니다:
it "avoids N+1 database queries", :request_store, :use_sql_query_cache do
visit_some_page # warm-up
control = ActiveRecord::QueryRecorder.new(skip_cached: false) { visit_some_page }
create_list(:issue, 5)
action = ActiveRecord::QueryRecorder.new(skip_cached: false) { visit_some_page }
expect(action).to issue_same_number_of_queries_as(control)
end
경우에 따라 무관한 이유로 실행 간 쿼리 수가 약간 달라질 수 있습니다.
이런 경우 issue_same_number_of_queries_as(control).with_threshold(acceptable_change)를 사용해야 할 수 있지만, 가능하면 피하는 것이 좋습니다.
이 테스트가 실패하고 제어값이 QueryRecorder로 전달된 경우, 실패 메시지는 가장 긴 공통 접두사를 기준으로 쿼리를 매칭하고 유사한 쿼리를 그룹화하여 추가 쿼리가 어디에 있는지 알려줍니다.
권장 패턴#
N+1 쿼리 테스트의 권장 패턴에는 테스트가 프로덕션 동작을 정확하게 반영하도록 보장하는 중요한 구성 요소들이 포함되어 있습니다:
it "avoids N+1 database queries", :request_store, :use_sql_query_cache do
visit_some_page # warm-up
control = ActiveRecord::QueryRecorder.new(skip_cached: false) { visit_some_page }
create_list(:issue, 5)
expect { visit_some_page }.to issue_same_number_of_queries_as(control)
end
각 구성 요소는 특정 목적을 위해 사용됩니다:
-
:request_store: 요청 기간 동안 메모리에 데이터를 캐시하는Gitlab::SafeRequestStore를 활성화합니다. 프로덕션에서는 활성화되어 있지만 테스트에서는 기본적으로 비활성화되어 있습니다. 이를 사용하지 않으면 잘못된 결과가 나올 수 있습니다. -
:use_sql_query_cache: 프로덕션에서 이미 활성화되어 있는 SQL 쿼리 캐시를 활성화합니다. -
skip_cached: false: 캐시된 쿼리를 포함한 모든 쿼리를 카운트합니다. 캐싱으로 인해 감춰질 수 있는 N+1 쿼리를 포착합니다. -
issue_same_number_of_queries_as: 쿼리 수가 예상치 않게 증가하거나 감소할 경우 실패합니다(양방향). -
warm-up: 스키마 로딩과 같이 이후 요청에서는 반복되지 않는 일회성 초기화 쿼리를 처리합니다.
exceed_query_limit 대신 issue_same_number_of_queries_as를 사용해야 하는 이유#
exceed_query_limit 대신 issue_same_number_of_queries_as를 사용하세요. 양방향으로 동작하여 쿼리 수가 예상치 않게 증가하거나 감소할 경우 실패하기 때문입니다.
이를 통해 리팩토링 중 필요한 쿼리가 실수로 제거되거나 사용되지 않는 코드가 실행되는 등 어느 방향으로든 의도치 않은 변경을 포착할 수 있습니다.
반면 exceed_query_limit는 쿼리가 예상 수를 초과할 때만 실패하므로, 쿼리가 예상치 않게 감소하는 경우에는 알림을 제공하지 않습니다.
컨트롤러 스펙 대신 request 스펙 사용#
컨트롤러 레벨에서 N+1 테스트를 작성할 때는 request 스펙을 사용하세요.
컨트롤러 스펙은 컨트롤러가 예시당 한 번만 초기화되므로 N+1 테스트 작성에 사용해서는 안 됩니다. 이로 인해 후속 “요청”에서 (예: 메모이제이션으로 인해) 쿼리가 줄어들어 테스트가 잘못 통과할 수 있습니다.
실패를 확인하지 않은 테스트는 신뢰하지 않는다#
N+1 쿼리 테스트를 추가하기 전에, 먼저 변경 사항 없이 테스트가 실패하는지 확인해야 합니다. 테스트 자체가 잘못되어 있거나, 잘못된 이유로 통과하고 있을 수 있기 때문입니다.
테스트를 검증하려면:
-
권장 패턴으로 테스트를 작성합니다.
-
N+1 수정 사항을 일시적으로 제거하거나 주석 처리합니다.
-
테스트를 실행하고 예상되는 쿼리 수 증가와 함께 실패하는지 확인합니다.
-
수정 사항을 복원하고 테스트가 통과하는지 확인합니다.
쿼리 소스 찾기#
쿼리 소스를 찾는 방법은 여러 가지가 있습니다.
QueryRecorder의 data 속성을 검사합니다. file_name:line_number:method_name 형태로 쿼리를 저장합니다.
각 항목은 다음 필드를 가진 hash입니다:
count: 이 file_name:line_number:method_name에서 쿼리가 호출된 횟수
-
occurrences: 각 호출의 실제SQL -
backtrace: 각 호출의 스택 트레이스 (다음 두 옵션 중 하나가 활성화된 경우)
QueryRecorder#find_query를 사용하면 file_name:line_number:method_name과 count 속성으로 쿼리를 필터링할 수 있습니다. 예를 들어:
control = ActiveRecord::QueryRecorder.new(skip_cached: false) { visit_some_page }
control.find_query(/.*note.rb.*/, 0, first_only: true)
QueryRecorder#occurrences_by_line_method는 data를 기반으로 count로 정렬된 배열을 반환합니다.
ActiveRecord::QueryRecorder.new(query_recorder_debug: true)를 사용하여 원하는 특정 QueryRecorder 인스턴스의 콜 백트레이스를 확인합니다. 출력 결과는 test.log 파일에 저장됩니다.
QUERY_RECORDER_DEBUG 환경 변수를 사용하여 모든 테스트에 콜 백트레이스를 활성화합니다.
이를 활성화하려면, QUERY_RECORDER_DEBUG 환경 변수를 설정하고 스펙을 실행합니다. 예를 들어:
QUERY_RECORDER_DEBUG=1 bundle exec rspec spec/requests/api/projects_spec.rb
이렇게 하면 QueryRecorder 호출이 test.log 파일에 기록됩니다. 예를 들어:
QueryRecorder SQL: SELECT COUNT(*) FROM "issues" WHERE "issues"."deleted_at" IS NULL AND "issues"."project_id" = $1 AND ("issues"."state" IN ('opened')) AND "issues"."confidential" = $2
--> /home/user/gitlab/gdk/gitlab/spec/support/query_recorder.rb:19:in `callback'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/fanout.rb:127:in `finish'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/fanout.rb:46:in `block in finish'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/fanout.rb:46:in `each'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/fanout.rb:46:in `finish'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/instrumenter.rb:36:in `finish'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications/instrumenter.rb:25:in `instrument'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract_adapter.rb:478:in `log'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/postgresql_adapter.rb:601:in `exec_cache'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/postgresql_adapter.rb:585:in `execute_and_clear'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/postgresql/database_statements.rb:160:in `exec_query'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/database_statements.rb:356:in `select'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/database_statements.rb:32:in `select_all'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/query_cache.rb:68:in `block in select_all'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/query_cache.rb:83:in `cache_sql'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/connection_adapters/abstract/query_cache.rb:68:in `select_all'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/relation/calculations.rb:270:in `execute_simple_calculation'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/relation/calculations.rb:227:in `perform_calculation'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/relation/calculations.rb:133:in `calculate'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activerecord-4.2.8/lib/active_record/relation/calculations.rb:48:in `count'
--> /home/user/gitlab/gdk/gitlab/app/services/base_count_service.rb:20:in `uncached_count'
--> /home/user/gitlab/gdk/gitlab/app/services/base_count_service.rb:12:in `block in count'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:299:in `block in fetch'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:585:in `block in save_block_result_to_cache'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:547:in `block in instrument'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/notifications.rb:166:in `instrument'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:547:in `instrument'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:584:in `save_block_result_to_cache'
--> /home/user/.rbenv/versions/2.3.5/lib/ruby/gems/2.3.0/gems/activesupport-4.2.8/lib/active_support/cache.rb:299:in `fetch'
--> /home/user/gitlab/gdk/gitlab/app/services/base_count_service.rb:12:in `count'
--> /home/user/gitlab/gdk/gitlab/app/models/project.rb:1296:in `open_issues_count'
Rails 콘솔에서 쿼리 수 테스트#
개발 중에 Rails 콘솔 출력에서 직접 데이터베이스 쿼리를 식별하고 카운트하는 것은 어려울 수 있습니다. QueryRecorder를 대화형으로 사용하면 테스트를 작성하기 전에 쿼리 성능을 분석하고 최적화하는 데 도움이 됩니다.
이 방법은 다음과 같은 경우에 특히 유용합니다:
-
기능 개발 중 N+1 쿼리 디버깅
-
다양한 프리로딩 전략 비교
-
코드 커밋 전 쿼리 최적화 검증
QueryRecorder 헬퍼는 Rails 콘솔에서 자동으로 로드되지 않으므로, 먼저 require해야 합니다:
# Important: Require the QueryRecorder helper (not loaded by default)
require './spec/support/helpers/query_recorder.rb'
# Example: Analyzing query counts for different preloading strategies
result = {}
package_files = Packages::PackageFile.limit(5)
# Create a helper to count queries
query_counter = proc do |&query_block|
ActiveRecord::QueryRecorder.new(&query_block).data.map { |_, v| v[:count] }.reduce(&:+)
end
# Test different approaches
result['without preloading'] = query_counter.call { package_files.map(&:package) }
result['with preload(:package)'] = query_counter.call { package_files.preload(:package).map(&:package) }
result['with includes(package: :project)'] = query_counter.call { package_files.includes(package: :project).map(&:package) }
# View results
result
# => {"without preloading"=>5, "with preload(:package)"=>2, "with includes(package: :project)"=>2}
# Find the most efficient approach
result.min { |a, b| a.second <=> b.second }
# => ["with preload(:package)", 2]
이 방법을 사용하면 개발 중에 다양한 쿼리 전략을 빠르게 테스트하고 비교할 수 있어, 테스트와 코드를 작성하기 전에 가장 효율적인 구현 방법을 선택하는 데 도움이 됩니다.
참고 자료#
-
Bullet
N+1쿼리 문제를 찾기 위한 도구 -
RedisCommands::Recorder Redis에서
N+1호출을 테스트하기 위한 도구