Spring Data JPAのN+1問題を解説する記事のアイキャッチ。Java道場風の空間で若手エンジニアが大量SQLとJOINを調査するイラスト。

Spring

Spring Data JPAでSQLが大量に発行される原因|N+1問題の確認方法と対策

AIによる要約

N+1問題は、一覧を取得する1本のSQLに加え、各行の関連Entityを読むたびに追加SQLが発行される状態です。件数が少ない開発環境では気づきにくく、本番データで急に遅くなります。SQLログで「似たSELECTがIDを変えて繰り返されていないか」を確認し、その画面で必要な関連だけをJOIN FETCH、@EntityGraph、DTO projectionなどでまとめて取得します。すべてをEAGERへ変える対策は避けます。

新人SE
新人SE
一覧画面は表示できますが、注文が100件になると急に遅くなります。RepositoryのfindAllは1回しか呼んでいません。
ポンコツSE
ポンコツSE
Java上のRepository呼び出しが1回でも、関連Entityを参照した時にSQLが追加発行されることがあります。まずSQLログを数えましょう。

Spring Data JPAでは、Entity同士の関連をJavaオブジェクトとしてたどれます。便利な一方、一覧処理のループ内で関連を参照すると、気づかないうちに大量のSELECTが発行されることがあります。

N+1問題は、エラーにならず結果も正しいため、レビューや単体テストで見落としやすい不具合です。現場では「ローカルでは速いのに、本番だけ遅い」という形で現れます。

この記事のポイント

  • Repositoryの呼び出し回数ではなく発行SQLを確認する
  • 1件の一覧SQLとN件の関連SQLでN+1になる
  • そのユースケースで必要な関連だけをまとめて取得する
  • JOIN FETCHと@EntityGraphは取得単位で使い分ける
  • すべてEAGERへ変更する対策は別画面へ負荷を広げる

N+1問題が起きるコード例

@Entity
public class Order {

    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    private BigDecimal totalAmount;
}

OrderからCustomerを遅延読み込みする関連を考えます。注文一覧を取得し、画面用DTOへ変換する処理が次です。

@Transactional(readOnly = true)
public List<OrderRow> findRows() {
    List<Order> orders = orderRepository.findAll();

    return orders.stream()
            .map(order -> new OrderRow(
                    order.getId(),
                    order.getCustomer().getName(),
                    order.getTotalAmount()))
            .toList();
}

findAllで注文一覧を取得するSQLが1本発行されます。その後、order.getCustomer().getName()を評価すると、未取得のCustomerを読むSQLが注文ごとに発行される可能性があります。

-- 最初の1本
select o.id, o.customer_id, o.total_amount
from orders o;

-- 注文ごとに追加されるSQL
select c.id, c.name from customers c where c.id = ?;
select c.id, c.name from customers c where c.id = ?;
select c.id, c.name from customers c where c.id = ?;
-- 以下繰り返し

注文がN件なら、最初の1本と関連取得N本で「1 + N」です。同じ顧客がキャッシュ済みなら本数が減る場合もありますが、データの偏りに依存するため安心できません。

なぜ開発環境では気づきにくいのか

  • テストデータが3件程度しかない
  • DBが同じPC上にあり通信が速い
  • 一度取得したEntityがキャッシュに残っている
  • SQLログを無効にしている
  • 結果が正しいため機能テストを通過する

本番では数百件、DBは別サーバー、同時アクセスもあるため、SQLの往復回数が応答時間へ大きく影響します。1本あたり数ミリ秒でも、数百本積み重なると画面の遅さになります。

まずSQLログで繰り返しを確認する

# 開発・調査環境でSQLを確認する例
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# logger名は利用するHibernate/Spring Bootのバージョンに合わせる
logging.level.org.hibernate.SQL=DEBUG

設定はプロジェクトのバージョンとログ方針に合わせます。本番で常時SQLやバインド値を詳細出力すると、ログ量と機密情報の問題があるため、調査環境や一時設定を基本にします。

N+1を疑うログの特徴は次の通りです。

  • 同じ形のSELECTが短時間に何度も出る
  • WHERE句のIDだけが変わっている
  • 一覧件数を増やすとSQL本数も比例して増える
  • DTO変換やテンプレート描画中に追加SQLが出る

N+1は「処理が遅い」だけで判断せず、一覧件数とSQL本数の関係を確認して特定します。

対策1:JOIN FETCHで必要な関連を同時取得する

public interface OrderRepository
        extends JpaRepository<Order, Long> {

    @Query("""
           select o
             from Order o
             join fetch o.customer
            order by o.id
           """)
    List<Order> findAllWithCustomer();
}

JOIN FETCHを使うと、OrderとCustomerを同じクエリで取得できます。注文一覧で必ず顧客名を表示するなら、そのユースケースに合った取得方法です。

select
    o.id,
    o.total_amount,
    c.id,
    c.name
from orders o
join customers c
  on c.id = o.customer_id
order by o.id;

Repositoryメソッド名にWithCustomerなどを入れ、通常のfindAllとの違いを示すと、呼び出し側が取得範囲を判断しやすくなります。

対策2:@EntityGraphで取得対象を指定する

public interface OrderRepository
        extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = "customer")
    List<Order> findByStatus(OrderStatus status);
}

@EntityGraphは、Repositoryメソッド単位で取得したい関連を宣言できます。JPQLを手書きせず、派生クエリへ取得グラフを加えたい場合に使いやすい方法です。

