Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

루비의 숨겨진 보석, Bullet 젬으로 데이터베이스 성능 문제 잡기

데이터베이스는 수많은 애플리케이션의 심장과 같습니다. 따라서 데이터베이스에 문제가 발생하면 심각한 성능 저하로 이어질 수 있습니다.

ActiveRecord나 Mongoid 같은 ORM은 구현 세부 사항을 추상화해 주고 더 빠른 개발을 가능하게 해주지만, 그만큼 내부에서 어떤 쿼리가 실행되고 있는지 확인하지 못하는 경우가 많습니다.

Bullet 젬은 이렇게 잘 알려진 데이터베이스 관련 문제들을 찾아내는 데 도움을 줍니다:

  1. N+1 쿼리: 목록의 각 항목을 로드하기 위해 개별 쿼리를 반복 실행하는 경우
  2. 불필요한 즉시 로딩(Unused Eager Loading): 주로 N+1 쿼리를 피하려고 데이터를 미리 로드했지만 실제로 사용하지 않는 경우
  3. 카운터 캐시 누락(Missing Counter Cache): 연관된 항목의 개수를 구하기 위해 매번 COUNT 쿼리를 실행해야 하는 경우

이 글에서는 다음 내용을 다룹니다:

  • Ruby 프로젝트에서 bullet 젬을 설정하는 방법
  • 앞서 언급한 각 문제의 예시
  • bullet이 각 문제를 감지하는 방식
  • 각 문제를 해결하는 방법
  • AppSignal과 bullet을 통합하는 방법

설명에는 이 글을 위해 별도로 만든 프로젝트의 예제 코드를 활용하겠습니다.

Ruby 프로젝트에 Bullet 설정하기

먼저 Gemfile에 젬을 추가합니다.

모든 환경에 추가할 수 있으며, 각 환경별로 활성화 여부와 감지 방식을 다르게 지정할 수 있습니다:

gem 'bullet'

다음으로 설정 작업이 필요합니다.

Rails 프로젝트라면 아래 명령어 하나로 설정 코드를 자동 생성할 수 있습니다:

bundle exec rails g bullet:install

Rails가 아닌 프로젝트라면 수동으로 추가해야 합니다. 예를 들어 애플리케이션 코드를 로드한 후 spec_helper.rb에 다음 코드를 추가합니다:

Bullet.enable        = true
Bullet.bullet_logger = true
Bullet.raise         = true

그리고 메인 파일에도 애플리케이션 코드를 로드한 후 아래 코드를 추가합니다:

Bullet.enable = true

이 글에서는 자주 쓰이는 설정 위주로 소개하며, 전체 옵션이 궁금하다면 bullet의 README 페이지를 참고하세요.

테스트에서 Bullet 활용하기

앞서 제안한 설정을 적용하면, 테스트 중에 실행된 비효율적인 쿼리를 Bullet이 감지하고 예외를 발생시킵니다.

이제 몇 가지 예제를 살펴보겠습니다.

N+1 쿼리 감지하기

다음과 같은 index 액션이 있다고 가정해 봅시다:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

그리고 뷰는 이렇습니다:

# app/views/posts/index.html.erb
 
<h1>Posts</h1>
 
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Comments</th>
    </tr>
  </thead>
 
  <tbody>
    <% @posts.each do |post| %>
    <tr>
      <td><%= post.name %></td>
      <td><%= post.comments.map(&:name) %></td>
    </tr>
    <% end %>
  </tbody>
</table>

컨트롤러와 뷰의 코드를 모두 실행하는 통합 테스트(예: 아래와 같은 리퀘스트 스펙)를 돌리면, bullet이 N+1 문제를 감지하고 에러를 발생시킵니다:

# spec/requests/posts_request_spec.rb
require 'rails_helper'
 
RSpec.describe "Posts", type: :request do
  describe "GET /index" do
    it 'lists all posts' do
      post1 = Post.create!
      post2 = Post.create!
 
      get '/posts'
 
      expect(response.status).to eq(200)
    end
  end
end

이 경우 다음과 같은 예외가 발생합니다:

Failures:

  1) Posts GET /index lists all posts
     Failure/Error: get '/posts'

     Bullet::Notification::UnoptimizedQueryError:
       user: fabioperrella
       GET /posts
       USE eager loading detected
         Post => [:comments]
         Add to your query: .includes([:comments])
       Call stack
         /Users/fabioperrella/projects/bullet-test/app/views/posts/index.html.erb:17:in `map'
         ...
     # ./spec/requests/posts_controller_spec.rb:9:in `block (3 levels) in <top (required)>'

원인은 뷰에서 post.comments.map(&:name) 부분이 각 게시글마다 댓글을 로드하는 개별 쿼리를 실행하기 때문입니다:

