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へ変更する対策は別画面へ負荷を広げる
実行用サンプル
同じ3注文で顧客が全員異なる場合と全員同じ場合を作り、顧客取得のSELECT回数と返す一覧を比較します。 JDK 17とMaven 3.9系で mvn test を実行できます。ZIPにはソース全文・テスト・pom.xml・READMEを収録しています。
N+1問題が起きるコード例
Order.java の全文
package info.syachiku.audit;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity(name = "PurchaseOrder")
@Table(name = "orders")
public class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
private BigDecimal totalAmount;
@Enumerated(EnumType.STRING)
private OrderStatus status;
protected Order() {
}
Order(Long id, Customer customer, BigDecimal totalAmount) {
this.id = id;
this.customer = customer;
this.totalAmount = totalAmount;
this.status = OrderStatus.WAITING;
}
Long getId() {
return id;
}
Customer getCustomer() {
return customer;
}
BigDecimal getTotalAmount() {
return totalAmount;
}
OrderStatus getStatus() {
return status;
}
void ship() {
status = OrderStatus.SHIPPED;
}
}OrderからCustomerを遅延読み込みする関連を考えます。注文一覧を取得し、画面用DTOへ変換する処理が次です。
OrderQueryService.java の全文
package info.syachiku.audit;
import java.util.List;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderQueryService {
private final OrderRepository repository;
OrderQueryService(OrderRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public List<OrderRow> findRows() {
return rows(repository.findAll(Sort.by("id")));
}
@Transactional(readOnly = true)
public List<OrderRow> findRowsWithFetchJoin() {
return rows(repository.findAllWithCustomer());
}
@Transactional(readOnly = true)
public List<OrderRow> findRowsWithEntityGraph() {
return rows(repository.findByStatusOrderById(OrderStatus.WAITING));
}
@Transactional(readOnly = true)
public List<OrderRow> findRowsWithProjection() {
return repository.findOrderRows();
}
private List<OrderRow> rows(List<Order> orders) {
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 = ?; -- 以下繰り返し
上のSQLは仕組みを示す概略です。関連取得は未取得の顧客ごとに発生するため、注文が同じ顧客を参照すれば本数が減ります。別顧客と同一顧客の測定結果は、次のSQLログの章で比較します。
なぜ開発環境では気づきにくいのか
- テストデータが3件程度しかない
- DBが同じPC上にあり通信が速い
- 一度取得したEntityがキャッシュに残っている
- SQLログを無効にしている
- 結果が正しいため機能テストを通過する
本番では数百件、DBは別サーバー、同時アクセスもあるため、SQLの往復回数が応答時間へ大きく影響します。1本あたり数ミリ秒でも、数百本積み重なると画面の遅さになります。
まずSQLログで繰り返しを確認する
Hibernateのバッチ取得とOSIVを無効にし、初期データを登録したトランザクションを閉じてから計測します。注文のSELECTが1回、未取得の顧客のSELECTが顧客ごとに1回です。3注文が別顧客なら4回、同じ顧客なら2回。JOIN FETCH、EntityGraph、DTO取得のいずれも1回となり、返した一覧の値は修正前と同じです。ここでは各読み取り処理がSELECTだけを発行するため、Hibernateが記録したSQL準備回数を比較しています。
OrderRepository.java の全文
package info.syachiku.audit;
import java.util.List;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
select o
from PurchaseOrder o
join fetch o.customer
order by o.id
""")
List<Order> findAllWithCustomer();
@EntityGraph(attributePaths = "customer")
List<Order> findByStatusOrderById(OrderStatus status);
@Query("""
select new info.syachiku.audit.OrderRow(o.id, c.name, o.totalAmount)
from PurchaseOrder o
join o.customer c
order by o.id
""")
List<OrderRow> findOrderRows();
}OrderQueryServiceTest.java の全文
package info.syachiku.audit;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.math.BigDecimal;
import java.util.List;
import java.util.function.Supplier;
import org.hibernate.SessionFactory;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.support.TransactionTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
class OrderQueryServiceTest {
@Autowired
private EntityManager entityManager;
@Autowired
private EntityManagerFactory entityManagerFactory;
@Autowired
private TransactionTemplate transaction;
@Autowired
private OrderQueryService queries;
@ParameterizedTest
@CsvSource({"false, 4", "true, 2"})
void 顧客の共有数でSELECT回数が変わり修正後は一回になる(
boolean sameCustomer, long expectedSelects) {
prepareOrders(sameCustomer);
List<OrderRow> before = countStatements(queries::findRows, expectedSelects);
assertEquals(List.of(1L, 2L, 3L), before.stream().map(OrderRow::orderId).toList());
assertEquals(sameCustomer ? List.of("C1", "C1", "C1") : List.of("C1", "C2", "C3"),
before.stream().map(OrderRow::customerName).toList());
assertEquals(before, countStatements(queries::findRowsWithFetchJoin, 1));
assertEquals(before, countStatements(queries::findRowsWithEntityGraph, 1));
assertEquals(before, countStatements(queries::findRowsWithProjection, 1));
}
private List<OrderRow> countStatements(Supplier<List<OrderRow>> query, long expected) {
var stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.clear();
List<OrderRow> result = query.get();
// These read-only methods perform SELECTs only.
assertEquals(expected, stats.getPrepareStatementCount());
return result;
}
private void prepareOrders(boolean sameCustomer) {
transaction.executeWithoutResult(status -> {
entityManager.createQuery("delete from PurchaseOrder").executeUpdate();
entityManager.createQuery("delete from Customer").executeUpdate();
var first = new Customer(1L, "C1");
entityManager.persist(first);
for (long id = 1; id <= 3; id++) {
var customer = first;
if (!sameCustomer && id != 1) {
customer = new Customer(id, "C" + id);
entityManager.persist(customer);
}
entityManager.persist(new Order(id, customer, BigDecimal.valueOf(100 * id)));
}
});
}
}# 開発・調査環境で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 PurchaseOrder 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> findByStatusOrderById(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 info.syachiku.audit.OrderRow(
o.id,
c.name,
o.totalAmount)
from PurchaseOrder 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とページングに注意する
明細が3行ある注文を1件表示するとき、注文一覧で数えたいのは1注文です。明細を結合したSQLでは3行になります。注文と明細を一緒に取るクエリにそのままPageableを渡すと、Hibernate ORM 6.6ではメモリ上でページングすることがあります。
Spring Boot 3.5.16、Hibernate ORM 6.6.53.Final、H2 2.3.232の実習では、spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=trueに設定します。直接ページングするfindPageWithItemsは例外になります。設定は、メモリでページングするクエリを開発時に見つけるためのものです。 Hibernate ORM 6.6の設定リファレンス
修正版はfindPageIdsで注文IDだけに件数制限を掛け、findWithItemsでそのIDの明細をまとめて取得します。注文日は降順、同じ日は注文IDの昇順です。LEFT JOIN FETCHを使うので明細なしの注文も残ります。以下は実習のOrderRepository.javaの全文です。
package info.syachiku.fetchpaging;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
select o from PurchaseOrder o
left join fetch o.items
order by o.placedOn desc, o.id asc
""")
List<Order> findPageWithItems(Pageable page);
@Query("""
select o.id from PurchaseOrder o
order by o.placedOn desc, o.id asc
""")
List<Long> findPageIds(Pageable page);
@Query("""
select o from PurchaseOrder o
left join fetch o.items
where o.id in :ids
""")
List<Order> findWithItems(@Param("ids") List<Long> ids);
}INで取得した結果の順序には頼らず、OrderPageService.findPageで最初のID順にDTOを組み立てます。空ページなら2回目の取得を省きます。以下は同じZIPのメソッド抜粋です。
@Transactional(readOnly = true)
public List<OrderRow> findPage(int page, int size) {
List<Long> ids = orders.findPageIds(PageRequest.of(page, size));
if (ids.isEmpty()) {
return List.of();
}
Map<Long, Order> byId = orders.findWithItems(ids).stream()
.collect(Collectors.toMap(Order::id, Function.identity()));
return ids.stream()
.map(byId::get)
.filter(Objects::nonNull)
.map(order -> new OrderRow(order.id(), order.itemSkus()))
.toList();
}1ページを2注文とすると、page=0は注文3(明細A・B・C)と注文1(明細なし)、page=1は注文2(明細D)、page=2は空です。この実行では先の2ページでSELECT各2回、空ページで1回です。一覧はListを返すため、全件数を求めるCOUNTは含めていません。
注文と明細のページングを4ケースで試す
上の顧客取得サンプルとは別の実習です。展開先でJDK 17とMaven 3.9系を使い、mvn testを実行します。ZIPにはEntity、Repository、Service、JUnitテスト、設定、pom.xml、手順書を収録しています。
実習の件数は固定データで測定しています。取得の途中で削除された注文は表示から外します。全件数が必要な画面や、更新がある間もページ間の重複・欠落を防ぐ必要がある画面では、その条件を加えて確認します。
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版
アプリを作った後に、DIやDB更新の仕組みを調べる。
DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。小さなWebアプリを一通り作り、Springの仕組みを詳しく学びたい人に向く参考書です。
- Springで小さなWebアプリを作ったことがある
- DIやトランザクションの仕組みを確認したい
Spring Framework 5向けです。Boot 3の実装に使うコード・設定は、採用している版の公式資料と照合してください。
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
N+1問題は、一覧を取得する1本のSQLに加え、関連Entityを読むSQLが件数分発行される状態です。Repositoryを1回しか呼んでいなくても起こるため、SQLログで実際の本数を確認します。
必要な関連だけをJOIN FETCH、@EntityGraph、DTO projectionでまとめて取得し、すべてEAGERへ変える対策は避けます。データ件数を増やしてSQL本数、ページング、別画面への影響まで確認しましょう。



