Claude Media
Claude CodeでSpring JPAのLazyInitializationExceptionを直す

Claude CodeでSpring JPAのLazyInitializationExceptionを直す

LazyInitializationExceptionとN+1を、@Transactionalを広げずfetch joinと@EntityGraphで直す手順です。スタックトレースとSQL発行数を渡す依頼文と、選び分けの基準を示します。

LazyInitializationExceptionは、Claude Codeに「直して」と頼むと、@Transactionalをサービス全体に付ける方向へ流れやすい例外です。例外は消えますが、トランザクションが長くなり、遅延読み込みのSELECTが1件ずつ増える問題(N+1)は残ります。

直し方を決めるのは、スタックトレースと発行されたSQLの件数です。この2つを渡し、「必要な関連を、クエリの時点で読み込む」方針を指定すると、fetch joinか@EntityGraphのどちらかに着地します。この記事では、依頼文、2つの使い分け、直ったかを確かめる手順を順に示します。

LazyInitializationExceptionは何が起きた合図か

LazyInitializationExceptionは、遅延読み込み(LAZY)の関連を、読み込み前に触ったときに投げられる例外です。Hibernateのユーザーガイドは、LAZYな関連は触る前に初期化しておく必要があり、そうしないとこの例外が出ると説明しています。

同じガイドは、対処法に良し悪しがあるとも書いています。最善とされるのは、永続コンテキストを閉じる前に、必要な関連をすべて取得しておく方法です。

つまり、例外の原因は「トランザクションが短い」ことではありません。「必要な関連を、必要な場所で読み込んでいない」ことです。@Transactionalの範囲を広げると例外は消えますが、読み込みを遅延のまま後追いするので、関連ごとにSELECTが走る状態は変わりません。

Spring Bootで例外が出る場所

Spring Bootのspring.jpa.open-in-viewは、既定値がtrueです。公式の説明では、リクエスト処理の全体にわたってEntityManagerをスレッドへ結び付けます。

この設定が有効なら、コントローラーやビューの描画中でも遅延読み込みが通ります。例外が出やすいのは次のような場面です。

  • open-in-viewをfalseにしているアプリ
  • @Asyncやバッチなど、Webリクエストの外で動く処理
  • サービスの戻り値を、トランザクション終了後に別の層が辿るコード

open-in-viewが効くのはWebリクエストの中だけです。テストからサービスを直接呼ぶと、この設定は効かず、例外が出ます。再現するコマンドを、最初に特定しておきます。

Claude Codeに渡す3点

Claude Code公式のワークフロー集は、バグ修正の依頼で、再現コマンドとスタックトレースを渡し、再現手順を伝えるよう勧めています。Spring Data JPAでは、これに「発行されたSQLの件数」を足します。

渡すもの何のためか
再現コマンドとスタックトレース何のためか例外が出る行と、辿った関連名を特定する
発行されたSQLのログ何のためか同じ形のSELECTが何回繰り返されるかを数える
直さない範囲の指定何のためか@Transactionalの拡大やFetchType.EAGERへの変更を禁じる

SQLログは、application.propertiesのspring.jpa.show-sql=trueで出せます(既定はfalseです)。テスト実行の出力に混ぜて、件数を数えます。

./mvnw -q test -Dtest=OrderServiceTest 2>&1 | tee /tmp/order-test.log
grep -c -i "from orders" /tmp/order-test.log

依頼文の例です。

OrderServiceTest#listSummaries が LazyInitializationException で落ちます。
再現: ./mvnw -q test -Dtest=OrderServiceTest(出力は /tmp/order-test.log)
スタックトレースの最初の自分のコード行は OrderSummaryMapper の item.getName() です。
 
条件:
- @Transactional の範囲は広げない
- 関連を FetchType.EAGER に変えない
- 必要な関連はリポジトリのクエリ側で読み込む
- 直した後、同じテストを再実行し、orders と order_items に
  発行される SELECT の件数を直す前と比べて報告して

「広げない」「EAGERにしない」を先に書くのが要点です。禁止がなければ、最短で例外を消す修正が選ばれます。

直し方は4つから選ぶ

Claude Codeが出してくる修正案は、次の4つに整理できます。どれを選ぶかを、依頼文の側で絞ります。

方法向く場面注意点
fetch join(@Query)向く場面1回のクエリで必要な関連を決め切れる注意点コレクションの複数同時取得、ページング
@EntityGraph向く場面派生クエリ名のまま、取得する関連だけ足したい注意点同じ取得計画を複数メソッドで使い回す
DTO射影向く場面画面に必要な列が少ない注意点エンティティの更新には使えない
@BatchSize向く場面関連を辿る形が複雑で、1回に畳めない注意点SELECTは減るが、1回にはならない

