Rails 요청 스펙에서 N+1 쿼리를 11줄의 Ruby로 잡아내기

발행: (2026년 2월 1일 오후 02:23 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

Cover image for Catch N+1 Queries in Rails Request Specs with 11 Lines of Ruby

N+1 쿼리는 Rails 애플리케이션에서 가장 흔한 성능 함정 중 하나입니다. Bullet이나 Prosopite와 같은 도구는 이를 감지하는 데 도움이 되지만, 추가적인 의존성 및 설정 작업이 필요합니다. 때로는 요청 스펙에서 직접 쿼리 수를 검증할 수 있는 가볍고 투명한 방법만을 원할 때가 있습니다.

Rails가 이미 제공하는 것만으로도 이를 구현하는 방법은 다음과 같습니다.

기술

Rails는 ActiveSupport::Notifications를 통해 sql.active_record 이벤트 아래 모든 SQL 쿼리를 계측합니다. 우리는 블록이 실행되는 동안 해당 이벤트를 구독하고 발생하는 모든 쿼리를 수집할 수 있습니다.

spec/support/query_counter.rb 파일을 생성합니다:

# spec/support/query_counter.rb
module QueryCounter
  def capture_queries(&block)
    queries = []
    callback = lambda do |_name, _start, _finish, _id, payload|
      queries << payload[:sql] unless payload[:sql].match?(/SCHEMA|BEGIN|COMMIT|SAVEPOINT|RELEASE/)
    end

    ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)

    queries
  end
end

RSpec.configure do |config|
  config.include QueryCounter, type: :request
end

여기서 핵심 메서드는 ActiveSupport::Notifications.subscribed이며, 블록이 실행되는 동안 콜백을 일시적으로 구독하고 자동으로 구독을 해제합니다. 정규식 필터는 스키마 조회와 트랜잭션 제어 문을 제외하므로, 실제 애플리케이션 코드가 발생시키는 쿼리만 남게 됩니다.

스펙에서 사용하기

엔드포인트가 게시물과 그 댓글들을 반환한다고 가정해 보세요. 순진한 구현은 먼저 게시물을 로드한 뒤 각 댓글마다 별도의 쿼리를 실행합니다— 전형적인 N+1 문제죠. 이를 잡아내는 방법은 다음과 같습니다:

# spec/requests/posts_spec.rb
RSpec.describe "Posts API", type: :request do
  let!(:posts) { create_list(:post, 5, :with_comments) }
  let(:target_id) { posts.last.id }

  it "avoids N+1 queries when fetching post details" do
    queries = capture_queries { get "/api/posts/#{target_id}" }

    comment_queries = queries.count { |q| q.include?("comments") }
    expect(comment_queries).to eq(1)
  end
end

테스트는 capture_queries 안에서 요청을 수행한 뒤, 수집된 SQL 문자열을 필터링하여 comments 테이블에 접근한 횟수를 셉니다. 누군가 includes 호출을 제거하거나 지연 로드를 도입하면 이 테스트가 실패합니다.

Source:

왜 이 방법이 잘 작동하는가

  • 명시적이다. 백그라운드 도구가 경고를 표시하도록 의존하는 대신, 직접적인 어설션을 작성합니다. 의도가 사양을 읽는 누구에게나 명확합니다.

  • 전체 스택을 테스트한다. 이 테스트가 요청 스펙 안에서 실행되므로 실제 컨트롤러, 직렬화기, 그리고 모든 eager loading을 수행합니다 — 격리된 쿼리에 대한 단위 테스트가 아닙니다.

  • 원시 SQL을 제공한다. queries 배열에 실제 SQL 문자열이 들어 있으므로 테스트가 실패했을 때 정확히 어떤 쿼리가 실행됐는지 확인할 수 있습니다. 이는 디버깅을 간단하게 만들어 줍니다:

    queries = capture_queries { get "/api/posts/#{target_id}" }
    puts queries # 실행된 모든 쿼리를 확인
  • 의존성이 없다. 이 방법은 Rails에 포함된 ActiveSupport::Notifications를 사용합니다. 설치, 설정, 업데이트가 필요하지 않습니다.

Limitations

This approach is intentionally minimal. It doesn’t automatically detect N+1s — you have to know what to assert on. It doesn’t provide RSpec matchers like expect { }.to execute_queries(at_most: 3). And it won’t alert you to regressions unless you’ve written a test for that specific endpoint.

For projects that need automatic detection across every request, tools like Prosopite or Bullet are better suited. But for targeted assertions on critical endpoints, 12 lines of Ruby and a clear test go a long way.

채택을 위한 팁

  • 가장 느린 엔드포인트부터 시작하세요. 성능에 민감한 엔드포인트에 대해 요청 스펙에 capture_queries를 추가하세요.
  • 특정 테이블에 대해 검증하세요. 모든 쿼리를 카운트하는 것은 깨지기 쉽습니다 — 스키마 변경으로 조인이 추가되거나 제거될 수 있습니다. 테이블 이름으로 필터링하면(위의 comments 예시처럼) 테스트가 더 견고해집니다.
  • 개발 중에 사용하세요. 최적화하기 전에 실제로 무슨 일이 일어나는지 확인하려면 capture_queries로 컨트롤러 액션을 감싸서 임시 테스트를 수행하세요.

최고의 성능 도구는 실제로 사용하게 되는 도구입니다. 때로는 완전한 기능을 갖춘 gem일 수도 있고, 때로는 spec/support에 12줄 정도의 코드일 수도 있습니다.


읽어 주셔서 감사합니다! 저는 현재 제 문제를 해결하기 위한 솔루션을 탐구 중입니다. 최근에 shaicli.devfrost를 만들었습니다. 확인해 보시고 의견을 알려 주세요!

Back to Blog

관련 글

더 보기 »

Dry-Run에 대한 찬사

번역하려는 텍스트를 제공해 주시겠어요? 텍스트를 알려주시면 한국어로 번역해 드리겠습니다.