the KodeLab

Spring Data JPA / Hibernate の "failed to lazily initialize a collection" 解決方法|@Transactional と FetchType

4,378 文字 11 分で読めます
Spring Data JPA / Hibernate の "failed to lazily initialize a collection" 解決方法|@Transactional と FetchType

Spring / Hibernate のエンティティに @OneToMany@ManyToMany を付けておくと、関連レコードをゲッター経由でそのまま読めて魔法のように見えます。ところが、あるときそのゲッターが次の例外を投げます。

org.hibernate.LazyInitializationException: failed to lazily initialize a collection

本記事では、なぜこの例外が発生するのか、LAZY と EAGER の fetch type は実際にどう動くのか、そして推奨される @Transactional による修正方法と、代替案である FetchType.EAGER を解説します。

発生する仕組み

JPA ではエンティティのフィールドに @OneToOne / @OneToMany / @ManyToOne / @ManyToMany を付けることで、そのフィールドが外部キーに対応していることを永続化層に伝えられます。ここまでは楽です。問題は、このリレーションを「タイミングの悪いところで」辿ろうとしたときに起きます。

各マッピングアノテーションにはデフォルトの fetch type が定義されています。

  • FetchType.EAGER — 親エンティティと同じクエリで関連レコードも読み込む
  • FetchType.LAZY — ゲッターを呼んだときに初めて読み込む(プロキシ経由で 2 回目のクエリを発行)

遅延読み込みが成立するのは、Hibernate のセッション(と、それが保持している DB 接続)が開いている間だけです。エンティティの参照を保持したままサービス / リポジトリメソッドの外に出て、そこでゲッターを呼ぶと、セッションはすでに閉じられているため 2 回目のクエリを発行できず、LazyInitializationException が投げられます。

よくあるのは、DAO / リポジトリからエンティティを受け取り、コントローラやテンプレート、あるいは元の Spring Bean の外にあるヘルパーから遅延フィールドにアクセスするパターンです。

アノテーション別のデフォルト fetch type:

アノテーションデフォルト fetch type
@OneToOneFetchType.EAGER
@ManyToOneFetchType.EAGER
@OneToManyFetchType.LAZY
@ManyToManyFetchType.LAZY

つまりこの例外は、ほぼ常にデフォルトが LAZY の @OneToMany / @ManyToMany から発生します。

解決策:@Transactional

遅延コレクションにアクセスするメソッドに @Transactional を付けます。Spring がそのメソッドの実行中ずっと Hibernate セッションを開いたままにしてくれるので、ゲッターから発行される 2 回目のクエリも無事に走ります。読み取り専用の処理なら readOnly = true を付けておくと少しだけパフォーマンスが上がります。

package com.example.blog;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class BlogPostService {
    @Autowired
    private BlogPostRepository postRepository;

    @Transactional(readOnly = true)
    public void doSomething() {
        postRepository.findById(...);
        // ここで遅延コレクションにアクセス可能 — セッションは開いたまま
    }
}

注意点:@Transactional は Spring のプロキシ機構で実装されているため、クラスの外から呼び出されたときだけ 有効になります。上の例で BlogPostService 内の別メソッドから doSomething() を呼んでも、プロキシを経由しないのでトランザクションは開始されません。

別解:FetchType.EAGER

エンティティ側で強制的に即時読み込みにする方法もあります。

package com.example.blog;

import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.OneToMany;
import java.util.List;

@Entity
public class BlogPost {
    @OneToMany(fetch = FetchType.EAGER)
    private List<BlogTag> tags;
}

関連レコードを最初のクエリで一気に読んでしまうので、あとでセッションが閉じても問題になりません。

代わりにパフォーマンスのコストを払います。BlogPost を読むたびに Hibernate は対応する BlogTag をすべて引いてくるため、呼び出し側が実際には使わない場合でも毎回取得されます。データ量が少なく毎回使うなら OK ですが、コメントや閲覧ログのように無制限に増えうるコレクションでは LAZY のままにして、アクセスパターン側を @Transactional で直すのが正解です。

javax.*jakarta.*:名前空間の変更

本記事のコードは原文に合わせて javax.persistencejavax.transaction を使っています。Spring Boot 2.x なら問題ありませんが、Spring Boot 3.x や Jakarta EE 9 以降のランタイムを使っている場合は、すべての import を javax から jakarta に置き換える必要があります。

経緯は短くまとめるとこうです。

  • Java EE → Jakarta EE。2017 年に Oracle が Java EE を Eclipse Foundation に移管。ライセンスの都合で javax.* という名前空間を引き継げませんでした。
  • Jakarta EE 9(2020 年 9 月リリース)でパッケージ名が機械的にリネーム。すべての javax.*jakarta.* に置換されました。API 自体に変更はありません。
  • Spring Boot 2.xjavax.* のまま(Jakarta EE 8 / Java EE 8 互換、Java 8 ベースライン)。
  • Spring Boot 3.x(2022 年 11 月リリース)で jakarta.* に移行し、Java 17 と Jakarta EE 9+ がベースラインになりました。

実務上の境界線は基本的に Spring Boot 2 と 3 の差 と考えて問題ありません。

プロジェクトのベースライン永続化 / トランザクションの import
Spring Boot 2.x、旧 Spring / Jakarta EE 8javax.persistence.*, javax.transaction.*
Spring Boot 3.x、Jakarta EE 9+jakarta.persistence.*, jakarta.transaction.*

どちらの名前空間でもアノテーション名、パラメータ、挙動はすべて同一です。マイグレーションの多くは javaxjakarta の単純な置換で済みます。

補足:Spring の @Transactional と JPA の違い

最近の Spring は以下のどちらのアノテーションでもトランザクションを開始してくれます(Spring Boot 3.x なら jakarta.transaction に置き換え)。

  • javax.transaction.Transactional
  • org.springframework.transaction.annotation.Transactional

ただし Spring のほうには JPA 版にない属性がいくつかあります。上記の修正例で使った readOnly = true は Spring 版にしか存在しません。rollbackFor / noRollbackFor も、Spring 版はクラス名を文字列で渡せますが、JPA 版は Class<?> リテラルしか受け付けません。

Spring の @Transactional だけにある属性:

  • boolean readOnly
  • int timeout
  • String timeoutString
  • String[] rollbackForClassName
  • String[] noRollbackForClassName

迷ったら、Spring ベースのコードベースでは org.springframework.transaction.annotation.Transactional を使っておくのが無難です。

参考リンク