いいね機能のN+1問題について

## はじめに
現在、ポートフォリオとしてRailsを使って簡易的なSNSアプリを開発しています。
開発を進める中で、いいね機能の実装でN+1問題に直面して苦戦しました。
そこで、本記事ではN+1問題の基礎知識と自分の経験をまとめました。

## 目次
[1. N+1問題とは](#n1問題とは)
[2. N+1問題の対処法](#n1問題の対処法)
[3. いいね機能のN+1問題](#いいね機能のn1問題)

## N+1問題とは
### 概要
- データベースクエリを扱う際に発生することがあるパフォーマンスの問題
- 1つのクエリで親テーブルからデータを取得し、それぞれのレコードに対してさらに子テーブルからデータを取得する度にクエリを発行すること
- ループ処理の中で無駄なデータベースアクセスが発生し、アプリケーションのパフォーマンスが低下する
### 具体例
```
# 投稿を全件取得して投稿したユーザー名を表示するコード
tweets = Tweet.all
tweets.each { puts _1.user.name }
```
上記のコードを実行して得られる結果は何も問題ないが、結果を得る過程で発行されるクエリが無駄に多くなりデータベースに負荷がかかってしまう。

例えば、合計で10件の投稿があった場合、まず、10件分の投稿を取得するクエリを1回発行し、次にループの中で投稿したユーザー名を取得するクエリを10回発行する。つまり、合計で11回のクエリが発行されてしまう。

投稿数を一般化してN件とすると、合計N+1回クエリが発行されてしまうのでN+1問題と呼ばれている。

## N+1問題の対処法
Railsでは以下のメソッドを使ってN+1問題に対処することができる。
- includes
- preload
- eager_load
### includes
`includes`メソッドを使うと、指定されたすべての関連付けを最小限のクエリ回数で読み込む。
```
tweets = Tweet.all.includes(:user)
tweets.each { puts _1.user.name }
```
今回の例では、クエリの発行数が2回だけで済むようになる。
```
Tweet Load (3.1ms)  SELECT `tweets`.* FROM `tweets`
User Load (2.4ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
```

### preload
`preload`メソッドを使うと、指定された関連付けを、1つの関連付けにつき1件のクエリで読み込む。
```
tweets = Tweet.all.preload(:user)
tweets.each { puts _1.user.name }
```
今回の例では、クエリの発行数が2回だけで済むようになる。（今回の条件では発行されるクエリも`includes`と同じとなった）
```
  Tweet Load (5.0ms)  SELECT `tweets`.* FROM `tweets`
  User Load (1.5ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
```

### eager_load
`eager_load`メソッドを使うと、指定されたすべての関連付けをLEFT OUTER JOINで読み込む。
```
tweets = Tweet.all.eager_load(:user)
tweets.each { puts _1.user.name }
```
tweetsテーブルとusersテーブルをLEFT OUTER JOINしているので、クエリの発行数が1回だけとなっている。
```
SQL (24.4ms)  SELECT `tweets`.`id` AS t0_r0, `tweets`.`user_id` AS t0_r1, `tweets`.`title` AS t0_r2, `tweets`.`content` AS t0_r3, `tweets`.`created_at` AS t0_r4, `tweets`.`updated_at` AS t0_r5, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, `users`.`email` AS t1_r2, `users`.`password_digest` AS t1_r3, `users`.`created_at` AS t1_r4, `users`.`updated_at` AS t1_r5 FROM `tweets` LEFT OUTER JOIN `users` ON `users`.`id` = `tweets`.`user_id`
```

## いいね機能のN+1問題
### 実現したいこと
- 画像のように投稿一覧ページで、各投稿にいいねボタンを表示させる
- いいねボタンはログインユーザーがいいねしている場合は緑色、いいねしていない場合は白色で表示する

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625095/019c0b0c-b68d-599c-3c51-4130e0f4499e.png" width="80%">

### 修正前のコード
**モデル**
投稿がユーザーによっていいねされているか判定するメソッド`favorited_by?`メソッドを定義する
```:app/models/tweet.rb
class Tweet < ApplicationRecord
  validates :user, presence: true
  validates :title, presence: true
  validates :content, presence: true

  belongs_to :user
  has_many :favorites
	
  # いいねされいているか否かを判定するメソッド
  def favorited_by?(target_user_id)
    favorites.where(user_id: target_user_id).exists?
  end
end
```

**コントローラー**
Tweetモデルの全てのレコードをデータベースから取得し、関連テーブルの`user`と`favorites`のデータ配列を取得し、キャッシュする。
```:app/controllers/tweets_controller.rb
class TweetsController < ApplicationController
  before_action :check_logged_in, only: %i[index show new create]
  before_action -> { check_edit_authority(Tweet.find(params[:id]).user_id) }, only: %i[edit update destroy]

  # GET /tweets
  def index
    @tweets = Tweet.all.preload(:user, :favorites)
  end

	# 他のアクションは省略
end
```

**ビュー**
`favorited_by?`メソッドを使って、投稿がログインユーザーによっていいねされているか判定して、いいねボタンの色を変える
```:app/views/tweets/index.html.erb
<% @tweets.each do |tweet| %>
  <!-- ユーザー名、メールアドレス、タイトル、投稿内容などを表示する処理 -->
	<% if tweet.favorited_by?(current_user.id) %>
		<!- ログインユーザーにいいねされている時の処理 -->
	<% else %>
	  <!- ログインユーザーにいいねされていない時の処理 -->
	<% end %>
<% end %>
```

### N+1問題が発生しているクエリ
投稿一覧画面を表示した時に発行されるクエリを確認すると...
Tweetsテーブルの全レコードを取得した後、preloadによって関連付けられたUsersテーブルのレコードとFavoritesテーブルのレコードを取得しているが、ビューのループで`favorite_by?`メソッドが呼ばれる度にクエリが発行されていることがわかる。
```
# Tweetsテーブルから全てのレコードを取得する
Tweet Load (0.5ms)  SELECT `tweets`.* FROM `tweets`

# Usersテーブルから特定のIDのユーザーを取得する
User Load (0.5ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2)

# Favoritesテーブルから特定のtweets_idに関連付けられたいいねを取得する
Favorite Load (0.4ms)  SELECT `favorites`.* FROM `favorites` WHERE `favorites`.`tweet_id` IN (1, 2, 3)

# 以下は投稿ごとにログインユーザーいいねされているかの判定
Favorite Exists? (0.4ms)  SELECT 1 AS one FROM `favorites` WHERE `favorites`.`tweet_id` = 1 AND `favorites`.`user_id` = 1 LIMIT 1

Favorite Exists? (0.2ms)  SELECT 1 AS one FROM `favorites` WHERE `favorites`.`tweet_id` = 2 AND `favorites`.`user_id` = 1 LIMIT 1

Favorite Exists? (0.2ms)  SELECT 1 AS one FROM `favorites` WHERE `favorites`.`tweet_id` = 3 AND `favorites`.`user_id` = 1 LIMIT 1
```

### N+1問題を解消する
### 原因を見つける
```
def favorited_by?(target_user_id)
  favorites.where(user_id: target_user_id).exists?
end
```
`favorite_by?`メソッドを確認すると...
指定した条件のレコードがDBに存在するかどうかを真偽値で返す`exists?`メソッドによりクエリが発行されていそうだと予測できる。
[:page_facing_up:Railsドキュメント|exists?](https://railsdoc.com/page/exists_q)

### 修正の方針を考える
preloadによって取得したキャッシュを使い、クエリを発行せずに指定したレコードが存在するか判定できるようにするという方針で修正したい。

Railsでレコードの存在チェックをする際によく使われるメソッドが他にないか調べてみたところ`exitst?`の他に`present?`と`any?`があった。

今回のケースでは`any?`を使うことで追加のクエリは発行することなく、コントローラの`preload`でキャッシュされたデータに対して存在チェックができる。

`exitst?`,`present?`,`any?`の違いは下記の記事を参考にした。
[:page_facing_up:Ruby on Railsのexists? / any? / present?](https://tech.unifa-e.com/entry/2023/08/21/000000)

### コードを修正する
`any?`を使って`favorite_by?`メソッドを修正する。
<details><summary>any?メソッドの解説</summary>

- `any?`とブロックを使って、各要素と比較するオブジェクトや条件を指定する方法を使っている。
- [:page_facing_up:Rubyリファレンスマニュアル|any?](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/any=3f.html)
  ```
  オブジェクト.any? { |ブロック引数| 条件 }
  ```
</details>

```:app/models/tweet.rb
  # 修正後
  def favorited_by?(target_user_id)
    favorites.any? { |favorite| favorite.user_id == target_user_id }
  end
```
修正後、投稿一覧画面を表示した時に発行されるクエリを確認すると...
`favorite_by?`メソッドが呼ばれる度に発行されていたクエリがなくなり、N+1問題が解消されていることがわかる！
```
Tweet Load (0.4ms)  SELECT `tweets`.* FROM `tweets`
User Load (0.7ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2)
Favorite Load (0.7ms)  SELECT `favorites`.* FROM `favorites` WHERE `favorites`.`tweet_id` IN (1, 2, 3)
```

## 参考資料
- [Railsガイド N+1クエリ問題](https://railsguides.jp/active_record_querying.html#n-1%E3%82%AF%E3%82%A8%E3%83%AA%E5%95%8F%E9%A1%8C)
- [『達人が教えるWebパフォーマンスチューニング〜ISUCONから学ぶ高速化の実践』 5章](https://gihyo.jp/book/2022/978-4-297-12846-3)