【SQL初心者向け】N+1問題が怖くなくなる記事

本記事はQmonus Value Streamの投稿キャンペーン記事です。
# 初めに
「N+1問題」自分が初めてこれを聞いたのは数年前。

おそらくこれからWeb開発に携わる方、特にSQLや他のクエリ操作をする機会がバックエンドを目指す人にとって、この入りの説明があるとイメージつきやすいのでは？と思ったので記事にします。

いまはTypeScriptでもバックエンドまでかけちゃう時代です。フロントエンド開発者でもDB回りのことを知っておいて損はないですし、初心者の方向けに解説していますので是非最後まで読んでみてください。

これを最後まで読めば、少なくとも現場で「N+1問題」という言葉が飛んできたときに拒否反応を起こすことはなくなるでしょう！

※今回はLaravelで解説しています

# N+1問題を理解する

N+1問題は**データベースアクセスの非効率性**を引き起こす問題です

ここからは例え話とコードを使って解説していきます。

## その1 図書館の係員  :mortar_board:

あなたは学校の図書館で働いています。
今日の仕事は、**各生徒の借りた本のリストを作成すること**です。

### リストの作成方法

1. **リストの作成方法1 (N+1問題)**
    1. まず、生徒リストを取得します→**1回目のクエリ(100人のリスト取得する)**
    2. 次に、各生徒ごとにその生徒が借りた本のリストを取得します。
    →**100人分なので100回のクエリ**

    この方法では、例えば生徒が100人いて、それぞれが借りた本のリストを取得する場合、DBに100 + 1 = 101回のアクセスを行います。これがN+1問題です。
    </br>
    </br>
2. **リストの作成方法2 (最適化)**
    1. 最初に生徒リストと一緒に、各生徒が借りた本のリストも一度に取得します。
    
    この方法では、DBに1回のアクセスですべての情報を取得できるので、DBへのアクセス回数が大幅に減ります。
    

### 実際のコードでの説明

LaravelでN+1問題を回避するために、**Eager Loading**という方法を使用します。

### N+1問題が発生するコード(以降、最適化と記載)

以下は、ユーザーとそれぞれの投稿を取得する例です。

```php
$users = User::all();

foreach ($users as $user) {
    // ここで各ユーザーの投稿を取得するために追加のクエリが実行されます
    $books = $user->books;
}
```

このコードでは、ユーザーのリストを取得するために1回のクエリが実行されますが、その後、各ユーザーごとに投稿を取得するために追加のクエリが実行されます。ユーザーが100人いる場合、100 + 1 = 101回のクエリが実行されます。

### N+1問題を解決するコード

Eager Loadingを使用して、N+1問題を解決します。

```php
// withメソッドを使用して、ユーザーとその投稿を一度に取得します
$users = User::with('books')->get();

foreach ($users as $user) {
    // ここで各ユーザーの投稿が既にロードされているので、追加のクエリは実行されません
    $books = $user->books;
}
```

このコードでは、`with`メソッドを使用して**ユーザーとその投稿を一度に取得します**。これにより、データベースへのアクセスは**たった1回**に減ります。

## その2 レストランのメニューと料理 🍳

あなたはレストランのシステムを管理しています。
レストランには複数のメニューがあり、それぞれのメニューには複数の料理が含まれているので、**メニューと料理のリストを表示する**必要があります。

### N+1問題が発生する例

1. メニューのリストを取得します→ **DBへの1回のクエリ**
2. 各メニューごとに、そのメニューに含まれる料理のリストを取得します
　→ **メニューが10個ある場合、10回のクエリが発生する**。

**合計クエリ数**: 1 + 10 = 11回

```php
$menus = Menu::all(); // 1回のクエリでメニューリストを取得

foreach ($menus as $menu) {
    // 各メニューの料理リストを取得するために追加のクエリが実行されます
    $dishes = $menu->dishes; // メニューが10個ある場合、10回の追加クエリが実行されます
}
```

### 最適化の例

1. メニューリストと一緒に、各メニューに含まれる料理のリストを一度に取得します。
→ これがDBへの1回のクエリです。**合計クエリ数**: 1回

```php
$menus = Menu::with('dishes')->get(); // 1回のクエリでメニューリストと料理リストを一度に取得

foreach ($menus as $menu) {
    // 各メニューの料理リストが既にロードされているので、追加のクエリは実行されません
    $dishes = $menu->dishes; // 追加のクエリなし
}
```

## その3 ブログ記事とコメント 📚

ブログ記事（Post）とそれに関連するコメント（Comment）を扱うシステムを考えてみましょう。

### **N+1問題が発生する例**

