Spring Data JPAのN+1問題を解決する方法 - @EntityGraphとJOIN FETCHの使い分け


開発環境では問題なかったのに、データ量が増えると急激にレスポンスが悪化する。ログを見ると大量のSQLクエリが発行されている。これが典型的なN+1問題です。

この記事では、N+1問題の検出方法から @EntityGraphJOIN 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 が発生することがあります。この場合は ListSet に変更するか、クエリを分割して実行しましょう。

@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 が発生することがあります。その場合は @EntityGraphJOIN FETCH で明示的に取得しましょう。

バッチフェッチによるクエリ最適化

@EntityGraphJOIN 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 を使う場合も対策が必要です。

@EntityGraphPageable は組み合わせて使えます。

public interface ArticleRepository extends JpaRepository<Article, Long> {
    
    @EntityGraph(attributePaths = "comments")
    Page<Article> findAll(Pageable pageable);
}

@EntityGraphJOIN 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問題を解決する方法を紹介しました。

基本的な流れは以下の通りです。

  1. クエリログを有効化して問題を検出
  2. @EntityGraph または JOIN FETCH で関連を一度に取得
  3. すべての関連を LAZY にして必要な箇所だけFETCH
  4. バッチフェッチで更なる最適化

という順序で進めるとよいでしょう。

本番環境にデプロイする前に、必ずクエリログを確認する習慣をつけると、パフォーマンス問題を未然に防げますよ。