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)という指定例が載っています。
どちらにするかの基準
迷ったときは、次の順に問います。
- 条件がメソッド名で書き切れるか。書き切れるなら
@EntityGraphが最小の変更になる whereや並び替えを自分で書く必要があるか。そうなら@Queryのfetch join- 取得したいのが一部の列だけか。そうなら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を、必ず読ませます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。