Processing by PostsController#index as HTML
  Post Load (0.4ms)  SELECT "posts".* FROM "posts"
  ↳ app/views/posts/index.html.erb:14
  Comment Load (0.0ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 1]]
  ↳ app/views/posts/index.html.erb:17:in `map'
  Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 2]]

해결 방법은 간단합니다. 에러 메시지의 안내대로 쿼리에 .includes([:comments])를 추가하면 됩니다:

-@posts = Post.all
+@posts = Post.all.includes([:comments])

이렇게 하면 ActiveRecord가 단 한 번의 쿼리로 모든 댓글을 로드하게 됩니다.

Processing by PostsController#index as HTML
  Post Load (0.2ms)  SELECT "posts".* FROM "posts"
  ↳ app/views/posts/index.html.erb:14
  Comment Load (0.0ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?)  [["post_id", 1], ["post_id", 2]]
  ↳ app/views/posts/index.html.erb:14

다만 아래처럼 컨트롤러 테스트만 실행하면 bullet은 예외를 발생시키지 않습니다. 컨트롤러 테스트는 기본적으로 뷰를 렌더링하지 않기 때문에 N+1 쿼리가 발생하지 않기 때문입니다.

참고: Rails 5부터는 컨트롤러 테스트 방식이 권장되지 않습니다:

# spec/controllers/posts_controller_spec.rb
require 'rails_helper'
 
RSpec.describe PostsController do
  describe 'GET index' do
    it 'lists all posts' do
      post1 = Post.create!
      post2 = Post.create!
 
      get :index
 
      expect(response.status).to eq(200)
    end
  end
end

Bullet이 N+1을 감지하지 못하는 또 다른 예는 뷰 테스트입니다. 이 경우 데이터베이스에서 실제로 N+1 쿼리가 실행되지 않기 때문입니다:

# spec/views/posts/index.html.erb_spec.rb
require 'rails_helper'
 
describe "posts/index.html.erb" do
  it 'lists all posts' do
    post1 = Post.create!(name: 'post1')
    post2 = Post.create!(name: 'post2')
 
    assign(:posts, [post1, post2])
 
    render
 
    expect(rendered).to include('post1')
    expect(rendered).to include('post2')
  end
end

테스트에서 N+1 감지 확률을 높이는 팁

각 컨트롤러 액션마다 최소 하나의 리퀘스트 스펙을 만들어 HTTP 상태 코드만이라도 검증하는 것을 추천합니다. 이렇게 하면 뷰가 렌더링되는 과정에서 bullet이 쿼리를 감시할 수 있습니다.

불필요한 즉시 로딩 감지하기

다음과 같은 basic_index 액션이 있다고 가정해 봅시다:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def basic_index
    @posts = Post.all.includes(:comments)
  end
end

그리고 해당 뷰는 이렇습니다:

# app/views/posts/basic_index.html.erb
 
<h1>Posts</h1>
 
<table>
  <thead>
    <tr>
      <th>Name</th>
    </tr>
  </thead>
 
  <tbody>
    <% @posts.each do |post| %>
    <tr>
      <td><%= post.name %></td>
    </tr>
    <% end %>
  </tbody>
</table>

아래 테스트를 실행하면:

# spec/requests/posts_request_spec.rb
require 'rails_helper'
 
RSpec.describe "Posts", type: :request do
  describe "GET /basic_index" do
    it 'lists all posts' do
      post1 = Post.create!
      post2 = Post.create!
 
      get '/posts/basic_index'
 
      expect(response.status).to eq(200)
    end
  end
end

Bullet은 다음과 같은 에러를 발생시킵니다:

  1) Posts GET /basic_index lists all posts
     Failure/Error: get '/posts/basic_index'

     Bullet::Notification::UnoptimizedQueryError:
       user: fabioperrella
       GET /posts/basic_index
       AVOID eager loading detected
         Post => [:comments]
         Remove from your query: .includes([:comments])
       Call stack
         /Users/fabioperrella/projects/bullet-test/spec/requests/posts_request_spec.rb:20:in `block (3 levels) in <top (required)>'

이 뷰에서는 댓글 목록을 로드할 필요가 없기 때문입니다.

역시 에러 메시지의 안내대로 .includes([:comments])를 제거하면 문제가 해결됩니다:

-@posts = Post.all.includes(:comments)
+@posts = Post.all

앞서 언급했듯이, render_views 없이 컨트롤러 테스트만 실행하면 이런 에러는 발생하지 않는다는 점도 기억해 두세요.

카운터 캐시 누락 감지하기

다음과 같은 컨트롤러가 있다고 가정해 봅시다:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index_with_counter
    @posts = Post.all
  end
end

그리고 뷰는 이렇습니다:

# app/views/posts/index_with_counter.html.erb
 
<h1>Posts</h1>
 
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Number of comments</th>
    </tr>
  </thead>
 
  <tbody>
    <% @posts.each do |post| %>
    <tr>
      <td><%= post.name %></td>
      <td><%= post.comments.size %></td>
    </tr>
    <% end %>
  </tbody>
</table>

다음 리퀘스트 스펙을 실행하면:

describe "GET /index_with_counter" do
  it 'lists all posts' do
    post1 = Post.create!
    post2 = Post.create!
 
    get '/posts/index_with_counter'
 
    expect(response.status).to eq(200)
  end
end

bullet은 다음과 같은 에러를 발생시킵니다:

1) Posts GET /index_with_counter lists all posts
  Failure/Error: get '/posts/index_with_counter'

  Bullet::Notification::UnoptimizedQueryError:
    user: fabioperrella
    GET /posts/index_with_counter
    Need Counter Cache
      Post => [:comments]
  # ./spec/requests/posts_request_spec.rb:31:in `block (3 levels) in <top (required)>'

이 뷰는 각 게시글마다 post.comments.size로 댓글 수를 세기 위해 개별 COUNT 쿼리를 실행하기 때문입니다:

Processing by PostsController#index_with_counter as HTML
  ↳ app/views/posts/index_with_counter.html.erb:14
  Post Load (0.4ms)  SELECT "posts".* FROM "posts"
  ↳ app/views/posts/index_with_counter.html.erb:14
   (0.4ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 1]]
  ↳ app/views/posts/index_with_counter.html.erb:17
   (0.1ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 2]]

이 문제를 해결하려면 카운터 캐시를 도입해야 하는데, 특히 운영 데이터베이스에 이미 데이터가 있는 경우에는 다소 복잡할 수 있습니다.

카운터 캐시는 테이블에 추가하는 컬럼으로, 연관 모델을 삽입하거나 삭제할 때 ActiveRecord가 자동으로 값을 갱신해 줍니다. 카운터 캐시를 생성하고 동기화하는 방법에 대한 자세한 내용은 관련 포스트를 참고하시길 권합니다.

개발 환경에서 Bullet 활용하기

테스트 커버리지가 낮으면 앞서 언급한 문제들이 테스트에서 감지되지 않을 수 있습니다. 그래서 다른 환경에서도 서로 다른 방식으로 bullet을 활성화할 수 있습니다.

개발 환경에서는 다음 설정을 활성화할 수 있습니다:

Bullet.alert         = true

그러면 브라우저에서 다음과 같은 알림이 표시됩니다.

Bullet.add_footer    = true

이 설정은 페이지 하단에 에러 내용을 담은 푸터를 추가합니다.

브라우저 콘솔에 에러를 기록하는 것도 가능합니다:

Bullet.console    = true

이 경우 콘솔에 다음과 같은 에러가 출력됩니다.

AppSignal과 함께 스테이징 환경에서 Bullet 사용하기

스테이징 환경에서는 이런 에러 메시지가 최종 사용자에게 노출되기를 원하지 않지만, 애플리케이션에서 앞서 언급한 문제가 발생하기 시작하면 알 수 있다면 좋겠죠.

동시에 bullet은 애플리케이션의 성능을 저하시키고 메모리 사용량을 늘릴 수 있습니다. 따라서 스테이징에서는 일시적으로만 활성화하고, 프로덕션에서는 활성화하지 않는 것이 좋습니다.

두 환경 간 차이를 줄이기 위해 좋은 관행으로 여겨지듯, 스테이징프로덕션과 동일한 설정 파일을 사용한다고 가정하면, 환경 변수로 bullet의 활성화 여부를 제어할 수 있습니다:

# config/environments/production.rb
config.after_initialize do
  Bullet.enabled   = ENV.fetch('BULLET_ENABLED', false)
  Bullet.appsignal = true
end

스테이징 환경에서 Bullet이 발견한 문제에 대한 알림을 받으려면 AppSignal을 통해 해당 알림을 에러로 보고할 수 있습니다. 이를 위해서는 프로젝트에 appsignal 젬이 설치 및 설정되어 있어야 합니다. 자세한 내용은 Ruby 젬 공식 문서를 참고하세요.

이후 bullet이 문제를 감지하면 다음과 같은 에러 인시던트가 생성됩니다.

이 에러는 bullet에서 분리된 uniform_notifier 젬이 발생시키는 것입니다.

아쉽게도 에러 메시지에 충분한 정보가 담겨 있지 않은데, 저는 이를 개선하기 위한 Pull Request를 보냈습니다!

마치며

bullet 젬은 애플리케이션의 성능을 저하시킬 수 있는 문제들을 감지하는 데 큰 도움이 되는 훌륭한 도구입니다.

앞서 말씀드린 것처럼 좋은 테스트 커버리지를 유지하면, 프로덕션 배포 전에 이런 문제들을 발견할 확률이 훨씬 높아집니다.

추가 팁으로, 데이터베이스 관련 성능 문제에 더욱 철저히 대비하고 싶다면 적절한 인덱스를 사용하지 않는 쿼리를 찾는 데 도움을 주는 wt-activerecord-index-spy 젬도 살펴보세요.

P.S. Ruby Magic의 새 글을 가장 먼저 읽고 싶으시다면 Ruby Magic 뉴스레터를 구독하고 어떤 글도 놓치지 마세요!