はじめに (対象読者・この記事でわかること)

この記事は、Java EE / Jakarta EE で Web アプリケーションを開発しているが「@RequestScoped がトランザクションを管理しているように見える」「@Transactional と何が違うのか分からない」という中級者の方を対象にしています。
読み進めることで以下のことが分かります。

  • @RequestScoped がトランザクション境界を引いているように見える根本理由
  • CDI のコンテキストと JTA トランザクションがどのように絡むか
  • コードレベルで「スコープ」と「トランザクション境界」を可視化する方法

筆者も「リクエストスコープ=トランザクション境界」だと 3 年間思い込んでおり、本番障害の調査で「実は違う」ことに気づいたのをきっかけに本稿を執筆しました。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • Java 8 以降の言語仕様
  • Servlet / JAX-RS でのリクエスト処理の流れ
  • JPA の EntityManager とトランザションの基本
  • CDI アノテーション(@RequestScoped, @ApplicationScoped など)の役割

@RequestScoped は「トランザクション」を管理しないのに、なぜ「見える」のか

@RequestScopedCDI が管理するビーンの寿命を HTTP リクエスト単位に縛る だけの仕組みです。
にもかかわらず、以下のようなコードを書いたときに「@RequestScoped がトランザクションを張ってくれている」ように見えることがあります。

Java
@RequestScoped public class ItemService { @PersistenceContext EntityManager em; public void create(Item item) { em.persist(item); // <- ここで例外が出ない! } }

これは JTA トランザクションがすでに開始されている からであり、@RequestScoped の責務ではありません。
Jakarta EE サーバー(WildFly / Payara / GlassFish など)は、次の 2 つの仕組みをデフォルトで有効にしているため、見えにくくなっています。

  1. JTA トランザクションの自動開始(EJB コンテナ or JAX-RS フィルタ)
  2. EntityManager のトランザクション参加em.joinTransaction()

つまり「@RequestScoped がトランザクションを貼っている」のではなく、「サーバーがリクエストの入り口でトランザクションを貼ってくれている」というだけです。

コードで検証する:スコープとトランザクション境界を可視化する

ここからは実際のコードで「スコープ」と「トランザクション境界」が別物であることを検証します。
使用する環境は以下です。

  • Java 21
  • Jakarta EE 10
  • Payara Micro 6.2024.1
  • EclipseLink 4.0(JPA 実装)

ステップ1:@RequestScoped ビーンを用意してログを取る

まず、リクエストスコープのビーンにトランザクション状態を出力してみます。

Java
@RequestScoped public class TxLogger { @Inject UserTransaction tx; // JTA トランザクション @PostConstruct void onCreate() { log("ビーン作成 TX.status=" + getStatus(tx)); } @PreDestroy void onDestroy() { log("ビーン破棄 TX.status=" + getStatus(tx)); } private String getStatus(UserTransaction tx) { try { return String.valueOf(tx.getStatus()); } catch (Exception e) { return "ERROR"; } } }

このビーンを JAX-RS リソースから呼び出してログを確認すると、ビーンのライフサイクル(@PostConstruct / @PreDestroy)とトランザクションの開始・コミットタイミングは一致しません
これが「スコープ ≠ トランザクション境界」の第一証拠です。

ステップ2:トランザクション境界を自分で引いてみる

次に、トランザクション境界を明示的に引くフィルタを自作し、ログを比較してみましょう。

Java
@Provider @Priority(Priorities.USER + 100) public class TxBoundaryFilter implements ContainerRequestFilter, ContainerResponseFilter { @Inject UserTransaction tx; @Override public void filter(ContainerRequestContext req) { try { tx.begin(); log("TX.begin"); } catch (Exception e) { throw new RuntimeException(e); } } @Override public void filter(ContainerRequestContext req, ContainerResponseContext res) { try { if (tx.getStatus() == Status.STATUS_ACTIVE) { tx.commit(); log("TX.commit"); } } catch (Exception e) { try { tx.rollback(); log("TX.rollback"); } catch (Exception ignore) {} } } }

このフィルタを有効にした上で、先ほどの ItemService#create を呼び出すと、ログは以下のようになります。

TX.begin
ビーン作成 TX.status=0   ← トランザクションは生きている
ItemService#create 実行
TX.commit
ビーン破棄 TX.status=6 ← トランザクションは既に close

つまり「@RequestScoped ビーンが生きている間、トランザクションが有効」に見えるのは、フィルタ(または EJB コンテナ)がちょうど同じタイミングでトランザクションを貼っているからにすぎません。

ハマった点:@Transactional を付けたら二重トランザクションになった

上記のフィルタに加えて、JTA 1.2 以降で導入された @Transactional を Service クラスに付けると、以下のような事象が起きました。

Java
@RequestScoped public class ItemService { @Transactional(REQUIRED) // <- ここで新規トランザクションが始まってしまう public void create(Item item) { ... } }

ログ:

TX.begin   <- フィルタ
TX.begin   <- @Transactional による新規トランザクション
...
TX.commit  <- @Transactional
TX.commit  <- フィルタ(ここで例外:既に commit 済み)

これは JTA 実装がスレッドローカルに紐づけるトランザションと、CDI インターセプタによるトランザクションが別物だからです。
結局、どちらか一方に統一する必要があります。

解決策:トランザクション境界は一箇所に集中させる

本番運用では、以下のどちらかに統一することが推奨されます。

  1. JAX-RS フィルタ or サーブレットフィルタで global にトランザクション境界を引く
    → シンプルだが、細かな伝播制御(REQUIRES_NEW など)がしにくい
  2. Service 層で @Transactional を使い、フィルタは使わない
    → トランザクション伝播をメソッド単位で制御できるが、各メソッドで忘れがち

今回は 2. を採用し、フィルタ側でトランザクションを貼らないようにしました。
その結果、ログは以下のようにスッキリし、二重トランザクションも解消されました。

ビーン作成 TX.status=0   <- トランザクションはまだ開始されていない
@Transactional により TX.begin
ItemService#create 実行
@Transactional により TX.commit
ビーン破棄 TX.status=6

まとめ

本記事では、@RequestScoped がトランザクション境界を引いているように見える理由を、サーバーの暗黙の JTA 開始と CDI コンテキストの寿命の違いから解説しました。

  • @RequestScoped はあくまで「ビーンの寿命」を制御するだけ
  • トランザクション境界は JTA / @Transactional で明示的に制御する
  • 二重トランザクションを避けるには、境界を一箇所に集中させる

この記事を通して、「スコープ」と「トランザクション境界」が別物であることを実感していただければ幸いです。
次回は、「@Transactional(REQUIRES_NEW) を使ったネストトランザクションの落とし穴」について掘り下げる予定です。

参考資料

  • Jakarta Transactions 2.0 仕様書
  • Payara Foundation - JTA と CDI の統合ガイド
  • 「Java EE 徹底解説」第 7 章 トランザクション管理(オライリー・ジャパン)