前回、JPAのリレーションシップにおいて、パフォーマンス維持のため、関連データの取得を遅らせる LAZY(遅延ロード) フェッチ戦略が推奨されることを学びました。
LAZYは非常に便利な機能ですが、使い方を誤ると、アプリケーションの性能を深刻に低下させる「N+1問題」という有名な落とし穴に陥ります。
今回は、このN+1問題の仕組みと、それを解消するための具体的なパフォーマンスチューニングテクニックを学びます。
1. N+1問題とは?
N+1問題とは、LAZY(遅延ロード)を設定している関連データをループ処理で次々と参照した際に発生する、不必要なDBアクセスの問題です。
1-1. 問題の発生シナリオ
例えば、「顧客一覧を取得し、それぞれの顧客の最新の注文情報を表示する」という処理を考えます。User EntityとOrder Entityは **1対多(LAZY)**で関連付けられているとします。
- N=1 (最初のクエリ): まず、全ての顧客(User)を1回のSQLで取得します。
- 実行されるSQL:
SELECT * FROM users;(例: 100件のユーザーを取得)
- 実行されるSQL:
- N回 (追加のクエリ): 100人の顧客のリストをループで回し、それぞれの顧客の
.getOrders()メソッド(LAZYで設定された関連データ)が初めて呼び出されます。 - 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のパフォーマンスを考える際の基本方針は以下の通りです。
- デフォルトはLAZY: 関連データはLAZYのままにする。
- 必要なときだけEAGER相当: N+1問題が発生する処理(関連データをループで参照する必要がある処理)でのみ、
JOIN FETCHや@EntityGraphを使って明示的に結合し、データを取得する。 - プロジェクションの活用: 関連データを全て取得するのではなく、必要なフィールドだけを取得するカスタム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) – プロジェクションによる部分データ取得 にご期待ください!


コメント