1. ブログ記事のリストを取得します→ これがDBへの1回のクエリです
2. 各ブログ記事ごとに、その記事に関連するコメントのリストを取得します。
 → ブログ記事が50個ある場合、50回の追加クエリが発生します。

**合計クエリ数**: 1 + 50 = 51回

### 実際のコード例（N+1問題が発生する場合）

```php
$posts = Post::all(); // 1回のクエリでブログ記事リストを取得

foreach ($posts as $post) {
    // 各ブログ記事のコメントリストを取得するために追加のクエリが実行されます
    $comments = $post->comments; // ブログ記事が50個ある場合、50回の追加クエリが実行されます
}
```

### 最適化の例
1. ブログ記事リストと一緒に、各記事に関連するコメントのリストを一度に取得します。
→ これはDBの1回のクエリです。

**合計クエリ数**: 1回

### 実際のコード例（N+1問題を解決する場合）

```php
$posts = Post::with('comments')->get(); // 1回のクエリでブログ記事リストとコメントリストを一度に取得

foreach ($posts as $post) {
    // 各ブログ記事のコメントリストが既にロードされているので、追加のクエリは実行されません
    $comments = $post->comments; // 追加のクエリなし
}
```

## その4 Projects, Task, Comment :writing_hand:

プロジェクト管理システムを考えてみましょう。よくあるTODOだと思ってください。

このシナリオでは、プロジェクト管理システムを考えます。システムには、以下の三つのモデルがあります。

1. **Project**: プロジェクトを表します。
2. **Task**: 各プロジェクトには複数のタスクがあります。
3. **Comment**: 各タスクには複数のコメントがあります。

目標は、各プロジェクトとそのタスクおよびタスクに関連するコメントをすべて取得し、それを表示することです。

### N+1問題が発生する例

```php
$projects = Project::all(); // 1回のクエリでプロジェクトリストを取得

foreach ($projects as $project) {
    $tasks = $project->tasks; // 各プロジェクトのタスクリストを取得するために追加のクエリが実行されます

    foreach ($tasks as $task) {
        $comments = $task->comments; // 各タスクのコメントリストを取得するために追加のクエリが実行されます
    }
}
```

**合計クエリ数**: 1 + (プロジェクトの数) + (タスクの数) です。

例えば、10個のプロジェクトがあり、各プロジェクトに10個のタスクがある場合、合計で1 + 10 + 100 = 111回のクエリが実行されます。

### 最適化の例

Eager Loadingを使用して、プロジェクトとそのタスクおよびタスクに関連するコメントを一度に取得します。

```php
// 1回のクエリでプロジェクト、タスク、およびコメントを一度に取得
$projects = Project::with(['tasks.comments'])->get();

foreach ($projects as $project) {
    $tasks = $project->tasks;

    foreach ($tasks as $task) {
        $comments = $task->comments; // 追加のクエリなし
    }
}
```

**合計クエリ数**: 1回

参考までにこの場合は以下のように、各モデルのリレーションが定義されていることが必要となります。※ポリモーフィックリレーションにできますが、わかりやすくするため省略

**Projects Model**

```php
namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;

class Project extends Model
{
    use HasFactory;

    public function tasks()
    {
        return $this->hasMany(Task::class);
    }
}
```

**Tasks Model**

```php
namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;

class Task extends Model
{
    use HasFactory;

    public function project()
    {
        return $this->belongsTo(Project::class);
    }

    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
}
```

**Comments Model**

```php
namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;

class Comment extends Model
{
    use HasFactory;

    public function task()
    {
        return $this->belongsTo(Task::class);
    }
}
```

### DBのmigration

```php
// projectsテーブルのマイグレーション
Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
});

// tasksテーブルのマイグレーション
Schema::create('tasks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('project_id')->constrained()->onDelete('cascade');
    $table->string('title');
    $table->timestamps();
});

// commentsテーブルのマイグレーション
Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('task_id')->constrained()->onDelete('cascade');
    $table->text('body');
    $table->timestamps();
});
```

これらを踏まえた上で、もう一度Eager Loadingの例で確認してみましょう

```php
// N+1問題を回避するためのEager Loading
$projects = Project::with(['tasks.comments'])->get();

foreach ($projects as $project) {
    echo "Project: " . $project->name . "\\n";

    foreach ($project->tasks as $task) {
        echo "  Task: " . $task->title . "\\n";

        foreach ($task->comments as $comment) {
            echo "    Comment: " . $comment->body . "\\n";
        }
    }
}
```

# まとめ
コードの意味が分からない初心者の方は、例えの方で大体どういうことを言っているかが理解できていればいいと思います。
フロントエンド開発者ならGraphQLなどのライブラリツールを使用する機会が多いと思いますが、その際にもこの問題はついてくるので頭の片隅に入れておきましょう。