Spring JPAで大量データを処理する際、適切なバッチINSERT/UPDATE設定と効率的なデータ取得方法の選択は、アプリケーションのパフォーマンスとメモリ消費に決定的な影響を与えます。本記事では、hibernate.jdbc.batch_sizeによるJDBCバッチ最適化、saveAll()の効率化と落とし穴、@BatchSizeアノテーションによるN+1問題の軽減、IDENTITY生成戦略がバッチ処理を無効化する理由、Stream<T>ScrollableResultsを活用したメモリ効率の良い大量データ読み込み、そしてflush()clear()によるメモリ管理のベストプラクティスを解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Data JPAに含まれる)
Jakarta Persistence 3.2
データベース PostgreSQL 16 / MySQL 8.x / H2 Database
ビルドツール Maven または Gradle
IDE VS Code または IntelliJ IDEA

事前に以下の準備を完了してください。

  • JDK 17以上のインストール
  • Spring Boot + Spring Data JPAプロジェクトの基本構成
  • 主キー生成戦略の基礎知識(主キー生成戦略ガイドを参照)

バッチ処理の仕組みとメリット

JDBCのバッチ処理とは、複数のSQL文を1つのリクエストにまとめてデータベースサーバーへ送信する機能です。これにより、ネットワークラウンドトリップ回数を削減し、大量データ処理のパフォーマンスを大幅に向上させます。

sequenceDiagram
    participant App as アプリケーション
    participant DB as データベース

    Note over App,DB: バッチ処理なし(N回のラウンドトリップ)
    loop 100回
        App->>DB: INSERT INTO products ...
        DB-->>App: OK
    end

    Note over App,DB: バッチ処理あり(1回のラウンドトリップ)
    App->>DB: INSERT INTO products ... (100件バッチ)
    DB-->>App: OK

Hibernateはこのバッチ処理機能を透過的にサポートしており、適切な設定を行うことで自動的にSQL文をまとめて実行します。

hibernate.jdbc.batch_sizeの設定と効果

Hibernateでバッチ処理を有効化するには、hibernate.jdbc.batch_sizeプロパティを設定します。

基本設定

application.ymlまたはapplication.propertiesで以下のように設定します。

1
2
3
4
5
6
7
8
9
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
1
2
3
4
# application.properties
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

設定項目の解説

プロパティ 説明 推奨値
hibernate.jdbc.batch_size バッチにまとめるSQL文の最大数。0または負の値で無効化 10〜50
hibernate.order_inserts エンティティタイプと主キーでINSERTをソートしバッチ効率を向上 true
hibernate.order_updates エンティティタイプと主キーでUPDATEをソートしバッチ効率を向上 true
hibernate.jdbc.batch_versioned_data @Version付きエンティティのバッチ処理を有効化 true(デフォルト)

batch_sizeの適切な値

batch_sizeの最適値はアプリケーションとデータベースに依存しますが、一般的には10〜50が推奨されます。値を大きくしすぎると、メモリ消費量の増加やデータベースサーバー側のログ肥大化を招く可能性があります。

1
2
3
4
5
6
7
# パフォーマンステスト用に一時的に調整する例
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 100

Sessionレベルでのbatch_size指定

Hibernate 5.2以降では、グローバル設定を上書きしてセッション単位でバッチサイズを指定できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@PersistenceContext
private EntityManager entityManager;

public void bulkInsertWithCustomBatchSize(List<Product> products) {
    Session session = entityManager.unwrap(Session.class);
    session.setJdbcBatchSize(100);
    
    for (Product product : products) {
        entityManager.persist(product);
    }
}

saveAll()の最適化と注意点

Spring Data JPAのsaveAll()メソッドは、コレクション内のエンティティを一括保存する便利なメソッドです。しかし、適切に使用しないとパフォーマンス問題を引き起こします。

saveAll()の基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @Transactional
    public void saveProducts(List<Product> products) {
        productRepository.saveAll(products);
    }
}

saveAll()がバッチ処理にならないケース

