Spring Data JPAのN+1問題を解決する方法 - @EntityGraphとJOIN FETCHの使い分け
開発環境では問題なかったのに、データ量が増えると急激にレスポンスが悪化する。ログを見ると大量のSQLクエリが発行されている。これが典型的なN+1問題です。
この記事では、N+1問題の検出方法から @EntityGraph や JOIN FETCH を使った解決策まで解説します。
N+1問題とは
N+1問題は、関連エンティティを取得する際に発生するパフォーマンス問題です。
例えば、記事(Article)とコメント(Comment)が1対多の関係にある場合を考えてみましょう。
@Entity
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@OneToMany(mappedBy = "article")
private List<Comment> comments = new ArrayList<>();
}
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "article_id")
private Article article;
}
次のようなコードで記事一覧とそれぞれのコメント数を表示しようとすると、以下のようになります。
List<Article> articles = articleRepository.findAll();
for (Article article : articles) {
System.out.println(article.getTitle() + ": " + article.getComments().size());
}
実際には以下のようなクエリが発行されます。
-- 1回目: 記事一覧取得
SELECT * FROM article;
-- 2回目以降: 各記事のコメント取得(記事数分繰り返し)
SELECT * FROM comment WHERE article_id = 1;
SELECT * FROM comment WHERE article_id = 2;
SELECT * FROM comment WHERE article_id = 3;
...
記事が100件あれば、1 + 100 = 101回のクエリが実行されます。これがN+1問題です。
N+1問題の検出方法
自分のアプリケーションでN+1問題が発生しているか確認するには、まずクエリログを有効化しましょう。
application.properties に以下を追加します。
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
spring.jpa.show-sql は標準出力にSQLを表示します。本番環境では logging.level.org.hibernate.SQL を使う方が、ロギングフレームワーク経由で出力されるため管理しやすいですね。
同じパターンのクエリが繰り返し実行されていたら、N+1問題の可能性が高いです。
より詳細に分析したい場合は、Hibernate統計機能も有効化できます。
spring.jpa.properties.hibernate.generate_statistics=true
@EntityGraphによる解決方法
@EntityGraph を使うと、関連エンティティを一度のクエリで取得できます。
Repositoryインターフェースで @EntityGraph を指定するだけです。
public interface ArticleRepository extends JpaRepository<Article, Long> {
@EntityGraph(attributePaths = "comments")
List<Article> findAll();
@EntityGraph(attributePaths = "comments")
Optional<Article> findById(Long id);
}
attributePaths に取得したい関連エンティティのフィールド名を指定します。これで記事とコメントが1回のクエリで取得され、LEFT JOINが使われます。
ネストした関連エンティティも取得したい場合は、@NamedEntityGraph を使います。
@Entity
@NamedEntityGraph(
name = "Article.withCommentsAndAuthor",
attributeNodes = {
@NamedAttributeNode("comments"),
@NamedAttributeNode(value = "author", subgraph = "author-detail")
},
subgraphs = {
@NamedSubgraph(
name = "author-detail",
attributeNodes = @NamedAttributeNode("profile")
)
}
)
public class Article {
@ManyToOne(fetch = FetchType.LAZY)
private User author;
// ...
}
Repositoryでは名前を指定するだけです。
@EntityGraph("Article.withCommentsAndAuthor")
List<Article> findAll();
JPQLのJOIN FETCHによる解決方法
カスタムクエリが必要な場合は、JPQLで JOIN FETCH を使います。クエリメソッドの応用として、より柔軟な条件指定が可能です。
public interface ArticleRepository extends JpaRepository<Article, Long> {
@Query("SELECT a FROM Article a LEFT JOIN FETCH a.comments WHERE a.published = true")
List<Article> findPublishedArticlesWithComments();
}
JOIN FETCH を使うと、関連エンティティも同時に取得されます。複数の関連を取得する場合は以下のようにします。
@Query("SELECT a FROM Article a " +
"LEFT JOIN FETCH a.comments " +
"LEFT JOIN FETCH a.author " +
"WHERE a.createdAt >= :since")
List<Article> findRecentArticlesWithDetails(@Param("since") LocalDateTime since);
LEFT JOIN FETCH は関連エンティティがない場合も親エンティティを取得します。必ず関連が存在する場合は INNER JOIN FETCH を使うとNULLチェックが不要になり、パフォーマンスが良くなることもあります。
注意点として、複数のコレクション関連を同時に JOIN FETCH すると MultipleBagFetchException が発生することがあります。この場合は List を Set に変更するか、クエリを分割して実行しましょう。
@EntityGraphとJOIN FETCHの使い分け
どちらを使うべきか迷いますよね。基本的な使い分けは以下の通りです。
@EntityGraphを使う場合:
- シンプルな関連取得(条件なし)
- 既存のクエリメソッドに適用したい
- 複数箇所で同じFETCH戦略を再利用したい
JOIN FETCHを使う場合:
- WHERE句で条件を指定したい
- 複雑な結合条件が必要
- 関連エンティティにも条件を付けたい
併用は予測困難なため推奨されません。どちらか一方を選びましょう。
FetchType.LAZYとFetchType.EAGERの選択基準
基本方針は すべてLAZYにして、必要な箇所だけFETCHで取得する です。
EAGER を使うと、エンティティを取得するたびに関連も自動的に取得されます。必要ないデータまで取得してしまい、N+1問題が見えにくくなります。
明示的に LAZY を設定しておきましょう。
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;
既存コードで EAGER のままの場合、LAZY に変更すると LazyInitializationException が発生することがあります。その場合は @EntityGraph や JOIN FETCH で明示的に取得しましょう。
バッチフェッチによるクエリ最適化
@EntityGraph や JOIN FETCH を使えない場面では バッチフェッチ が有効です。N+1回のクエリをN/batch_size+1回に削減します。
application.properties に以下を追加します。
spring.jpa.properties.hibernate.default_batch_fetch_size=10
これで関連エンティティを10件ずつまとめて取得するようになります。
-- バッチフェッチなし: 100回のクエリ
SELECT * FROM comment WHERE article_id = 1;
SELECT * FROM comment WHERE article_id = 2;
...
-- バッチフェッチあり: 10回のクエリ
SELECT * FROM comment WHERE article_id IN (1, 2, 3, ..., 10);
SELECT * FROM comment WHERE article_id IN (11, 12, 13, ..., 20);
...
推奨値は10〜50程度です。大きすぎるとIN句が長くなりすぎて逆効果になることがあります。
エンティティごとに個別設定したい場合は @BatchSize を @OneToMany 側に付けます。
@Entity
public class Article {
@OneToMany(mappedBy = "article")
@BatchSize(size = 20)
private List<Comment> comments = new ArrayList<>();
}
ページネーション実装時のN+1対策
ページング処理でもN+1問題は発生します。ページネーション実装で Pageable を使う場合も対策が必要です。
@EntityGraph と Pageable は組み合わせて使えます。
public interface ArticleRepository extends JpaRepository<Article, Long> {
@EntityGraph(attributePaths = "comments")
Page<Article> findAll(Pageable pageable);
}
@EntityGraph や JOIN FETCH でコレクションを取得する場合、JOINにより重複行が発生します。カウントが不正確になるため、@Query でカウントクエリを分離し、DISTINCT を使います。
@Query(value = "SELECT DISTINCT a FROM Article a LEFT JOIN FETCH a.comments",
countQuery = "SELECT COUNT(DISTINCT a) FROM Article a")
Page<Article> findAllWithComments(Pageable pageable);
パフォーマンス改善の実例
実際にN+1問題を解決した例を見てみましょう。
改善前:
// 記事100件を取得してコメント数を表示
List<Article> articles = articleRepository.findAll();
for (Article article : articles) {
int commentCount = article.getComments().size();
System.out.println(article.getTitle() + ": " + commentCount);
}
クエリログは以下の通りです。
-- 1回目
SELECT * FROM article; -- 100件取得
-- 2〜101回目
SELECT * FROM comment WHERE article_id = 1;
SELECT * FROM comment WHERE article_id = 2;
... (100回繰り返し)
測定条件: 記事100件、各記事平均5コメント、ローカル開発環境(PostgreSQL)
実行時間: 約2.5秒
改善後:
// Repositoryに@EntityGraphを追加
@EntityGraph(attributePaths = "comments")
List<Article> findAll();
// 同じコードを実行
List<Article> articles = articleRepository.findAll();
for (Article article : articles) {
int commentCount = article.getComments().size();
System.out.println(article.getTitle() + ": " + commentCount);
}
クエリログは以下のようになります。
-- 1回のみ(LEFT JOINで記事とコメントを同時取得)
SELECT a.*, c.*
FROM article a
LEFT JOIN comment c ON a.id = c.article_id;
実行時間: 約0.08秒
クエリ数が101回から1回に削減され、実行時間も30倍以上改善されました。
まとめ
Spring Data JPAのN+1問題を解決する方法を紹介しました。
基本的な流れは以下の通りです。
- クエリログを有効化して問題を検出
@EntityGraphまたはJOIN FETCHで関連を一度に取得- すべての関連を
LAZYにして必要な箇所だけFETCH - バッチフェッチで更なる最適化
という順序で進めるとよいでしょう。
本番環境にデプロイする前に、必ずクエリログを確認する習慣をつけると、パフォーマンス問題を未然に防げますよ。