3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

N+1問題を「テストで固定化する」

3
Posted at

この記事の立ち位置

前回の記事では「N+1問題はServiceテスト(Mockito)では原理的に検知できず、Repositoryテストの担当領域である」という結論だけを示しました。今回はその先、実際にどう書けばN+1を検知できるテストになるのかを実装レベルで詰めます。

なぜN+1のテストは「値」ではなく「回数」を検証するのか

通常のテストは戻り値やDBの状態を検証します。

assertThat(result.getName()).isEqualTo("商品A");

しかしN+1問題は値が正しいかどうかとは無関係に発生します。取得したデータの中身は正しいのに、そのために発行されたSQLの回数だけが異常、というのがN+1の本質です。つまり検証すべき対象が根本的に違います。

assertThat(queryCount).isEqualTo(1); // 値ではなく「発行されたSQLの回数」を検証する

この違いを意識せずに「取得したリストの中身が正しいからOK」で済ませてしまうと、テストは通っているのに本番で大量のクエリが飛ぶ、という事故を防げません。


実例:意図的に@EntityGraphを使っていないケース

public interface CustomerOrderRepository extends JpaRepository<CustomerOrder, Long> {

    /**
     * 明細(items)はコレクションのため JOIN FETCH と Pageable を併用すると
     * Hibernate がメモリ上でページングしてしまう。
     * それを避けるため、この一覧取得では customer のみ JOIN FETCH し、
     * 明細は別クエリで取得する。
     */
    @Override
    @Query(
            value = "SELECT o FROM CustomerOrder o LEFT JOIN FETCH o.customer",
            countQuery = "SELECT COUNT(o) FROM CustomerOrder o")
    Page<CustomerOrder> findAll(Pageable pageable);

    @Query(
            """
        SELECT DISTINCT o FROM CustomerOrder o
        LEFT JOIN FETCH o.customer
        LEFT JOIN FETCH o.items i
        LEFT JOIN FETCH i.product
        WHERE o.id = :id
        """)
    Optional<CustomerOrder> findByIdWithItems(@Param("id") Long id);
}

このコメントは、N+1対策そのものより一段深い落とし穴に触れています。「コレクション(@OneToMany)をJOIN FETCHしつつPageableを併用すると、Hibernateがページングをメモリ上で行ってしまう」という問題です。

なぜこの落とし穴が起きるのか

SQLの世界では「1対多」の結合をすると行が水増しされます。1件の注文に3件の明細があれば、結合結果は3行になります。この状態でLIMITOFFSETをSQLレベルでかけると、注文単位ではなく行単位でページングされてしまい、「1ページ20件」のつもりが「実質数件の注文しか含まれない」という不正確な結果になります。

これを避けるためHibernateは、コレクションをJOIN FETCHした状態でPageableを渡すと、SQL側のLIMITを諦めて、全件取得してからメモリ上でページングするという挙動を取ります。これは「N+1は起きていないが、全件が毎回メモリに載る」という、N+1とは異なる種類のパフォーマンス問題です。しかもテストが自動的に失敗するわけではないため、気づかないまま本番でメモリを圧迫するという事故につながりやすい類のバグです。

このAPIがfindAllではcustomerだけをJOIN FETCHし、明細(items、1対多のコレクション)はfindByIdWithItemsという別クエリに分離しているのは、この落とし穴を避けるための設計判断です。一覧画面では明細は不要、詳細画面でだけitemsJOIN FETCHする、という要求に応じた分離とも言えます。

Hibernate統計情報でクエリ回数をテストに固定化する

ここからが本題です。「クエリが1回で完結している」ことをテストコードとして残す方法を実装します。

ステップ1:統計情報の有効化

# application-test.yml など、テスト用プロファイル
spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true

本番でこれを有効にするとオーバーヘッドがあるため、テストプロファイル限定で有効化します。

ステップ2:Statisticsオブジェクトを取得してアサーションする

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({TestcontainersConfiguration.class, JpaAuditConfig.class})
class CustomerOrderRepositoryTest {

    @Autowired private TestEntityManager entityManager;
    @Autowired private CustomerOrderRepository customerOrderRepository;

    private Statistics statistics() {
        return entityManager
                .getEntityManager()
                .getEntityManagerFactory()
                .unwrap(SessionFactory.class)
                .getStatistics();
    }

    @Test
    @DisplayName("findAllは顧客をJOIN FETCHし、1クエリで完結すること")
    void findAll_shouldFetchCustomerInSingleQuery() {
        // 3件の注文を、それぞれ異なる顧客で永続化しておく
        persistOrderWithCustomer("顧客A");
        persistOrderWithCustomer("顧客B");
        persistOrderWithCustomer("顧客C");
        entityManager.flush();
        entityManager.clear();

        statistics().clear(); // ここまでのセットアップ用クエリはカウントから除外する

        Page<CustomerOrder> page = customerOrderRepository.findAll(PageRequest.of(0, 10));
        // ループで顧客名にアクセスする(N+1が起きるとしたらここ)
        page.getContent().forEach(order -> order.getCustomer().getName());

        assertThat(statistics().getPrepareStatementCount())
                .as("customerのJOIN FETCHにより、件数によらず一定回数のクエリで完結するはず")
                .isEqualTo(2); // データ本体1回 + countQuery1回
    }
}