saveAll()は内部的にループで各エンティティを保存するため、以下の条件を満たさないとバッチ処理の恩恵を受けられません。

  1. hibernate.jdbc.batch_sizeが適切に設定されている
  2. IDENTITY生成戦略を使用していない
  3. トランザクション内で実行されている
  4. 定期的なflush()/clear()でメモリ管理されている

アンチパターン:大量データをそのまま保存

以下は大量データ保存のアンチパターンです。

1
2
3
4
5
6
// アンチパターン:メモリを大量に消費する
@Transactional
public void saveProductsBadExample(List<Product> products) {
    // 100,000件のエンティティがすべて永続化コンテキストに蓄積される
    productRepository.saveAll(products);
}

100,000件のエンティティを一度に保存しようとすると、すべてのエンティティが永続化コンテキスト(第1レベルキャッシュ)にキャッシュされ、OutOfMemoryErrorを引き起こす可能性があります。

推奨パターン:flush/clearによるメモリ管理

大量データを保存する際は、一定間隔でflush()clear()を呼び出してメモリを解放します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Service
@RequiredArgsConstructor
public class ProductBatchService {

    @PersistenceContext
    private EntityManager entityManager;

    private static final int BATCH_SIZE = 50;

    @Transactional
    public void saveProductsOptimized(List<Product> products) {
        for (int i = 0; i < products.size(); i++) {
            entityManager.persist(products.get(i));

            if (i > 0 && i % BATCH_SIZE == 0) {
                // バッチサイズ分のINSERTを実行
                entityManager.flush();
                // 永続化コンテキストをクリアしてメモリ解放
                entityManager.clear();
            }
        }
        // 残りのエンティティを処理
        entityManager.flush();
        entityManager.clear();
    }
}

この実装では、50件ごとにSQLをフラッシュし、永続化コンテキストをクリアすることでメモリ消費を一定に保ちます。

@BatchSizeによる一括フェッチの仕組み

@BatchSizeアノテーションは、遅延ロードされる関連エンティティやコレクションを一括でフェッチするための機能です。これはN+1問題を軽減する効果的な手法の1つです。

N+1問題の復習

N+1問題とは、親エンティティN件を取得した後、各親に紐づく子エンティティを個別に取得するためにN回の追加クエリが発生する現象です。

1
2
3
4
5
6
// N+1問題の例:10件のAuthorに対して11回のSQLが発生
List<Author> authors = authorRepository.findAll();
for (Author author : authors) {
    // 各Authorに対してBooksを取得するSQLが発行される
    System.out.println(author.getBooks().size());
}

@BatchSizeアノテーションの使用

@BatchSizeを付与することで、遅延ロード時に複数のプロキシを一括で初期化できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Entity
@Table(name = "authors")
public class Author {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
    @BatchSize(size = 25)
    private List<Book> books = new ArrayList<>();

    // getters and setters
}

上記の設定により、最初のコレクションアクセス時に最大25件のコレクションが一括でロードされます。

@BatchSizeの動作確認

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Service
@RequiredArgsConstructor
public class AuthorService {

    private final AuthorRepository authorRepository;

    @Transactional(readOnly = true)
    public void printAuthorsWithBooks() {
        List<Author> authors = authorRepository.findAll();
        // 例:100件のAuthorがいる場合
        // @BatchSize(size = 25)なら4回のIN句クエリで済む
        // @BatchSizeなしなら100回のクエリが発生
        for (Author author : authors) {
            log.info("Author: {}, Books: {}", 
                author.getName(), 
                author.getBooks().size());
        }
    }
}

発行されるSQLの例(@BatchSize(size = 25)の場合):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 親エンティティ取得
SELECT a.id, a.name FROM authors a

-- 子コレクション一括取得(最大25件のauthor_idをIN句で指定)
SELECT b.id, b.title, b.author_id 
FROM books b 
WHERE b.author_id IN (1, 2, 3, ..., 25)

SELECT b.id, b.title, b.author_id 
FROM books b 
WHERE b.author_id IN (26, 27, 28, ..., 50)
-- 以降同様

グローバルな@BatchSizeのデフォルト設定

アプリケーション全体でデフォルトのバッチフェッチサイズを設定することも可能です。

1
2
3
4
5
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 25

