【Laravel】Laravelのパフォーマンスチューニング入門

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

前回は外部APIとの連携方法を解説しました。

今回は、実装したアプリケーションが「遅い」と感じ始めたときに見直すべき、Laravelのパフォーマンスチューニングの基本を扱います。

やみくもにキャッシュを増やすのではなく、まずボトルネックを見つけることから始めましょう。

パフォーマンス改善の前にボトルネックを特定する

感覚ではなく計測から始める

パフォーマンスチューニングでいちばんやってはいけないのが、「なんとなく遅そうな箇所」を勘で改善することです。

実際には想定と違う箇所がボトルネックになっていることが多く、見当違いの改善に時間を使ってしまいがちです。

Laravelでボトルネックを特定する代表的な方法は次の3つです。

  • Laravel Debugbar:開発環境でクエリ数・実行時間・メモリ使用量を画面上に表示する
  • Laravel Telescope:リクエスト・クエリ・キュー・例外などをまとめて記録し、後から確認できる
  • DB::listen():発行されたSQLをログに出力し、想定外のクエリが飛んでいないか確認する
// AppServiceProvider.php(開発環境限定で使う)
use Illuminate\Support\Facades\DB;

public function boot(): void
{
    if (app()->environment('local')) {
        DB::listen(function ($query) {
            logger()->info($query->sql, $query->bindings);
        });
    }
}

このように「どこで何が起きているか」を可視化してから対策を打つのが、遠回りに見えて実は最短ルートです。

クエリ周りのチューニング

N+1問題をeager loadingで解消する

パフォーマンス低下の原因として最も多いのが、N+1問題(1件のデータ取得ごとに関連データのクエリが追加で発行される問題)です。

❌ Before:ループの中でリレーションにアクセスしてN+1が発生する

$posts = Post::all(); // 投稿を1回のクエリで取得

foreach ($posts as $post) {
    echo $post->user->name; // ここで投稿の件数分だけ追加クエリが発行される
}

投稿が100件あれば、Post::all() の1回に加えて user を取得するクエリが100回、合計101回のクエリが発行されてしまいます。

✅ After:with()でeager loadingし、まとめて取得する

$posts = Post::with('user')->get(); // 投稿とユーザーを合計2回のクエリで取得

foreach ($posts as $post) {
    echo $post->user->name; // 追加のクエリは発生しない
}

with('user') を付けることで、投稿一覧の取得とユーザー情報の取得がそれぞれ1回のクエリにまとまり、クエリ数が101回から2回に激減します。

私が実際に業務で担当した管理画面でも、一覧表示に3秒近くかかっていたページがN+1問題を解消しただけで200ミリ秒台まで改善した経験があります。

原因はまさにこのパターンで、コードレビューでは見逃されがちなので、一覧表示系の実装が終わったら必ずLaravel Debugbarでクエリ数を確認する習慣をつけています。

必要なカラムだけをselect()で絞り込む

一覧画面で使わないカラムまで毎回取得していると、データ転送量やメモリ使用量が無駄に増えます。

// ❌ Before:全カラムを取得する
$posts = Post::with('user')->get();

// ✅ After:必要なカラムだけを取得する
$posts = Post::select(['id', 'title', 'user_id', 'created_at'])
    ->with('user:id,name')
    ->get();

with('user:id,name') のように、リレーション側でも取得カラムを絞り込める点がポイントです。

大きなテキストカラム(body など)を一覧画面で使わないなら、こうした絞り込みだけでもクエリの実行速度は変わってきます。

インデックスの有効活用

検索やソートに使うカラムにインデックスが設定されていないと、テーブルの件数が増えるにつれて検索速度が急激に悪化します。

// database/migrations/xxxx_add_index_to_posts_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->index('status');
            $table->index(['user_id', 'created_at']); // 複合インデックス
        });
    }

    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropIndex(['status']);
            $table->dropIndex(['user_id', 'created_at']);
        });
    }
};

WHERE status = 'published' のように頻繁に絞り込みに使うカラムや、ORDER BY created_at と組み合わせてよく使うカラムには、インデックスを検討する価値があります。

