⚡️ Spring Framework 講座【第24回】遅延ロードの落とし穴!〜パフォーマンスチューニングとN+1問題〜

docs

前回、JPAのリレーションシップにおいて、パフォーマンス維持のため、関連データの取得を遅らせる LAZY(遅延ロード) フェッチ戦略が推奨されることを学びました。

LAZYは非常に便利な機能ですが、使い方を誤ると、アプリケーションの性能を深刻に低下させる「N+1問題」という有名な落とし穴に陥ります。

今回は、このN+1問題の仕組みと、それを解消するための具体的なパフォーマンスチューニングテクニックを学びます。


1. N+1問題とは?

N+1問題とは、LAZY(遅延ロード)を設定している関連データをループ処理で次々と参照した際に発生する、不必要なDBアクセスの問題です。

1-1. 問題の発生シナリオ

例えば、「顧客一覧を取得し、それぞれの顧客の最新の注文情報を表示する」という処理を考えます。User EntityとOrder Entityは **1対多(LAZY)**で関連付けられているとします。

  1. N=1 (最初のクエリ): まず、全ての顧客(User)を1回のSQLで取得します。
    • 実行されるSQL: SELECT * FROM users; (例: 100件のユーザーを取得)
  2. N回 (追加のクエリ): 100人の顧客のリストをループで回し、それぞれの顧客の .getOrders() メソッド(LAZYで設定された関連データ)が初めて呼び出されます。
  3. JPAは、顧客が100人いる場合、個々の顧客に対して注文情報を取得するために100回のSQLを自動で実行します。

合計のクエリ数 = 1 (顧客一覧) + N (100人分の注文) = 101回

データ量が少なければ問題ありませんが、これが数千件になると、DBへの接続・切断が繰り返され、アプリケーションの処理時間が極端に長くなります。これがN+1問題です。


2. N+1問題の解決策(EAGERは避ける)

N+1問題の解決策は、「最初から必要な関連データも一緒に取得する」ことです。ただし、フェッチ戦略を EAGER に変更すると、常に関連データが取得されてしまい、使わない処理でも性能が低下するため、避けるべきです。

解決策は、必要な処理のときだけEAGERと同様の結合検索を行うことです。

2-1. 解決策①:JOIN FETCH(推奨)

**JPQL(Java Persistence Query Language)**の JOIN FETCH 句を使うのが、最も標準的で強力な解決策です。

JOIN FETCH を使うと、SQLの JOIN を実行し、関連先のデータも親エンティティのプロパティとして一度のクエリでロードするようにJPAに指示できます。

Java

// UserRepository.java に定義

import org.springframework.data.jpa.repository.Query;
import java.util.List;

public interface UserRepository extends JpaRepository<User, Long> {

    // JPQLで JOIN FETCH を使用
    // Userを取得する際に、ordersリスト(関連データ)も同時に取得し、N+1を回避する
    @Query("SELECT u FROM User u JOIN FETCH u.orders")
    List<User> findAllUsersWithOrders();
}

このメソッドを呼び出すと、実行されるSQLは 1回SELECT * FROM users u JOIN orders o ON u.id = o.user_id; のようなクエリになります。これにより、アプリケーション側でループ処理を行っても、Ordersを参照する際に追加のDBアクセスは発生しません

2-2. 解決策②:Entity Graph(より宣言的)

@EntityGraph アノテーションを使うと、JPQLを書かずに、どの関連データを一緒に取得するかを宣言的に指定できます。

Java

import org.springframework.data.jpa.repository.EntityGraph;

public interface UserRepository extends JpaRepository<User, Long> {

    // EntityGraphで、ordersという関連(属性)を同時に取得するように指定
    @EntityGraph(attributePaths = {"orders"})
    List<User> findAll();
}
// この findAll() メソッドを呼び出したときだけ、JOIN FETCHが適用される

@EntityGraph は、JOIN FETCH と同様にN+1を回避できますが、JPQLを書く手間がなく、よりコードがシンプルになります。


3. チューニングの基本方針

N+1問題の対策を含め、JPAのパフォーマンスを考える際の基本方針は以下の通りです。

  1. デフォルトはLAZY: 関連データはLAZYのままにする。
  2. 必要なときだけEAGER相当: N+1問題が発生する処理(関連データをループで参照する必要がある処理)でのみ、JOIN FETCH@EntityGraph を使って明示的に結合し、データを取得する。
  3. プロジェクションの活用: 関連データを全て取得するのではなく、必要なフィールドだけを取得するカスタムDTO(プロジェクション)を使うことも、データ転送量を減らす有効な手段です(第25回で詳述)。

✅ 本日のまとめ

  • N+1問題は、LAZY(遅延ロード)設定の関連データをループ処理で参照した際に、大量の不必要なDBアクセスが発生する現象である。
  • N+1問題の解決には、EAGERに変更するのではなく、必要な処理でのみ結合検索を行う方法を選ぶ。
  • JOIN FETCH 句を含むJPQLを使うことで、関連データを1回のクエリで取得し、N+1を回避できる(推奨解決策)。
  • @EntityGraph アノテーションも、JOIN FETCHと同様の効果を、よりシンプルに実現できる。

🔔 次回予告

今回、N+1問題の解決策として「必要なデータだけ取得する」ことの重要性を学びました。しかし、JOIN FETCH を使うと、関連するEntityの全フィールドが取得されてしまいます。

次回は、大量のデータの中から「ユーザーIDと名前だけ」「商品名と価格だけ」のように、本当に必要なデータの一部だけをDTOやインターフェースの形で取得する **プロジェクション(Projection)**のテクニックについて学びます。

次回:【第25回】Spring Data JPA (3) – プロジェクションによる部分データ取得 にご期待ください!

コメント

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