なぜstatistics().clear()が重要か

テストデータの準備(persistOrderWithCustomer)自体もSQLを発行します。これをカウントに含めてしまうと「何件セットアップしたか」でアサーションの期待値が変わってしまい、テストが壊れやすくなります。「検証したい操作の直前でクリアする」のが鉄則です。

なぜentityManager.clear()も必要か

Hibernateの第一レベルキャッシュ(永続化コンテキスト)に、直前でpersistしたエンティティがそのまま残っていると、findAll()で取得した際に「キャッシュから返されて実はSQLが発行されていない」ケースが混入し、テストが正しく機能しません。clear()でキャッシュを空にしてから測定することで、確実にDBへ問い合わせが発生する状態を作れます。

件数を可変にしてアサーションする、より頑健な書き方

「2回」という固定値だけでなく、件数を変えても同じ回数のままであることを確認すると、N+1をより確実に検知できます。

@Test
@DisplayName("注文件数が変わってもクエリ回数は一定であること(N+1でないことの証明)")
void findAll_queryCountShouldNotScaleWithOrderCount() {
    persistOrderWithCustomer("顧客A");
    entityManager.flush();
    entityManager.clear();
    statistics().clear();
    customerOrderRepository.findAll(PageRequest.of(0, 10))
            .forEach(o -> o.getCustomer().getName());
    long countWith1Order = statistics().getPrepareStatementCount();

    persistOrderWithCustomer("顧客B");
    persistOrderWithCustomer("顧客C");
    persistOrderWithCustomer("顧客D");
    entityManager.flush();
    entityManager.clear();
    statistics().clear();
    customerOrderRepository.findAll(PageRequest.of(0, 10))
            .forEach(o -> o.getCustomer().getName());
    long countWith4Orders = statistics().getPrepareStatementCount();

    assertThat(countWith4Orders)
        .as("件数が増えてもクエリ回数が比例して増えないこと")
        .isEqualTo(countWith1Order);
}

これはN+1の定義そのもの(「Nに比例してクエリが増える」)を直接テストコードに落とし込んだ形です。「2回」のような具体的な数字よりも、「データ量に対してクエリ回数がスケールしないこと」を検証する方が、実装の細部(countQueryの有無など)が変わってもテストの意図が崩れにくく、頑健です。

@EntityGraphJOIN FETCHの使い分けを、テストの書きやすさからも見る

04-entity-design.mdの規約ではこう整理されていました。

手法 推奨されるケース
@EntityGraph 単純な関連の一括結合、ページネーションを伴う検索
JOIN FETCH(JPQL) 結合条件に動的な絞り込みが必要、DTOへの直接マッピング

ここに、テストの観点からもう一つ判断材料を加えられます。

  • @EntityGraphはメソッドシグネチャに注釈を付けるだけで、生成されるSQLがブラックボックスになりやすい
  • JOIN FETCH(JPQL)は書いたクエリがそのまま実行される文なので、「このJOINがなぜ必要か」をコメントやコード自体で説明しやすい

先ほどの例でfindAllのコメントに「コレクションのJOIN FETCHとPageableの併用を避けた」という設計判断の理由を明記できているのは、JPQLで明示的に書いているからこそです。@EntityGraphだけで済ませていたら、このような「なぜこの実装なのか」という背景はコードから読み取れなくなっていたはずです。パフォーマンス上の理由でクエリの形を細かく制御したい場面では、JOIN FETCHの方がテストとドキュメント性の両面で優位になるケースがある、と言えます。


default_batch_fetch_sizeという「セーフティネット」に頼りすぎない

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

これは「万が一N+1が発生しても、1件ずつではなく100件ずつのIN句クエリにまとめる」という保険です。あくまで「被害を軽減する」保険であり、「N+1を無くす」対策ではありません。100件を超えれば結局N/100 + 1回のクエリが発生します。前述のようなクエリ回数を固定化するテストがあれば、この設定の有無にかかわらず「本当にJOIN FETCHで1回に収まっているか」を機械的に検知できます。保険はあっても、根本対策(JOIN FETCH/@EntityGraph)をテストで担保する習慣は別に必要です。

CIに組み込む際の注意点

1. Testcontainersが必要なテストは実行時間が伸びる

クエリ回数の検証は@DataJpaTest(本物のDB)が前提です。前回の記事で触れた通り、これは低速な層です。CIのステージを分け、「単体テスト(高速)」と「Repositoryテスト(低速・Docker必須)」を別ジョブにする、あるいは並列実行するといった工夫が必要になります。

2. Statisticsはテストメソッド間で状態を共有しない

@DataJpaTestはデフォルトで各テストをトランザクションロールバックしますが、SessionFactoryStatisticsオブジェクト自体はトランザクションと無関係にプロセス全体で共有されるグローバルな状態です。前のテストのクエリカウントが残ったまま次のテストに影響しないよう、必ず検証したい操作の直前でstatistics().clear()を呼ぶことを徹底する必要があります(テストの独立性を壊さないための重要なポイントです)。

3. 閾値ではなく厳密な一致でアサーションする

「3回以下ならOK」のような閾値判定は、N+1が実際に混入していても許容してしまう可能性があります。可能な限り「ちょうどこの回数」という厳密な一致でアサーションし、想定より増えたら即座にテストが赤くなるようにするのが望ましい設計です。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?