# 2026年Spring Data JPAのN+1問題解決法:Fetch JoinとEntityGraph > Spring Data JPAにおけるN+1問題の検出と修正のための完全ガイド。Fetch join、@EntityGraph、batch fetching、クエリパフォーマンス戦略を解説します。 - Published: 2026-03-17 - Updated: 2026-05-01 - Author: SharpSkill - Tags: spring data jpa, n+1 problem, fetch join, entitygraph, performance - Reading time: 12 min --- N+1問題は、JPAで最もよく見られるパフォーマンス上の落とし穴の一つです。100件の注文を取得する単純なクエリが、101回のSQLクエリを引き起こす可能性があります。注文用に1回、関連する各顧客に対して1回ずつです。この静かなクエリの増殖は、パフォーマンスを低下させ、データベースに過度の負荷をかけます。 > **N+1の実際の影響** > > 50件の記事を著者と共に返すエンドポイントは、N+1のせいで10msから500msに跳ね上がることがあります。早期検出により、本番環境での重大な問題を防げます。 ## JPAにおけるN+1問題の理解 N+1問題は、JPAがエンティティのコレクションを読み込んだ後、各エンティティの関連を読み込むために追加のクエリを実行する時に発生します。この動作は、`@OneToMany`および`@ManyToMany`関係のデフォルトのlazy loadingに起因します。 注文と顧客を持つ古典的なモデルを考えます。各注文は1人の顧客に属し、この関係はデフォルトでlazy loadingを使用します。 ```java // Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String orderNumber; private LocalDateTime createdAt; // ManyToOne relationship is lazy by default since JPA 2.0 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // OneToMany relationship lazy by default @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) private List items = new ArrayList<>(); // Getters and setters omitted } ``` ```java // Customer.java @Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; // Getters and setters omitted } ``` クエリが注文を取得した後で顧客名にアクセスすると、Hibernateは各注文に対して追加のクエリを実行します。 ```java // OrderService.java - Problematic code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // 1 query: SELECT * FROM orders List orders = orderRepository.findAll(); // For each order, accessing customer triggers a query return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), // N queries: SELECT * FROM customers WHERE id = ? order.getCustomer().getName() )) .toList(); } } ``` 100件の注文に対して、このコードは101回のSQLクエリを実行します。Hibernateのログがこの破壊的なパターンを明らかにします。 ```sql -- Query 1: fetch orders SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o -- Queries 2-101: fetch each customer SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1 SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2 SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3 -- ... 97 more identical queries ``` ## HibernateログによるN+1問題の検出 最初のステップは、SQLログを有効にして問題のあるクエリを特定することです。次の設定により、Hibernateが実行する各クエリが表示されます。 ```yaml # application.yml spring: jpa: show-sql: true properties: hibernate: # Format SQL for better readability format_sql: true # Display session statistics (queries, time) generate_statistics: true logging: level: # Detailed SQL query logging org.hibernate.SQL: DEBUG # Display prepared statement parameters org.hibernate.orm.jdbc.bind: TRACE ``` Hibernateの統計は、各トランザクションの最後に貴重な要約を提供します。 ``` Session Metrics { 23421 nanoseconds spent acquiring 1 JDBC connection; 0 nanoseconds spent releasing 0 JDBC connections; 1254789 nanoseconds spent preparing 101 JDBC statements; 15478963 nanoseconds spent executing 101 JDBC statements; 0 nanoseconds spent executing 0 JDBC batches; } ``` 単純な注文リストに対する101件のJDBCステートメント数は、明らかにN+1問題を示しています。 > **本番環境では無効化** > > SQLログと統計はパフォーマンスに影響を与えます。これらのオプションは本番環境では無効のままにし、開発およびテスト環境のみで使用してください。 ## 解決策1:JPQLによるFetch Join Fetch joinはjoinを通じて関連を1つのSQLクエリで読み込みます。この明示的なアプローチは、必要なすべてのデータを一度に取得することでN+1を解決します。 ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Explicit fetch join to load customers @Query("SELECT o FROM Order o JOIN FETCH o.customer") List findAllWithCustomer(); // Multiple fetch join for several associations @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "JOIN FETCH o.items i") List findAllWithCustomerAndItems(); // Fetch join with WHERE condition @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "WHERE o.createdAt > :since") List findRecentOrdersWithCustomer( @Param("since") LocalDateTime since ); } ``` Fetch joinは、N+1クエリを単一の最適化されたクエリに変換します。 ```sql -- Single query with join SELECT o.id, o.order_number, o.created_at, o.customer_id, c.id, c.name, c.email FROM orders o JOIN customers c ON o.customer_id = c.id ``` サービスは、ビジネスコードを変更せずに最適化されたメソッドを使用するようになります。 ```java // OrderService.java - Optimized code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // Single query with join List orders = orderRepository.findAllWithCustomer(); // No additional queries return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), order.getCustomer().getName() // Already loaded )) .toList(); } } ``` ## 解決策2:宣言的制御のための@EntityGraph `@EntityGraph`アノテーションは、fetch joinに代わる宣言的な選択肢を提供します。カスタムJPQLを書かずに、どの関連を読み込むかを定義します。 ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Inline EntityGraph with attributePaths @EntityGraph(attributePaths = {"customer"}) List findAll(); // EntityGraph with multiple attributes @EntityGraph(attributePaths = {"customer", "items"}) List findByCreatedAtAfter(LocalDateTime since); // Named EntityGraph referencing entity definition @EntityGraph(value = "Order.withCustomerAndItems") List findByCustomerId(Long customerId); // Combination with custom query @EntityGraph(attributePaths = {"customer"}) @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%") List findByOrderNumberPrefix(@Param("prefix") String prefix); } ``` 名前付きEntityGraphは、複数のリポジトリで再利用するためにエンティティ上に直接定義されます。 ```java // Order.java @Entity @Table(name = "orders") @NamedEntityGraph( name = "Order.withCustomer", attributeNodes = @NamedAttributeNode("customer") ) @NamedEntityGraph( name = "Order.withCustomerAndItems", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode("items") } ) @NamedEntityGraph( name = "Order.full", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode(value = "items", subgraph = "items-product") }, subgraphs = @NamedSubgraph( name = "items-product", attributeNodes = @NamedAttributeNode("product") ) ) public class Order { // Fields unchanged } ``` サブグラフによりネストされた関連の読み込みが可能になります。上記の例では、注文、その項目、各項目の製品を1つのクエリで読み込みます。 ## 解決策3:コレクションのためのBatch Fetching Batch fetchingは、`@OneToMany`コレクションに対してfetch joinの代替を提供します。各コレクションを個別に読み込む代わりに、Hibernateはクエリをバッチにグループ化します。 ```java // Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String orderNumber; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // Batch fetching on the collection @OneToMany(mappedBy = "order") @BatchSize(size = 25) private List items = new ArrayList<>(); } ``` `@BatchSize(size = 25)`を使用すると、Hibernateは1つではなく25件の注文の項目を一度に読み込みます。100件の注文の場合、クエリ数は101から5に減少します。 ```sql -- Without batch fetching: 100 queries SELECT * FROM order_items WHERE order_id = 1 SELECT * FROM order_items WHERE order_id = 2 -- ... 98 more queries -- With @BatchSize(size = 25): 4 queries SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25) SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50) SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75) SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100) ``` バッチサイズのグローバル設定は、アプリケーション内のすべてのコレクションに適用されます。 ```yaml # application.yml spring: jpa: properties: hibernate: # Global default batch size default_batch_fetch_size: 25 ``` > **BatchとFetch Joinの比較** > > Batch fetchingは、fetch joinが大きすぎるデカルト積を生成する場合に適しています。10件の項目と5件の支払いがある注文の場合、fetch joinは50行を返します。Batch fetchingは、より効率的な2つの別々のクエリを実行します。 ## 読み込み戦略の比較 各戦略は、使用コンテキストに応じて利点を提供します。次の表は、最適な使用ケースをまとめています。 | 戦略 | 使用ケース | 利点 | 欠点 | |----------|----------|------------|---------------| | **Fetch Join** | `@ManyToOne`関係 | 単一のSQLクエリ | コレクションでデカルト積 | | **@EntityGraph** | 宣言的な読み込み | 再利用可能、可読性が高い | JPQLより柔軟性が低い | | **Batch Fetching** | `@OneToMany`コレクション | デカルト積を回避 | 複数のクエリ | | **Subselect** | あまりアクセスされないコレクション | 必要な時のみ読み込み | 相関サブクエリ | Subselect戦略は、任意の要素への最初のアクセスでコレクション全体を読み込みます。 ```java // Order.java @OneToMany(mappedBy = "order") @Fetch(FetchMode.SUBSELECT) private List items = new ArrayList<>(); ``` ```sql -- Generated subselect query SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?) ``` ## DTOプロジェクションによるN+1の回避 DTOプロジェクションは、根本的だが効果的なアプローチを提供します。必要な列のみを選択することで、プロジェクションはエンティティとその関連の読み込みを完全に回避します。 ```java // OrderSummaryDto.java public record OrderSummaryDto( Long orderId, String orderNumber, String customerName, String customerEmail ) {} ``` ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // DTO projection with constructor @Query("SELECT new com.example.dto.OrderSummaryDto(" + "o.id, o.orderNumber, c.name, c.email) " + "FROM Order o JOIN o.customer c") List findAllOrderSummaries(); // DTO projection with condition @Query("SELECT new com.example.dto.OrderSummaryDto(" + "o.id, o.orderNumber, c.name, c.email) " + "FROM Order o JOIN o.customer c " + "WHERE o.createdAt > :since") List findRecentOrderSummaries( @Param("since") LocalDateTime since ); } ``` このアプローチは、エンティティマッピングのオーバーヘッドなしに最適なSQLクエリを生成します。 ```sql SELECT o.id, o.order_number, c.name, c.email FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.created_at > ? ``` ## Spring Data JPAでの高度な設定 Spring Data JPA 3.xは、EntityGraph管理と最適化されたクエリのための改善を導入しています。 ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Dynamic EntityGraph with Specification @EntityGraph(attributePaths = {"customer"}) List findAll(Specification spec); // Pagination with EntityGraph @EntityGraph(attributePaths = {"customer"}) Page findByCustomerNameContaining( String name, Pageable pageable ); // Slice for efficient pagination @EntityGraph(attributePaths = {"customer"}) Slice findByCreatedAtBefore( LocalDateTime date, Pageable pageable ); } ``` 条件付き読み込みにより、コンテキストに応じて異なる戦略を適用できます。 ```java // OrderService.java @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final EntityManager entityManager; public List getOrdersWithGraph(String graphName) { // Dynamic EntityGraph retrieval EntityGraph graph = entityManager .getEntityGraph(graphName); return entityManager .createQuery("SELECT o FROM Order o", Order.class) .setHint("jakarta.persistence.loadgraph", graph) .getResultList(); } public List getOrdersForListing() { // Minimal graph for listing return getOrdersWithGraph("Order.withCustomer"); } public List getOrdersForDetail() { // Full graph for detail view return getOrdersWithGraph("Order.full"); } } ``` ## N+1検出のためのパフォーマンステスト 自動化されたテストは、実行されたSQLクエリの数を数えることでN+1問題がないことを検証します。 ```java // OrderRepositoryPerformanceTest.java @DataJpaTest @AutoConfigureTestDatabase(replace = Replace.NONE) class OrderRepositoryPerformanceTest { @Autowired private OrderRepository orderRepository; @Autowired private EntityManager entityManager; @PersistenceContext private EntityManager em; private Statistics statistics; @BeforeEach void setUp() { // Enable Hibernate statistics Session session = entityManager.unwrap(Session.class); SessionFactory factory = session.getSessionFactory(); statistics = factory.getStatistics(); statistics.setStatisticsEnabled(true); statistics.clear(); } @Test void findAllWithCustomer_shouldExecuteSingleQuery() { // Given: 50 orders in database createTestOrders(50); entityManager.clear(); statistics.clear(); // When: fetch with fetch join List orders = orderRepository.findAllWithCustomer(); // Then: single query executed assertThat(orders).hasSize(50); assertThat(statistics.getQueryExecutionCount()) .as("Should execute only 1 query with fetch join") .isEqualTo(1); } @Test void findAll_withoutOptimization_triggersNPlus1() { // Given: 50 orders in database createTestOrders(50); entityManager.clear(); statistics.clear(); // When: fetch without optimization List orders = orderRepository.findAll(); // Access customers to trigger lazy loading orders.forEach(o -> o.getCustomer().getName()); // Then: N+1 queries (51 instead of 1) assertThat(statistics.getQueryExecutionCount()) .as("Should detect N+1 problem") .isGreaterThan(1); } private void createTestOrders(int count) { for (int i = 0; i < count; i++) { Customer customer = new Customer(); customer.setName("Customer " + i); customer.setEmail("customer" + i + "@test.com"); entityManager.persist(customer); Order order = new Order(); order.setOrderNumber("ORD-" + i); order.setCustomer(customer); order.setCreatedAt(LocalDateTime.now()); entityManager.persist(order); } entityManager.flush(); } } ``` `getQueryExecutionCount()`に対するアサーションは、コードの進化中も最適化が維持されることを保証します。 ## まとめ N+1問題はSpring Data JPAにおける主要なパフォーマンスの課題ですが、いくつかの効果的な解決策があります。Fetch joinと`@EntityGraph`は、関連を1つのクエリで読み込むことによってほとんどのケースを解決します。Batch fetchingは、fetch joinがデカルト積を生成する大きなコレクションのための代替案を提供します。 **N+1予防チェックリスト:** - ✅ 開発環境でSQLログを有効にして複数のクエリを検出する - ✅ 頻繁にアクセスされる`@ManyToOne`関係には`JOIN FETCH`を使用する - ✅ 再利用可能な宣言的読み込みのために`@EntityGraph`を適用する - ✅ 大きな`@OneToMany`コレクションには`@BatchSize`を設定する - ✅ 読み取り専用操作にはDTOプロジェクションを優先する - ✅ 実行されるクエリ数を検証するテストを記述する - ✅ 本番環境ではSQLログと統計を無効化する - ✅ Hibernateメトリクスで重要なエンドポイントを定期的にプロファイリングする --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ja/blog/spring-boot/spring-data-jpa-n-plus-1-fetch-join-entitygraph