Spring Transaction Propagation: 内部構造と実務的適用戦略

場のコードレビューにおいて、最も頻繁に遭遇するアンチパターンの一つが@Transactionalアノテーションの無自覚な使用です。多くの開発者はデフォルト設定(REQUIRED)に依存しており、トランザクション境界がどこで開始され、どこで終了するかを正確に把握していないケースが散見されます。これは単なるバグだけでなく、データベースコネクションプールの枯渇や、デッドロックによるシステム停止を引き起こす主要因となります。本稿では、Spring Frameworkにおけるトランザクション伝播(Propagation)のメカニズムを、プロキシパターンと物理/論理トランザクションの観点から解析し、各設定値のトレードオフを明らかにします。

1. アーキテクチャと抽象化のコスト

Springのトランザクション管理は「魔法」ではありません。その本質はAOP(アスペクト指向プログラミング)によるプロキシパターンと、ThreadLocalを用いたコンテキスト管理にあります。@Transactionalが付与されたメソッドを呼び出す際、呼び出し元は実際のBeanではなく、Springが生成したプロキシオブジェクトを経由します。

Architecture Note:
Springにおける「論理トランザクション(Logical Transaction)」と「物理トランザクション(Physical Transaction)」の区別は重要です。複数のメソッドが同一のトランザクションに参加する場合、論理スコープは複数存在しますが、裏側でデータベースと接続している物理コネクションは1つです。

プロキシはTransactionInterceptorを通じてトランザクションの開始・コミット・ロールバックを制御します。ここで重要なのが「伝播(Propagation)」設定です。これは、既存の物理トランザクションが存在する場合に、新しい論理トランザクションスコープをどのように扱うかを定義するルールセットです。適切な設定を選択しない限り、部分的なロールバックの失敗や、意図しないデータの永続化が発生します。

2. 7つの伝播属性とそのトレードオフ

Springが提供する7つの伝播設定は、ユースケースに応じて厳密に使い分ける必要があります。ここでは、それぞれの挙動とエンジニアリング観点での注意点を解説します。

Propagation Type 既存Txがある場合 既存Txがない場合 主なリスク・備考
REQUIRED 参加する 新規作成 デフォルト。部分的なロールバックは不可(全体がロールバックされる)。
REQUIRES_NEW 中断(Suspend)し新規作成 新規作成 DBコネクションを2本消費する。デッドロックのリスク増。
SUPPORTS 参加する Txなしで実行 一貫性が保証されない可能性がある。参照系で使用。
MANDATORY 参加する 例外発生 必ず既存コンテキスト内で実行させる制約。
NOT_SUPPORTED 中断しTxなしで実行 Txなしで実行 Txコンテキストオーバーヘッドの回避。
NEVER 例外発生 Txなしで実行 Tx内での実行を明確に禁止。
NESTED ネスト(Savepoint)作成 新規作成 JDBC Savepoint機能に依存。JPA/Hibernateでは動作しない場合がある。

主要設定の詳細分析

REQUIRED (The Default)

最も一般的ですが、例外ハンドリングにおいて注意が必要です。呼び出された内部メソッドで例外が発生しロールバックマークが付くと、たとえ呼び出し元(Caller)でその例外をcatchしても、論理トランザクション全体が「ロールバック専用(Rollback-Only)」としてマークされているため、コミット時にUnexpectedRollbackExceptionが発生します。

REQUIRES_NEW (Isolation vs Resource)

外部サービスの監査ログ保存や、メイン処理の成否に関わらず実行したい処理に使用します。しかし、以下のアーキテクチャ上のコストを考慮しなければなりません。

  • リソース枯渇: 親トランザクションが中断され、子トランザクションのために新しいDBコネクションが取得されます。高負荷時にコネクションプールが枯渇する直接的な原因となります。
  • ロック競合: 親と子が同じテーブル行を操作する場合、データベースレベルでのデッドロックが発生する可能性があります。

// REQUIRES_NEW の使用例:監査ログ
// 呼び出し元のトランザクションがロールバックされても、ログは残る
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(String action) {
    auditRepository.save(new AuditLog(action));
}

NESTED (Savepoints)

NESTEDは物理トランザクションを一つだけ使用し、JDBCのSAVEPOINT機能を利用して部分的なロールバックを可能にします。REQUIRES_NEWと比較してリソース効率が良いですが、Hibernate等のORMを使用する場合、JPA仕様との不整合により正しく動作しないケースがあるため、DataSourceTransactionManager環境下での利用が推奨されます。

3. 実装上のアンチパターンと解決策

プロキシパターンの特性を理解していない実装は、設定が無効化される原因となります。

Self-Invocation (自己呼び出し) 問題

同一クラス内のメソッド呼び出し(this.method())はプロキシを経由しません。そのため、呼び出されたメソッドに@Transactional(propagation = Propagation.REQUIRES_NEW)が付与されていても、その設定は無視され、既存のトランザクション(あるいはトランザクションなし)で実行されます。

Anti-Pattern:
同一クラス内でのトランザクションメソッド呼び出しは避け、別クラス(Service)に分離するか、自己注入(Self-Injection)を利用する必要があります。

@Service
public class OrderService {

    // 解決策: 自分自身をInjectする(またはLombokの@RequiredArgsConstructor等を利用)
    @Autowired
    private OrderService self;

    @Transactional
    public void createOrder() {
        // NG: プロキシを通らないため、REQUIRES_NEWが機能しない
        // this.generateInvoice(); 

        // OK: プロキシ経由で呼び出されるため、新しいトランザクションが開始される
        self.generateInvoice();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void generateInvoice() {
        // 独立したトランザクション処理
    }
}

例外によるロールバック制御

Springのデフォルトでは、RuntimeException(非チェック例外)およびErrorのみがロールバックのトリガーとなります。Exception(チェック例外)が発生してもコミットされる挙動は、Javaの例外設計思想に基づくものですが、実務上は予期せぬデータ不整合を招くことがあります。

Best Practice:
ビジネスロジックでチェック例外を使用する場合は、明示的にrollbackFor属性を指定してください。
例: @Transactional(rollbackFor = Exception.class)

結論: 適切な戦略の選択

トランザクション伝播の選択は、単なるコードの書き方ではなく、システム全体の整合性とパフォーマンスを決定づけるアーキテクチャ上の意思決定です。

  • 基本はREQUIREDを使用し、論理的な一塊の処理を定義する。
  • REQUIRES_NEWは、監査ログや通知など、メイン処理とライフサイクルが明確に異なる場合にのみ限定的に使用する(コネクションプール設定を見直すこと)。
  • パフォーマンス要件が厳しい読み取り専用操作には、SUPPORTSreadOnly=trueを活用し、ORMのダーティチェック等のオーバーヘッドを回避する。

フレームワークの隠蔽された複雑性を理解し、意図を持って制御することが、堅牢なバックエンドシステム構築への第一歩です。

Post a Comment