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


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 FETCH | JPQLで結合・条件を明示したい | クエリが長くなる |
| @EntityGraph | Repositoryメソッドへ取得範囲を加えたい | 複雑な条件は別途必要 |
| 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を見つけた時の確認順
- 遅いリクエストと対象画面を固定する
- SQLログを有効にし発行本数を数える
- IDだけ変わる同型SELECTを探す
- どのgetter・DTO変換・テンプレート参照で出るか特定する
- その画面で本当に必要な関連を決める
- JOIN FETCH、@EntityGraph、DTO取得から最小の方法を選ぶ
- 件数を増やしてSQL本数が増えないことを確認する
- 別画面やページングへの副作用を確認する
現場レビューでよくある指摘
// レビューコメント例 注文一覧の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版
Spring Frameworkの主要機能を、実務レベルで整理する。
DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。ピンポイント検索から体系学習へ進みたい人向けです。
- Springの全体像を整理したい
- 現場で使う機能を体系的に学びたい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
N+1問題は、一覧を取得する1本のSQLに加え、関連Entityを読むSQLが件数分発行される状態です。Repositoryを1回しか呼んでいなくても起こるため、SQLログで実際の本数を確認します。
必要な関連だけをJOIN FETCH、@EntityGraph、DTO projectionでまとめて取得し、すべてEAGERへ変える対策は避けます。データ件数を増やしてSQL本数、ページング、別画面への影響まで確認しましょう。