方法向いている場面注意点
JOIN FETCHJPQLで結合・条件を明示したいクエリが長くなる
@EntityGraphRepositoryメソッドへ取得範囲を加えたい複雑な条件は別途必要
DTO projection一覧に必要な列だけ取得したい更新用Entityとしては使わない

対策3:一覧用DTOを直接取得する

public record OrderRow(
        Long orderId,
        String customerName,
        BigDecimal totalAmount) {
}

@Query("""
       select new com.example.order.OrderRow(
           o.id,
           c.name,
           o.totalAmount)
         from Order o
         join o.customer c
        order by o.id
       """)
List<OrderRow> findOrderRows();

一覧画面で使う列が決まっているなら、Entity全体ではなくDTOを直接取得できます。遅延関連を後からたどらず、公開項目も明確です。

一方、取得した注文をそのまま更新する処理には向きません。参照用クエリと更新用Entity取得を役割で分けます。

すべてEAGERにする対策が危険な理由

// N+1が出たからと、すべてEAGERへ変えない
@ManyToOne(fetch = FetchType.EAGER)
private Customer customer;

EAGERへ変えると、顧客情報が不要なバッチや集計でも常に取得されます。関連がさらに別の関連を持つと、想定以上に大きなオブジェクトグラフを読み込むことがあります。

また、EAGERだから必ず1本のJOINになるとは限りません。JPA実装が追加SELECTを使えばN+1になることもあります。取得戦略はマッピングへ固定するより、画面や処理ごとの必要データに合わせてクエリで指定する方が追いやすいです。

コレクションのJOIN FETCHとページングに注意する

Orderが複数のOrderItemを持つ場合、1対多のコレクションをJOIN FETCHすると、SQL結果では注文行が明細数だけ重複します。

@Query("""
       select distinct o
         from Order o
         join fetch o.items
        where o.status = :status
       """)
List<Order> findWithItems(OrderStatus status);

distinctでEntity重複を整理できる場合がありますが、ページングと組み合わせると、DB上の行数と注文件数が一致せず問題になることがあります。ページ一覧では先に注文IDをページングし、別クエリで関連を取得するなど、既存プロジェクトの方式へ揃えます。

新人が最初から複雑な最適化を独自実装する必要はありません。まず「一覧1ページで何が必要か」を整理し、SQL本数と結果件数をレビューで共有してください。

Open Session in Viewに隠されることがある

設定によっては、ControllerやThymeleafの描画中も遅延読み込みが可能です。その場合、ServiceではSQLが少なく見えても、テンプレートのループ中にN本追加されます。

<tr th:each="order : ${orders}">
  <td th:text="${order.customer.name}"></td>
  <td th:text="${order.totalAmount}"></td>
</tr>

テンプレートへEntityを直接渡すと、どの参照でSQLが出たか気づきにくくなります。Service内で必要データを取得し、画面用DTOへ変換して渡すとSQLの境界が明確になります。

N+1を見つけた時の確認順

  1. 遅いリクエストと対象画面を固定する
  2. SQLログを有効にし発行本数を数える
  3. IDだけ変わる同型SELECTを探す
  4. どのgetter・DTO変換・テンプレート参照で出るか特定する
  5. その画面で本当に必要な関連を決める
  6. JOIN FETCH、@EntityGraph、DTO取得から最小の方法を選ぶ
  7. 件数を増やしてSQL本数が増えないことを確認する
  8. 別画面やページングへの副作用を確認する

現場レビューでよくある指摘

// レビューコメント例
注文一覧のDTO変換でcustomerを参照しており、
一覧件数分のSELECTが発行されています。SQLログを確認してください。

// レビューコメント例
対策として全関連をEAGERへ変更していますが、
顧客を使わない処理にも取得負荷が広がります。
この一覧用Repositoryメソッドだけで取得範囲を指定してください。

// レビューコメント例
1対多のfetch joinとPageableを組み合わせています。
件数・ページ境界が正しいか、実データ量でSQLと結果を確認してください。

提出前のセルフチェック

  • 一覧件数を増やしてSQL本数を確認したか
  • ループ内で関連Entityを参照していないか
  • テンプレート描画中のSQLも確認したか
  • 必要な関連だけをまとめて取得しているか
  • すべてをEAGERへ変更していないか
  • 1対多JOINとページングの結果を確認したか
  • 参照専用ならDTO projectionを検討したか
  • SQL・バインド値を本番へ常時詳細出力していないか

Spring Data JPAを体系的に学ぶ参考書

Entityの関連、遅延読み込み、Repositoryクエリを一つの仕組みとして学ぶと、SQLログからN+1の原因を説明しやすくなります。

書籍「Spring徹底入門 第2版」の表紙
商品画像:Amazon.co.jp
PR Springを体系的に学ぶ若手向け

Spring徹底入門 第2版

株式会社NTTデータ (著), 株式会社NTTデータグループ (編集)

Spring Frameworkの主要機能を、実務レベルで整理する。

DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。ピンポイント検索から体系学習へ進みたい人向けです。

  • Springの全体像を整理したい
  • 現場で使う機能を体系的に学びたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

まとめ

N+1問題は、一覧を取得する1本のSQLに加え、関連Entityを読むSQLが件数分発行される状態です。Repositoryを1回しか呼んでいなくても起こるため、SQLログで実際の本数を確認します。

必要な関連だけをJOIN FETCH、@EntityGraph、DTO projectionでまとめて取得し、すべてEAGERへ変える対策は避けます。データ件数を増やしてSQL本数、ページング、別画面への影響まで確認しましょう。

-Spring
-, , , , , , ,