この設定により、@BatchSizeアノテーションを個別に付与しなくても、すべての遅延ロード関連に対してバッチフェッチが適用されます。

@BatchSizeとJOIN FETCHの使い分け

手法 適用場面 メリット デメリット
@BatchSize アクセスパターンが不定の場合 設定が簡単、柔軟 複数クエリが発行される
JOIN FETCH 常に必要な関連がある場合 1クエリで完結 カルテシアン積のリスク
@EntityGraph 動的に取得関連を変更する場合 宣言的で明確 設定が複雑になりがち

IDENTITY生成戦略のバッチ処理制限

IDENTITY生成戦略(MySQLのAUTO_INCREMENTやSQL ServerのIDENTITY列)を使用している場合、HibernateはバッチINSERTを透過的に無効化します。これはHibernateの重要な制約であり、大量データ処理のパフォーマンスに大きく影響します。

なぜIDENTITYはバッチ処理できないのか

IDENTITY戦略では、主キー値はINSERT文の実行後にデータベースが生成します。Hibernateはエンティティを永続化コンテキストで一意に識別するために主キー値を必要とするため、persist()呼び出し直後にINSERTを実行し、生成されたIDを取得しなければなりません。

sequenceDiagram
    participant App as アプリケーション
    participant Hibernate as Hibernate
    participant DB as データベース

    Note over App,DB: IDENTITY戦略の動作
    App->>Hibernate: persist(entity1)
    Hibernate->>DB: INSERT INTO products ...
    DB-->>Hibernate: Generated ID: 1
    Hibernate-->>App: entity1.id = 1

    App->>Hibernate: persist(entity2)
    Hibernate->>DB: INSERT INTO products ...
    DB-->>Hibernate: Generated ID: 2
    Hibernate-->>App: entity2.id = 2
~~~

この「挿入→ID取得→挿入→ID取得」のサイクルにより、複数のINSERTをバッチ化することが不可能になります。

### IDENTITY戦略のエンティティ例

~~~java
@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private BigDecimal price;

    // getters and setters
}
~~~

上記のエンティティでは、`batch_size`を設定していてもバッチINSERTは行われません。

### 代替戦略:SEQUENCE

バッチ処理が必要な場合は、SEQUENCE戦略を使用します。シーケンスの`allocationSize`により、事前に複数のID値を取得できるため、バッチ処理が可能になります。

~~~java
@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
    @SequenceGenerator(
        name = "product_seq",
        sequenceName = "product_sequence",
        allocationSize = 50
    )
    private Long id;

    private String name;

    private BigDecimal price;

    // getters and setters
}
~~~

`allocationSize = 50`により、Hibernateは1回のシーケンス呼び出しで50個分のID値を確保し、その範囲内ではデータベースへのラウンドトリップなしにIDを割り当てられます。

### データベース別の推奨戦略

| データベース | シーケンスサポート | 推奨戦略 |
|-------------|-------------------|---------|
| PostgreSQL | あり | SEQUENCE |
| Oracle | あり | SEQUENCE |
| MySQL 8.x+ | なし(エミュレーション可) | SEQUENCE(Hibernateがテーブルでエミュレート)またはIDENTITY |
| SQL Server | あり(2012以降) | SEQUENCE |
| H2 Database | あり | SEQUENCE |

MySQLでシーケンスをエミュレーションする場合、Hibernateは内部的に`hibernate_sequences`テーブルを使用してシーケンスを模倣します。ただし、この方法は別トランザクションでの行ロックを必要とするため、TABLE戦略と同様にパフォーマンスが低下する可能性があります。

## Stream&lt;T&gt;によるメモリ効率の良い大量データ取得

大量データを取得する際、`List`で全件メモリに読み込むとメモリ不足を招きます。Java 8以降、Spring Data JPAは`Stream<T>`を返すクエリをサポートしており、これによりカーソルベースの逐次処理が可能です。

### Stream対応リポジトリメソッド

~~~java
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("SELECT p FROM Product p WHERE p.category = :category")
    Stream<Product> findByCategoryAsStream(@Param("category") String category);

    // メソッド名クエリでも使用可能
    Stream<Product> findByPriceGreaterThan(BigDecimal price);
}
~~~