ただしインデックスは検索を速くする一方で、書き込み(INSERT・UPDATE)をわずかに遅くする性質もあるため、「よく検索するが、書き込み頻度はそこまで高くない」テーブル・カラムから優先的に検討するのがセオリーです。

キャッシュの活用

クエリ結果をキャッシュする

頻繁にアクセスされる一方で更新頻度が低いデータは、キャッシュを使うことでデータベースへの負荷を大きく減らせます。

use Illuminate\Support\Facades\Cache;

public function getCategories()
{
    return Cache::remember('categories.all', now()->addHours(6), function () {
        return Category::orderBy('name')->get();
    });
}

Cache::remember() は、キャッシュが存在すればそれを返し、存在しなければクロージャの処理結果をキャッシュに保存してから返してくれる便利なメソッドです。

カテゴリ一覧のような「めったに変わらないが、あらゆるページで参照される」データは、キャッシュの効果が特に出やすい対象です。

キャッシュの更新漏れに注意する

キャッシュを導入する際にありがちな失敗が、データ更新時にキャッシュを消し忘れることです。

❌ Before:キャッシュを消さずにデータを更新する

public function update(Request $request, Category $category)
{
    $category->update($request->validated());

    return redirect()->route('categories.index');
    // キャッシュは古いままなので、一覧画面には更新前のデータが表示され続ける
}

✅ After:更新時に該当のキャッシュを明示的に消す

use Illuminate\Support\Facades\Cache;

public function update(Request $request, Category $category)
{
    $category->update($request->validated());

    Cache::forget('categories.all');

    return redirect()->route('categories.index');
}

キャッシュの有効期限が切れるまで古いデータが表示され続けてしまうバグは、「なぜか変更が反映されない」という問い合わせにつながりやすく、原因の切り分けにも時間がかかりがちです。

キャッシュを導入するときは、必ず「どのタイミングで消すか」までセットで設計することを徹底しています。

設定・環境レベルのチューニング

本番環境でのコンフィグ・ルートキャッシュ

Laravelには、設定ファイルやルート定義を事前にキャッシュして起動を高速化する仕組みがあります。

php artisan config:cache
php artisan route:cache
php artisan view:cache

これらのコマンドは、毎リクエストで設定ファイルを読み込んだりルートを解決したりする処理を省略できるようにするもので、本番環境のデプロイフローに組み込むのが定番です。

ただし config:cache を実行すると、.env の値が設定ファイルに焼き込まれ、以降 .env を直接読む env() 関数がコントローラなどで正しく動かなくなる点には注意が必要です。

config() ヘルパー経由で値を取得するようにコードを統一しておくことが、このキャッシュ機構を安全に使うための前提になります。

キューを使って重い処理を非同期化する

メール送信や画像のリサイズなど、時間のかかる処理をリクエスト内で同期的に実行すると、ユーザーの待ち時間が長くなってしまいます。

// app/Jobs/SendWelcomeEmail.php
namespace App\Jobs;

use App\Mail\WelcomeMail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Mail;

class SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, Queueable;

    public function __construct(public User $user)
    {
    }

    public function handle(): void
    {
        Mail::to($this->user->email)->send(new WelcomeMail($this->user));
    }
}
// 呼び出し側
SendWelcomeEmail::dispatch($user);

ShouldQueue を実装したジョブをディスパッチすると、処理はキューに積まれ、バックグラウンドのワーカーが処理してくれます。

会員登録直後のレスポンスは即座に返しつつ、メール送信は裏側で非同期に実行できるため、体感速度が大きく改善します。

まとめ

この記事のポイント

  • チューニングは勘に頼らず、Laravel DebugbarやTelescopeで計測してから着手する
  • N+1問題はeager loading(with())で解消し、必要なカラムだけをselect()で絞り込む
  • 検索・ソートで頻繁に使うカラムにはインデックスを検討する
  • キャッシュは更新時の削除漏れに注意しながら導入する
  • 設定・ルートキャッシュとキューを組み合わせ、リクエストごとの処理を軽くする

次に読むべき記事

パフォーマンス面のチューニングを押さえたところで、次回からは設計・アーキテクチャ編に入り、Controllerを太らせない書き方を解説します。

→ 次の記事:MVCを実践する:太らせないControllerの書き方

タイトルとURLをコピーしました