QueryRecorder

QueryRecorder은 테스트에서 N+1 queries problem를 감지하는 도구입니다.

spec/support/query_recorder.rb9c623e3e를 통해 구현되었습니다.

합병 요청은 보통 query counts를 증가시키지 말아야 합니다. 만약 N+1 쿼리를 피하기 위해 .includes(:author, :assignee)와 같은 것을 추가하게 된다면, QueryRecorder를 사용하여 이를 테스트하여 강제할 수 있습니다. 이를 통해 새로운 기능이 추가될 때 추가 모델에 접근하는 문제가 암묵적으로 재진단될 수 있습니다.

작동 방식

이 스타일의 테스트는 ActiveRecord에 의해 실행된 SQL 쿼리의 수를 세어서 작동합니다. 먼저 제어 횟수를 측정한 다음, 데이터베이스에 새 레코드를 추가하고 횟수를 재측정합니다. 만약 쿼리의 수가 크게 증가한다면 N+1 queries 문제가 존재하는 것입니다.

it "N+1 데이터베이스 쿼리를 피합니다.", :use_sql_query_cache do
  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 "N+1 데이터베이스 쿼리를 피합니다." do
  control = ActiveRecord::QueryRecorder.new { visit_some_page }
  create_list(:issue, 5)
  action = ActiveRecord::QueryRecorder.new { visit_some_page }

  expect(action).to issue_same_number_of_queries_as(control)
end

예를 들어, 제어 사이에 5개의 이슈를 생성할 수 있는데, 이는 N+1 문제가 존재한다면 쿼리 횟수가 5개 증가하게 됩니다.

어떤 경우에는 쿼리 횟수가 관련 없는 이유로 실행될 때 약간 변경될 수 있습니다. 만약 테스트가 실패하고, 제어가 QueryRecorder로 전달되었다면, 실패 메시지는 가장 긴 공통 접두어에서 쿼리를 매칭시켜 비슷한 쿼리들을 묶어서 추가된 쿼리가 어디에 있는지를 알려줍니다.

어떤 경우에는 N+1 사양이 캐시를 따뜻하게 하는 세 요청을 포함하도록 작성되었습니다: 캐시를 데우고, 제어를 설정하고, N+1 쿼리가 없는지를 검증하는 세 번째 요청입니다. 캐시를 따뜻하게 하는 추가 요청을 하는 대신에 (캐시를) 무시하는 테스트와 (제어와 테스트) 두 요청으로 하는 것이 더 좋습니다.

it "N+1 데이터베이스 쿼리를 피합니다." do
  # 캐시 데우기
  visit_some_page

  control = ActiveRecord::QueryRecorder.new(skip_cached: true) { visit_some_page }
  create_list(:issue, 5)
  expect { visit_some_page }.to issue_same_number_of_queries_as(control)
end

캐시된 쿼리

기본적으로 QueryRecorder는 cached queries를 카운트하지 않습니다. 그러나 쿼리가 문제를 감추는 N+1 쿼리를 삽입하는 것을 피하기 위해 모든 쿼리를 카운트하는 것이 더 좋을 수 있습니다. 이를 위해 :use_sql_query_cache 플래그를 설정해야 합니다. skip_cached 변수를 QueryRecorder에 전달하고 issue_same_number_of_queries_as 매처를 사용해야 합니다:

it "N+1 데이터베이스 쿼리를 피합니다.", :use_sql_query_cache do
  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

RequestStore 사용

RequestStore / Gitlab::SafeRequestStore는 요청 기간 동안 메모리에 데이터를 캐싱함으로써 N+1 쿼리를 피하는데 도움을 줍니다. 그러나 이것은 기본적으로 테스트에서 비활성화되어 있어서 N+1 쿼리를 테스트할 때 가짜 음수를 유발할 수 있습니다.

테스트에서 RequestStore를 활성화하려면 필요할 때 request_store 헬퍼를 사용하세요:

it "N+1 데이터베이스 쿼리를 피합니다.", :request_store do
  control = ActiveRecord::QueryRecorder.new(skip_cached: true) { visit_some_page }
  create_list(:issue, 5)
  expect { visit_some_page }.to issue_same_number_of_queries_as(control)
end

컨트롤러 스펙 대신 요청 스펙 사용하기

컨트롤러 수준에서 N+1 테스트를 작성할 때는 요청 스펙을 사용하세요.

컨트롤러 스펙은 컨트롤러가 각 예시당 한 번만 초기화되기 때문에 N+1 테스트를 작성하는 데 사용해서는 안 됩니다. 이로 인해 후속 “요청”에서 쿼리가 감소할 수 있어(예: 메모이제이션으로 인한) 잘못된 성공으로 이어질 수 있습니다.

보지 않은 실패하는 테스트는 신뢰하지 마세요

N+1 쿼리에 대한 테스트를 추가하기 전에 먼저 해당 변경 사항 없이 테스트가 실패하는지 확인해야 합니다. 왜냐하면 테스트가 망가졌을 수도 있고, 테스트가 잘못된 이유로 통과되었을 수도 있기 때문입니다.

쿼리의 소스 찾기

쿼리의 소스를 찾는 다양한 방법이 있습니다.

  • QueryRecorderdata 속성을 검사합니다. 이는 file_name:line_number:method_name으로 쿼리를 저장합니다. 각 항목은 다음과 같은 필드를 가진 해시입니다:

    • count: 해당 file_name:line_number:method_name에서 쿼리가 호출된 횟수
    • occurrences: 각 호출의 실제 SQL
    • backtrace: 각 호출의 스택 트레이스(다음 두 옵션 중 하나가 활성화되었을 경우)

    QueryRecorder#find_queryfile_name:line_number:method_namecount 속성에 따라 쿼리를 필터링할 수 있습니다. 예를 들면:

    control = ActiveRecord::QueryRecorder.new(skip_cached: false) { visit_some_page }
    control.find_query(/.*note.rb.*/, 0, first_only: true)
    

    QueryRecorder#occurrences_by_line_methodcount에 기반하여 data로 정렬된 배열을 반환합니다.

  • 특정 QueryRecorder 인스턴스에 대한 호출 스택 트레이스를 보려면 ActiveRecord::QueryRecorder.new(query_recorder_debug: true)를 사용합니다. 이 출력물은 test.log 파일에 저장됩니다.

  • 모든 테스트에 대한 호출 스택 트레이스를 활성화하려면 QUERY_RECORDER_DEBUG 환경 변수를 사용하세요.

    이를 활성화하려면 QUERY_RECORDER_DEBUG 환경 변수가 설정된 상태에서 스펙을 실행합니다. 예를 들면:

    QUERY_RECORDER_DEBUG=1 bundle exec rspec spec/requests/api/projects_spec.rb
    

    이렇게 하면 test.log 파일에 QueryRecorder의 호출 내용이 로깅됩니다. 예를 들면:

     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'
        --> ...
    

관련 항목