### Streamの使用方法

`Stream`を使用する際は、**必ずtry-with-resourcesでクローズ**する必要があります。`Stream`は内部でJDBCの`ResultSet`をオープンしたままにするため、クローズしないとリソースリークが発生します。

~~~java
@Service
@RequiredArgsConstructor
public class ProductExportService {

    private final ProductRepository productRepository;

    @Transactional(readOnly = true)
    public void exportProducts(String category) {
        try (Stream<Product> productStream = 
                productRepository.findByCategoryAsStream(category)) {
            
            productStream
                .map(this::toExportFormat)
                .forEach(this::writeToFile);
        }
    }

    private String toExportFormat(Product product) {
        return String.format("%d,%s,%.2f", 
            product.getId(), 
            product.getName(), 
            product.getPrice());
    }

    private void writeToFile(String line) {
        // ファイル書き込み処理
    }
}
~~~

### Streamの利点と制約

**利点:**

- 大量データをメモリに一括ロードしない
- Java Stream APIの豊富な中間操作(`filter`、`map`、`skip`、`limit`など)が使用可能
- 遅延評価により、不要なデータ処理をスキップ

**制約:**

- 必ず`@Transactional`スコープ内で使用する
- 必ずtry-with-resourcesでクローズする
- コレクションフェッチ(JOIN FETCH)と組み合わせると期待通りに動作しない場合がある

### Streamとページングの比較

| 手法 | メモリ消費 | 実装複雑度 | 適用場面 |
|------|-----------|-----------|---------|
| `Stream<T>` | 低(逐次処理) | 中 | バッチ処理、エクスポート |
| `Page<T>` / `Slice<T>` | 中(ページ単位) | 低 | ページネーションUI |
| `List<T>` | 高(全件一括) | 低 | 少量データ処理 |

## ScrollableResultsの活用方法

`ScrollableResults`はHibernate固有のAPIで、サーバーサイドカーソルを使用して結果セットをスクロールします。`Stream`よりも低レベルな制御が可能です。

### ScrollableResultsの基本的な使い方

~~~java
@Service
@RequiredArgsConstructor
public class ProductScrollService {

    @PersistenceContext
    private EntityManager entityManager;

    @Transactional(readOnly = true)
    public void processAllProducts() {
        Session session = entityManager.unwrap(Session.class);

        try (ScrollableResults<Product> scrollableResults = session
                .createQuery("SELECT p FROM Product p", Product.class)
                .setCacheMode(CacheMode.IGNORE)
                .scroll(ScrollMode.FORWARD_ONLY)) {

            int count = 0;
            while (scrollableResults.next()) {
                Product product = scrollableResults.get();
                processProduct(product);

                // 定期的にメモリを解放
                if (++count % 50 == 0) {
                    entityManager.flush();
                    entityManager.clear();
                }
            }
        }
    }

    private void processProduct(Product product) {
        // 個別の製品処理ロジック
    }
}
~~~

### ScrollModeの種類

| ScrollMode | 説明 | 対応ドライバ |
|-----------|------|-------------|
| `FORWARD_ONLY` | 前方向のみスクロール可能 | ほぼすべて |
| `SCROLL_SENSITIVE` | 双方向スクロール、変更を反映 | 限定的 |
| `SCROLL_INSENSITIVE` | 双方向スクロール、スナップショット | 多くのドライバ |

多くの場合、`FORWARD_ONLY`で十分であり、パフォーマンスも最も良好です。

### バッチ更新でのScrollableResults活用

読み取りだけでなく、大量データの更新処理にも`ScrollableResults`は有効です。

~~~java
@Transactional
public void updateAllProductPrices(BigDecimal multiplier) {
    Session session = entityManager.unwrap(Session.class);

    try (ScrollableResults<Product> scrollableResults = session
            .createQuery("SELECT p FROM Product p", Product.class)
            .setCacheMode(CacheMode.IGNORE)
            .scroll(ScrollMode.FORWARD_ONLY)) {

        int count = 0;
        while (scrollableResults.next()) {
            Product product = scrollableResults.get();
            BigDecimal newPrice = product.getPrice().multiply(multiplier);
            product.setPrice(newPrice);

            if (++count % 50 == 0) {
                entityManager.flush();
                entityManager.clear();
            }
        }
    }
}
~~~