Hibernateのガイドの評価は明確です。@BatchSizeはN+1よりましですが、多くの場合、DTO射影かJOIN FETCHのほうが、必要なデータを1回のクエリで取れるぶん優れているとしています。

fetch joinで直す

@ManyToOne、@OneToOne、そして高々1つのコレクションには、JOIN FETCHが向いている、というのがHibernateのガイドの説明です。リポジトリに次のようなクエリを足します。エンティティ名は例です。

public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @Query("select o from Order o join fetch o.items where o.customerId = :customerId")
    List<Order> findWithItemsByCustomerId(@Param("customerId") Long customerId);
}

Hibernate 6では、join fetchで生じる重複行が、読み込み後にメモリー上で自動的に取り除かれます。ガイドは、この目的でdistinctを使うべきではないと書いています。Claude Codeが古い書き方を持ち込んだら、その点を指摘します。

@EntityGraphで直す

Spring Data JPAの公式ドキュメントでは、@EntityGraphをリポジトリのクエリメソッドに付けると、そのクエリの取得計画を設定できます。attributePathsで、@NamedEntityGraphを定義せずに関連名を直接指定できます。

public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @EntityGraph(attributePaths = { "items" })
    List<Order> findByCustomerId(Long customerId);
}

JPQLを書かず、メソッド名はそのまま。差分が1行に収まり、レビューしやすい形です。取得の種類はtype属性で選べ、EntityGraphType.FETCHとEntityGraphType.LOADの2つがあります。Spring Data JPAのドキュメントには、@EntityGraph(value = "GroupInfo.detail", type = EntityGraphType.LOAD)という指定例が載っています。

どちらにするかの基準

迷ったときは、次の順に問います。

  1. 条件がメソッド名で書き切れるか。書き切れるなら@EntityGraphが最小の変更になる
  2. whereや並び替えを自分で書く必要があるか。そうなら@Queryのfetch join
  3. 取得したいのが一部の列だけか。そうならDTO射影を検討する

この順序は、公式の規則ではなく、変更の小ささで並べた目安です。

コレクションを2つ以上取りたいとき

Hibernateのガイドは、複数のコレクションをfetch joinで一度に取ると、直積(Cartesian product)になると警告しています。2つ以上のコレクションが必要なときは、関連を辿る時点で発行される二次クエリ、またはHibernate#initialize(Object proxy)で初期化する方法を挙げています。

2つ目のコレクションを初期化する場合は、トランザクションの中でHibernate.initializeを呼びます。次の断片は使い方の例です。

@Transactional(readOnly = true)
public Order loadWithItemsAndPayments(Long id) {
    Order order = orderRepository.findWithItemsById(id); // itemsはfetch joinで取得
    Hibernate.initialize(order.getPayments());           // paymentsは別のSELECTで初期化
    return order;
}

1つ目のコレクションはfetch joinの1クエリ、2つ目は追加のSELECT1回で取れるので、直積にはなりません。永続コンテキストが閉じる前に呼ぶ必要があるため、トランザクションの外では使えません。

Claude Codeがやりがちなのは、1つのクエリにjoin fetch o.items join fetch o.paymentsと並べる修正です。例外は消えても、行数が掛け算で増えます。依頼文に「コレクションのfetch joinは1クエリにつき1つまで」と書いておくと、その形を避けられます。

ページングと組み合わせない

fetch joinとページングの組み合わせにも、ガイドが警告を出しています。コレクションをfetch joinしたクエリにlimitを付けても、発行されるSQLにはlimit句が入りません。Hibernateは条件に合う行をすべて取り出し、メモリー上で件数を絞ります。

Pageableを受け取るメソッドを直すときは、直した後のSQLを必ず読ませます。@EntityGraphを同じ状況で使ったときの挙動は、Spring Data JPAの該当節に記載がありません。実際に発行されるSQLで、limitが付いているかを確かめます。

直ったかをテストで確かめる

例外が消えただけでは、直ったとは言えません。確認する項目は2つです。

  • 同じテストが緑になる
  • 発行されるSELECTの件数が減っている

Hibernateはhibernate.generate_statisticsをtrueにすると統計の収集を始めます。既定では収集しません。テスト専用の設定としてspring.jpa.properties.hibernate.generate_statistics=trueを足し、件数を検証に使う方法もあります。クエリ数をテストで固定する考え方は、Claude CodeでN+1クエリを見つけて直すで、RailsとSQLAlchemyを例に説明しています。

