2026年Spring Data JPAのN+1問題解決法:Fetch JoinとEntityGraph

Spring Data JPAにおけるN+1問題の検出と修正のための完全ガイド。Fetch join、@EntityGraph、batch fetching、クエリパフォーマンス戦略を解説します。

fetch joinとEntityGraphを使用したSpring Data JPAでのN+1問題の解決

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を使用します。

Order.javajava
@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<OrderItem> items = new ArrayList<>();

    // Getters and setters omitted
}
Customer.javajava
@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は各注文に対して追加のクエリを実行します。

OrderService.java - Problematic codejava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // 1 query: SELECT * FROM orders
        List<Order> 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の統計は、各トランザクションの最後に貴重な要約を提供します。

text
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を解決します。

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Explicit fetch join to load customers
    @Query("SELECT o FROM Order o JOIN FETCH o.customer")
    List<Order> findAllWithCustomer();

    // Multiple fetch join for several associations
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "JOIN FETCH o.items i")
    List<Order> findAllWithCustomerAndItems();

    // Fetch join with WHERE condition
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "WHERE o.createdAt > :since")
    List<Order> 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

サービスは、ビジネスコードを変更せずに最適化されたメソッドを使用するようになります。

OrderService.java - Optimized codejava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // Single query with join
        List<Order> orders = orderRepository.findAllWithCustomer();

        // No additional queries
        return orders.stream()
            .map(order -> new OrderDto(
                order.getId(),
                order.getOrderNumber(),
                order.getCustomer().getName() // Already loaded
            ))
            .toList();
    }
}

Spring Bootの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

解決策2:宣言的制御のための@EntityGraph

@EntityGraphアノテーションは、fetch joinに代わる宣言的な選択肢を提供します。カスタムJPQLを書かずに、どの関連を読み込むかを定義します。

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Inline EntityGraph with attributePaths
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll();

    // EntityGraph with multiple attributes
    @EntityGraph(attributePaths = {"customer", "items"})
    List<Order> findByCreatedAtAfter(LocalDateTime since);

    // Named EntityGraph referencing entity definition
    @EntityGraph(value = "Order.withCustomerAndItems")
    List<Order> findByCustomerId(Long customerId);

    // Combination with custom query
    @EntityGraph(attributePaths = {"customer"})
    @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%")
    List<Order> findByOrderNumberPrefix(@Param("prefix") String prefix);
}

名前付きEntityGraphは、複数のリポジトリで再利用するためにエンティティ上に直接定義されます。

Order.javajava
@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はクエリをバッチにグループ化します。

Order.javajava
@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<OrderItem> 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戦略は、任意の要素への最初のアクセスでコレクション全体を読み込みます。

Order.javajava
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> 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プロジェクションは、根本的だが効果的なアプローチを提供します。必要な列のみを選択することで、プロジェクションはエンティティとその関連の読み込みを完全に回避します。

OrderSummaryDto.javajava
public record OrderSummaryDto(
    Long orderId,
    String orderNumber,
    String customerName,
    String customerEmail
) {}
OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // 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<OrderSummaryDto> 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<OrderSummaryDto> 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管理と最適化されたクエリのための改善を導入しています。

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Dynamic EntityGraph with Specification
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll(Specification<Order> spec);

    // Pagination with EntityGraph
    @EntityGraph(attributePaths = {"customer"})
    Page<Order> findByCustomerNameContaining(
        String name,
        Pageable pageable
    );

    // Slice for efficient pagination
    @EntityGraph(attributePaths = {"customer"})
    Slice<Order> findByCreatedAtBefore(
        LocalDateTime date,
        Pageable pageable
    );
}

条件付き読み込みにより、コンテキストに応じて異なる戦略を適用できます。

OrderService.javajava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final EntityManager entityManager;

    public List<Order> 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<Order> getOrdersForListing() {
        // Minimal graph for listing
        return getOrdersWithGraph("Order.withCustomer");
    }

    public List<Order> getOrdersForDetail() {
        // Full graph for detail view
        return getOrdersWithGraph("Order.full");
    }
}

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

N+1検出のためのパフォーマンステスト

自動化されたテストは、実行されたSQLクエリの数を数えることでN+1問題がないことを検証します。

OrderRepositoryPerformanceTest.javajava
@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<Order> 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<Order> 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メトリクスで重要なエンドポイントを定期的にプロファイリングする

タグ

#spring data jpa
#n+1 problem
#fetch join
#entitygraph
#performance

共有

関連記事