## flush/clearによるメモリ管理とパフォーマンス向上

永続化コンテキストは「トランザクショナルライトビハインドキャッシュ」として機能し、エンティティの状態変更を蓄積します。大量データ処理では、このキャッシュがメモリを圧迫するため、適切なタイミングで`flush()`と`clear()`を呼び出す必要があります。

### flush()の役割

`flush()`は、永続化コンテキストに蓄積された変更をデータベースに同期します。これにより、INSERT、UPDATE、DELETE文が実際に実行されます。

~~~java
entityManager.flush(); // 蓄積された変更をDBに書き込む
~~~

### clear()の役割

`clear()`は、永続化コンテキストからすべてのエンティティを切り離します(デタッチ状態にする)。これによりメモリが解放されます。

~~~java
entityManager.clear(); // 永続化コンテキストをクリア
~~~

### flush/clearの適切な間隔

`flush()`/`clear()`の呼び出し間隔は`batch_size`と同じか、その倍数に設定することが推奨されます。

~~~java
private static final int BATCH_SIZE = 50;

@Transactional
public void bulkInsert(List<Product> products) {
    for (int i = 0; i < products.size(); i++) {
        entityManager.persist(products.get(i));

        // batch_sizeと同じ間隔でflush/clear
        if ((i + 1) % BATCH_SIZE == 0) {
            entityManager.flush();
            entityManager.clear();
            log.debug("Flushed and cleared at index: {}", i);
        }
    }
    // 残りを処理
    entityManager.flush();
    entityManager.clear();
}
~~~

### evict()による個別エンティティの切り離し

`clear()`がすべてのエンティティを切り離すのに対し、`evict()`(Hibernate Session API)または`detach()`(JPA EntityManager API)は個別のエンティティを切り離します。

~~~java
// JPA標準
entityManager.detach(product);

// Hibernate API
Session session = entityManager.unwrap(Session.class);
session.evict(product);
~~~

特定のエンティティだけをメモリから解放したい場合に使用します。

## StatelessSessionによる軽量バッチ処理

Hibernateは`StatelessSession`という軽量なセッションAPIを提供しています。これは永続化コンテキストを持たず、より低レベルなJDBC操作に近い処理が可能です。

### StatelessSessionの特徴

- 第1レベルキャッシュなし
- 第2レベルキャッシュ、クエリキャッシュとの相互作用なし
- トランザクショナルライトビハインドなし
- カスケード処理なし
- 遅延ロードは明示的な`fetch()`のみ

### StatelessSessionの使用例

~~~java
@Service
@RequiredArgsConstructor
public class ProductStatelessService {

    private final EntityManagerFactory entityManagerFactory;

    public void bulkUpdateWithStatelessSession(BigDecimal multiplier) {
        SessionFactory sessionFactory = 
            entityManagerFactory.unwrap(SessionFactory.class);

        try (StatelessSession statelessSession = 
                sessionFactory.openStatelessSession()) {
            
            Transaction tx = statelessSession.beginTransaction();

            try (ScrollableResults<Product> results = statelessSession
                    .createQuery("SELECT p FROM Product p", Product.class)
                    .scroll(ScrollMode.FORWARD_ONLY)) {

                while (results.next()) {
                    Product product = results.get();
                    BigDecimal newPrice = product.getPrice().multiply(multiplier);
                    product.setPrice(newPrice);
                    statelessSession.update(product);
                }
            }

            tx.commit();
        }
    }
}
~~~

`StatelessSession`では`flush()`/`clear()`が不要であり、各操作が即座にデータベースに反映されます。

### StatelessSessionの注意点

- `persist()`ではなく`insert()`を使用
- `merge()`ではなく`update()`を使用
- コレクションは無視される(明示的な操作が必要)
- Hibernateのイベントモデルやインターセプターをバイパスする

## よくある誤解とアンチパターン

### 誤解1:saveAll()は自動的にバッチ処理される