リポジトリの読み書きを実際のDBで確かめる@DataJpaTestの使い方は、JUnit 5とMockitoのテストを書く記事にあります。テストの種類をどう選ぶかは、そちらを参照してください。

修正前後のSQL件数の読み方

件数を数えるときは、同じ形のSELECTが繰り返されているかを見ます。注文10件に明細が付くアプリを例にすると、遅延読み込みのままでは次の形になります(件数は例です)。

select ... from orders where customer_id=?
select ... from order_items where order_id=?
select ... from order_items where order_id=?
(order_idだけ変わるSELECTが、注文の件数ぶん続く)

最初の1回で注文を取り、残りは注文ごとに明細を取りに行く形です。これがN+1の正体で、注文が10件なら11回、100件なら101回になります。fetch joinか@EntityGraphに直すと、同じ処理が1回のSELECTに収まります。

報告させる数字は「直す前11回、直した後1回」のように、前後を並べた形にします。件数が変わっていなければ、修正は読み込みの場所に効いていません。

@BatchSizeを選ぶのはいつか

fetch joinにも@EntityGraphにも載せにくい場面があります。たとえば、関連を辿る経路が複数あり、取得計画をクエリ側へ集約できないときです。

その場合、Hibernateの@BatchSizeは、未初期化のプロキシを1件ずつでなくまとめて読み込みます。ガイドの例では、10件の親に対して必要だった子の取得が、2回のSQLに減っています。hibernate.default_batch_fetch_sizeは、バッチ取得の既定値を決める設定です。既定では、@BatchSizeを付けた対象にしかバッチ取得を使いません。

ただし、ロックモードがNONE以外のときは、バッチ取得が実行されません。悲観ロックを使うクエリでは、効果が出ない前提で考えます。

CLAUDE.mdに書いておく方針

同じ修正を毎回頼むなら、プロジェクトの方針としてCLAUDE.mdに書いておきます。

## JPAの読み込み方針
- LazyInitializationExceptionは @Transactional の拡大で直さない
- 関連のFetchTypeをEAGERに変えない
- 必要な関連はリポジトリのクエリ側(fetch join または @EntityGraph)で読み込む
- コレクションのfetch joinは1クエリにつき1つまで
- ページングを伴うクエリを直したら、発行SQLに limit が付いているか確認する
- 修正後は対象テストを再実行し、SELECT件数の前後を報告する

EAGERを避ける理由は、Hibernateのガイドが書いています。EAGERは「ほぼ常に悪い選択」で、クエリごとに上書きできず、JOIN FETCHを忘れると二次SELECTが発行され、N+1の原因になるというものです。方針の背景として、そのまま使えます。

全体のSpring Boot開発フロー

アプリの雛形作成から権限設定までの流れは、Claude CodeでSpring Bootアプリを作るにまとめています。この記事の方針は、そこで作るCLAUDE.mdの「規約」の節に足せます。

よくあるつまずき

例外は消えたが、SELECTは減っていない

@Transactionalを広げた修正でよく起きます。例外だけを見て「直った」と報告されるので、依頼文でSELECT件数の前後比較を必須にします。

Hibernateのバージョンで挙動が違う

重複行の自動除去は、Hibernate 6の挙動です。古いバージョンのアプリでは、distinctが必要な場合があります。プロジェクトのバージョンを、依頼文に添えます。

取得する関連が増えてクエリが重くなった

fetch joinや@EntityGraphは、そのメソッドを呼ぶすべての場所で取得計画を変えます。一覧用と詳細用で必要な関連が違うなら、メソッドを分けます。

まとめ

LazyInitializationExceptionは、トランザクションの長さではなく、読み込みの場所の問題です。Claude Codeには、スタックトレースとSQL件数を渡し、@Transactionalの拡大とEAGER化を禁じたうえで、クエリ側での取得を指定します。メソッド名で済むなら@EntityGraph、条件を自分で書くならfetch joinが、変更の小さい出発点です。

コレクションの同時取得とページングの2つは、例外が消えたあとにも性能を壊せる落とし穴です。直した後のSQLを、必ず読ませます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/common-workflows
  2. docs.spring.io/spring-framework/reference/data-access/transaction.html
  3. docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
  4. docs.jboss.org/hibernate/orm/6.6/userguide/html_single/Hibernate_User_Gui…
この記事を共有:XはてブLinkedIn