N+1問題を検知するため、gem bulletを使ってみた

# はじめに
プログラミングスクール「RUNTEQ」で、Rubyを中心に学習中のあっしーです。
学習中のアウトプットのため、N+1問題を検知するgem bulletについてまとめてみました。
※初学者のため、内容に誤りがある場合もあります。ご容赦ください。コメント等で教えていただけますと幸いです。

# 環境
- Ruby  3.3.6
- Rails   7.1.5

# N+1問題とは
ご存知の方は、以下の説明は読み飛ばしてください。
<details>
<summary>N+1問題とは？（ご存知ない方はこちらをクリック）</summary>

**ビュー等でのループ処理の中で、毎回余計なSQLを発行してしまい、パフォーマンスが低下する問題です。**

例えば、投稿アプリで投稿一覧ページがあった場合、ビューでユーザーの名前を表示したいとき、以下のようなコードがあったとします。




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

```ruby:app/views/posts/index.html
<% @posts.each do |post| %>
　<p><%= post.user.name %></p>
<% end %>
```

モデルのアソシエーションは下記の通りです。

```ruby:app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end
```


```ruby:app/models/user.rb
class User < ApplicationRecord
  has_many :posts
end
```
この場合、発行されているSQLは以下の2箇所になります。

１　コントローラーの`@posts = Post.all`（投稿を全て取得）
２　ビューの`<%= post.user.name %>`（投稿に関連付いているユーザーを取得）

この２に注目すると、ループ処理の中で、投稿の数の分だけSQLを発行しています。

このように、ループ処理の中で

・Post.all　→ 全ての投稿を1回で取得
・post.user　→ 投稿の数（N件）をN回取得

→合計で「N+1回」SQLが発行されてしまい、パフォーマンスの低下（アプリの読み込みや動作が遅くなる）を引き起こしてしまう問題を **「N+1問題」** と言います。

<details>
<summary>【参考】ログ</summary>
以下(ログの抜粋）のように、Post一覧を表示する際に、Postごとに関連Userを取得してしまっており、
「User Load」が複数回発行されています。

<pre>
Processing by PostsController#index as HTML
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Post Load (5.2ms)  SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:1
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
</pre>
</details>
解決方法の例として、コントローラーでPostの情報を取得する際に、includesメソッドを使用して、事前にUserの情報も一度に取得する方法が挙げられます。
※後に記述します

このような、N+1問題が発生している時にgem bulletを導入すると簡単に問題箇所を発見することができます。
</details>

# bulletについて

**ページにアクセスしたタイミングで、N+1問題が発生していれば検知して教えてくれるgem** になります。

https://github.com/flyerhzm/bullet

最初は自分も「どうやって教えてくれるんだろう？」と不思議に思いましたが、実際にやってみたらわかりました。導入してみましょう！
# 導入手順
##### １　Gemfileに以下の記述を書いて、gem「bullet」を導入
```ruby:Gemfile
group :development do
  gem 'bullet'
end
```

##### ２　bundle install　を実施

##### ３　bundle exec rails g bullet:installを実施。

すると、`config/environments/development.rb`ファイルに以下のコードがを生成されます。

```ruby:config/environments/development.rb
config.after_initialize do
    Bullet.enable        = true # Bulletを有効にする
    Bullet.alert         = true # ブラウザに通知（アラート）を出す
    Bullet.bullet_logger = true # log/bullet.log にログを出力する
    Bullet.console       = true # ブラウザの開発者ツールのコンソールにログを出力する
    Bullet.rails_logger  = true # log/development.log にログを出力する
    Bullet.add_footer    = true # フッター（画面左下）に通知を出す
  end
```
これで導入完了です。

# 実際にN+1問題を発生させてみる
実際にわざとN+1問題を発生させて、どのように教えてくれるのか確認してみます。

投稿アプリで投稿一覧ページがあった場合、ビューでユーザーの名前を表示してみます。
コードは以下の通りです。
```ruby:app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end
```

```ruby:app/views/posts/index.html
<% @posts.each do |post| %>
　<p><%= post.user.name %></p>
<% end %>
```

モデルのアソシエーションは下記の通りです。

```ruby:app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end
```


```ruby:app/models/user.rb
class User < ApplicationRecord
  has_many :posts
end
```

実際に一覧画面を表示してみると、以下のとおりブラウザに通知（アラート）が表示されました。

![bulletアラート.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4097958/c36fc24a-39aa-4dca-8f9b-d3f5f1e9a814.png)



また、Bulletはブラウザ以外にも以下のような方法で通知を出してくれます。

- コンソール（ターミナル）
-  Railsのログファイル（log/development.log）
-  Bullet専用のログファイル（log/bullet.log）
- ページのフッター

…と、いろんな場所に出してくれるので、開発中に気づきやすい仕組みとなっています。

どこに出るかは`config/environments/development.rb`に設定された内容によって変わるので、  実際に手元で試してみて、「どこに出るか」「どんなふうに見えるか」を確認してみてください 💡

# 解決策
解決方法として、今回の例ではコントローラーでPostの情報を取得する際に、includesメソッドを使用して、事前にUserの情報も一度に取得する方法が挙げられます。
includes(:user) を使うことで、Postを取得する際に関連するUser情報も一括で読み込むため、ビューのループ内で毎回SQLを発行する必要がなくなります。

```ruby:app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:user)
  end
end
```
※allは省略可


再度、ページを表示してみると、ブラウザの通知は表示されませんでした。
![bulletアラートなし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4097958/06db7bfa-fe02-4bc7-910c-616249985904.png)

また、includesメソッドを使用する前と後のログを比較してみると

<details open>
<summary>before</summary>
<pre>
Processing by PostsController#index as HTML
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Post Load (5.2ms)  SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:1
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/index.html.erb:2
</pre>
</details>

<details open>
<summary>after</summary>
<pre>
Processing by PostsController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering posts/index.html.erb within layouts/application
  Post Load (3.8ms)  SELECT "posts".* FROM "posts"
  ↳ app/views/posts/index.html.erb:1
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ?  [["id", 1]]
  ↳ app/views/posts/index.html.erb:1
</pre>
</details>

発行されているクエリの数が減っていることがわかります。

# まとめ
今回は、gem「bullet」を使って、N+1問題を検知する方法を紹介しました。

- N+1問題とは、ループ処理の中で、毎回余計なSQLを発行してしまい、パフォーマンスが低下する問題
- bulletを導入すると、アラートやログなどでN+1問題が発生してくれている箇所を教えてくれる
- 代表的な例として、includesメソッドを使うことで、関連データをまとめて取得でき、N+1問題を回避できる

今回の例では投稿数が5件だったため、パフォーマンスの低下は目立ちませんでしたが、投稿数が増えるにつれて余計なSQLがどんどん発行され、アプリの表示速度やユーザー体験にも悪影響を与えるようになります。

こちらを参考にパフォーマンス向上をさせてみてください！