`saveAll()`は内部的にループで`save()`を呼び出すため、`batch_size`設定がなければバッチ処理されません。また、IDENTITY戦略ではバッチ処理自体が無効です。

~~~java
// 誤解:これだけでバッチ処理されると思い込む
productRepository.saveAll(products); // batch_sizeとSEQUENCE戦略が必要
~~~

### 誤解2:batch_sizeを大きくするほど良い

`batch_size`を極端に大きくすると、以下の問題が発生する可能性があります。

- JVMヒープメモリの圧迫
- データベースサーバー側のログ増大
- ネットワークパケットサイズの上限に抵触

~~~yaml
# アンチパターン:極端に大きなbatch_size
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 10000  # 大きすぎる
~~~

### 誤解3:@BatchSizeはバッチINSERTの設定である

`@BatchSize`はフェッチ戦略に関するアノテーションであり、バッチINSERT/UPDATEとは無関係です。

| アノテーション/設定 | 用途 |
|-------------------|------|
| `hibernate.jdbc.batch_size` | INSERT/UPDATE/DELETEのバッチ化 |
| `@BatchSize` | 遅延ロード時の一括フェッチ |

### アンチパターン:flush/clearなしの大量persist

~~~java
// アンチパターン:メモリリークを引き起こす
@Transactional
public void badBulkInsert(List<Product> products) {
    for (Product product : products) {
        entityManager.persist(product);
        // flush/clearがない!
    }
}
~~~

### アンチパターン:読み取り専用処理でのflush

~~~java
// アンチパターン:読み取り専用なのにflush
@Transactional(readOnly = true)
public void processProducts() {
    try (Stream<Product> stream = productRepository.streamAll()) {
        stream.forEach(product -> {
            process(product);
            entityManager.flush(); // 不要かつ例外の原因になり得る
        });
    }
}
~~~

読み取り専用トランザクションでは`flush()`を呼び出さないでください。

## まとめと実践Tips

Spring JPAで大量データを効率的に処理するためのベストプラクティスをまとめます。

### バッチINSERT/UPDATEのチェックリスト

1. `hibernate.jdbc.batch_size`を10〜50に設定
2. `hibernate.order_inserts`と`hibernate.order_updates`を`true`に設定
3. IDENTITY戦略を避け、SEQUENCE戦略を使用
4. `@SequenceGenerator`の`allocationSize`を`batch_size`以上に設定
5. 一定間隔で`flush()`/`clear()`を実行

### 大量データ読み込みのチェックリスト

1. `Stream<T>`または`ScrollableResults`を使用
2. 必ずtry-with-resourcesでリソースをクローズ
3. `@Transactional(readOnly = true)`を設定
4. 更新が必要な場合は`flush()`/`clear()`でメモリ管理
5. `StatelessSession`の活用を検討

### パフォーマンス検証のポイント

- SQLログを有効化してバッチ化を確認(`show_sql=true`、`format_sql=true`)
- バッチ実行確認用に`generate_statistics=true`を設定
- 本番相当のデータ量でベンチマークを実施
- メモリ使用量(ヒープダンプ)を監視

~~~yaml
# 開発・検証環境でのデバッグ設定
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.stat: DEBUG
~~~

## 参考リンク

- [Hibernate ORM User Guide - Batching](https://docs.hibernate.org/orm/6.6/userguide/html_single/Hibernate_User_Guide.html#batch)
- [Hibernate ORM User Guide - Fetching](https://docs.hibernate.org/orm/6.6/userguide/html_single/Hibernate_User_Guide.html#fetching)
- [Spring Data JPA Reference - Streaming Query Results](https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html)
- [Hibernate Javadoc - StatelessSession](https://docs.hibernate.org/orm/6.6/javadocs/org/hibernate/StatelessSession.html)
- [Hibernate Javadoc - @BatchSize](https://docs.hibernate.org/orm/6.6/javadocs/org/hibernate/annotations/BatchSize.html)
- [Vlad Mihalcea - How to batch INSERT and UPDATE statements with Hibernate](https://vladmihalcea.com/how-to-batch-insert-and-update-statements-with-hibernate/)