Spring Bootの@Transactionalでトランザクション管理を理解する - 伝播レベルと分離レベルの使い分け


注文処理でエラーが起きたのに、在庫だけ減ってしまった経験はありませんか?あるいは、@Transactionalを付けてもロールバックされない、複数のメソッド呼び出しでトランザクション境界が不明確といった問題に遭遇したことはないでしょうか。

トランザクション管理は業務システム開発で必須の知識です。この記事では、Spring Bootの@Transactionalアノテーションの動作原理から、伝播レベルと分離レベルの使い分け、実務でよくある失敗パターンまで、段階的に解説します。

@Transactionalの基本動作とデフォルト設定

@Transactionalアノテーションは、メソッドやクラスに付けることで、Spring Bootが自動的にトランザクション管理を行ってくれる仕組みです。

基本的な動作は以下の通りです。

  • メソッド実行前にトランザクションを開始
  • メソッドが正常終了すればコミット
  • 例外が発生すればロールバック

ただし、デフォルトでは RuntimeException(unchecked例外)でのみロールバック されます。checked例外ではコミットされてしまうので注意が必要です。

また、Spring Bootでは@EnableTransactionManagementの設定は不要です。自動設定で有効になります。

以下は最小限の実装例です。

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        // 処理中にRuntimeExceptionが発生するとロールバック
        if (order.getAmount() < 0) {
            throw new IllegalArgumentException("金額が不正です");
        }
    }
}

重要なポイントとして、@TransactionalSpring AOPのプロキシベース で動作します。そのため、外部からの呼び出しでのみ有効で、同じクラス内のメソッド呼び出し(self-invocation)では効果がありません。この点は後ほど詳しく説明します。

AOPの仕組みについてはこちらの記事で解説しています。

トランザクション伝播レベル(Propagation)の種類と使い分け

トランザクション伝播レベルは、既存のトランザクションが存在する場合にどう振る舞うかを制御します。Spring Bootでは7種類の伝播レベルが用意されています。

REQUIRED(デフォルト)

最も一般的な選択肢です。既存のトランザクションがあれば参加し、なければ新規作成します。

@Transactional(propagation = Propagation.REQUIRED)
public void processOrder(Order order) {
    // 既存トランザクションがあれば参加、なければ新規作成
}

REQUIRES_NEW

常に新しいトランザクションを開始します。外側のトランザクションとは独立してコミット・ロールバックされます。

監査ログや通知処理など、メインの処理が失敗してもログは残したい場合に有用です。

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final NotificationService notificationService;

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        
        // 通知処理は独立したトランザクションで実行
        // 注文処理が失敗しても通知ログは残る
        notificationService.sendNotification(order);
        
        // この後で例外が発生しても、通知ログはコミット済み
        validateOrder(order);
    }
}

@Service
public class NotificationService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(Order order) {
        // 新しいトランザクションで通知ログを保存
        // 注意: この処理自体が失敗した場合、外側のトランザクションには影響しない
        // (例外が伝播すると外側もロールバックされるため、必要に応じてtry-catchで例外を捕捉)
    }
}

NESTED

ネストしたトランザクションを作成します。内側のトランザクションだけをロールバックできます。

この機能を利用するには、データベースがセーブポイントをサポートしている必要があります。主要なRDBMS(PostgreSQL、MySQL/MariaDB、Oracle、H2など)ではサポートされていますが、一部のデータベースでは利用できない場合があります。

SUPPORTS

トランザクションがあれば参加し、なくても実行します。読み取り専用の処理に有用です。

NOT_SUPPORTED

トランザクションを一時停止して実行します。長時間実行処理でデータベース接続を占有したくない場合に使用します。

MANDATORY

既存のトランザクションが必須です。トランザクションがない場合は例外をスローします。

NEVER

トランザクションが存在すると例外をスローします。トランザクション不要な処理であることを明示的に示します。

実務では、REQUIREDREQUIRES_NEW を理解しておけば、ほとんどのケースに対応できます。

トランザクション分離レベル(Isolation)の違いと選択基準

分離レベルは、複数のトランザクションが同時実行される際のデータ整合性を制御します。厳格にするほどデータの一貫性は高まりますが、パフォーマンスは低下します。

READ_UNCOMMITTED

未コミットのデータも読めます(Dirty Read)。実務ではほぼ使用しません。

READ_COMMITTED

コミット済みのデータのみ読めます。多くのデータベースでデフォルトです。同じトランザクション内でも、他のトランザクションのコミットにより読み取り結果が変わる可能性があります(Non-Repeatable Read)。

REPEATABLE_READ

同一トランザクション内で同じクエリを実行すると、同じ結果が保証されます。ただし、範囲検索で他のトランザクションの挿入により結果が変わる現象(Phantom Read)は発生する可能性があります。MySQLのInnoDBでは、Phantom Readも防ぎます。

金額計算など、厳密な整合性が必要な場合に使用します。

@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processPayment(Long orderId, BigDecimal amount) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    
    // この時点でorderの金額を読み取る
    BigDecimal currentAmount = order.getAmount();
    
    // 他の処理...
    
    // 再度読み取っても同じ金額が保証される
    order = orderRepository.findById(orderId).orElseThrow();
    // currentAmountと一致する
}

SERIALIZABLE

最も厳格な分離レベルです。完全な分離を保証しますが、パフォーマンスへの影響が大きくなります。

実務での選択指針

通常は デフォルト(READ_COMMITTED) で十分です。Spring BootではIsolation.DEFAULTを指定すると、使用しているデータソースまたはデータベースのデフォルト分離レベルが使われます。

金額計算や在庫管理など、厳密な整合性が必要な場合のみ、REPEATABLE_READ 以上を検討してください。ただし、分離レベルを上げるとロック競合が増加する点に注意が必要です。

ロールバックが効かない典型的な失敗パターンと対処法

実務でよく遭遇する問題とその解決策を紹介します。

失敗パターン1: checked例外でロールバックされない

デフォルトでは、RuntimeException(unchecked例外)でのみロールバックされます。checked例外ではコミットされてしまいます。

@Transactional
public void processOrder(Order order) throws Exception {
    orderRepository.save(order);
    
    // checked例外をスロー → ロールバックされない!
    if (order.getAmount() < 0) {
        throw new Exception("金額が不正です");
    }
}

対処法: rollbackFor属性で明示的に指定します。

@Transactional(rollbackFor = Exception.class)
public void processOrder(Order order) throws Exception {
    orderRepository.save(order);
    
    // checked例外でもロールバックされる
    if (order.getAmount() < 0) {
        throw new Exception("金額が不正です");
    }
}

全ての例外でロールバックさせたい場合は、rollbackFor = Exception.classを指定するのが確実です。

失敗パターン2: 同じクラス内のメソッド呼び出し(self-invocation)

@Transactionalはプロキシベースで動作するため、同じクラス内からの呼び出しではトランザクションが適用されません。

@Service
public class OrderService {

    public void processOrder(Order order) {
        // 同じクラス内のメソッド呼び出し
        // プロキシを経由しないため、@Transactionalが効かない!
        saveOrder(order);
    }

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
    }
}

対処法: 別のServiceクラスに分割します。

@Service
public class OrderService {

    private final OrderPersistenceService persistenceService;

    public void processOrder(Order order) {
        // 別クラスのメソッド呼び出し → プロキシを経由する
        persistenceService.saveOrder(order);
    }
}

@Service
public class OrderPersistenceService {

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
    }
}

失敗パターン3: privateメソッドに@Transactionalを付ける

プロキシはpublicメソッドにのみ適用されます。privateメソッドに@Transactionalを付けても効果がありません。

トラブルシューティング: ログでトランザクション境界を確認

トランザクションが意図通り動作しているか確認するには、ログ設定が有効です。

logging.level.org.springframework.transaction=DEBUG

この設定により、「Creating new transaction」「Participating in existing transaction」などのログが出力され、トランザクション境界を可視化できます。

例外処理の詳細については、Spring BootのREST APIにおける例外処理の実装方法で解説しています。

readOnly属性によるパフォーマンス最適化

readOnly=trueを設定すると、読み取り専用のヒントをデータベースに伝えることができます。

@Transactional(readOnly = true)
public List<Order> searchOrders(String keyword) {
    return orderRepository.findByKeyword(keyword);
}

readOnly=trueの効果は以下の通りです。

  • Hibernateでは変更検知(dirty checking)をスキップし、FlushModeをMANUALに設定
  • データベース側でも読み取り専用最適化が行われる場合がある
  • メモリ使用量の削減とパフォーマンス向上

検索処理やレポート生成など、更新を伴わない処理には積極的に使用してください。

ただし、readOnly=trueを設定した状態で更新処理を実行すると、データベースやJPA実装によって挙動が異なります。多くの場合、TransactionSystemExceptionが発生しますが、環境によってはエラーにならずに更新が無視される場合もあるため注意が必要です。

JPAを使ったデータ操作については、Spring BootでJPAのEntityのリレーションをマッピングする方法で詳しく解説しています。

トランザクション境界の可視化と動作確認方法

実務でトランザクションが意図通り動作しているか確認する手法を紹介します。

ログ設定による確認

application.propertiesで以下を設定します。

logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.hibernate.SQL=DEBUG

ログから以下のような情報を確認できます。

  • 「Creating new transaction」: 新しいトランザクションの開始
  • 「Participating in existing transaction」: 既存のトランザクションへの参加
  • 「Committing JPA transaction」: コミット
  • 「Rolling back JPA transaction」: ロールバック

テストコードでの確認

Spring Testフレームワークにおいて、テストクラスやテストメソッドに付けた@Transactionalは、アプリケーションコードの@Transactionalとは別の文脈で動作します。テストでは、デフォルトでロールバックされるため、テスト間でデータが干渉しません。

実際にコミットしてデータベースの状態を確認したい場合(結合テストで実際のDB状態を検証する必要がある場合など)は、@Commitまたは@Rollback(false)を使用します。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    @Transactional
    @Commit  // 結合テストで実際のDB状態を確認したい場合に使用
    void testCreateOrder() {
        Order order = new Order();
        order.setAmount(new BigDecimal("1000"));
        
        orderService.createOrder(order);
        
        // データベースに実際に保存される
    }

    @Test
    @Transactional
    void testTransactionPropagation() {
        // REQUIRES_NEWの動作を検証
        // デフォルトでロールバックされるため、テスト間で干渉しない
    }
}

テストについての詳細は、Spring BootのテストをJUnitとMockitoで書く方法をご覧ください。

実務でのトランザクション設計のベストプラクティス

典型的な3層アーキテクチャを例に、保守性とパフォーマンスを両立するトランザクション設計の指針を紹介します。

典型的な3層アーキテクチャでのトランザクション配置

トランザクション境界はServiceレイヤーに配置するのが定石です。以下のように各層で役割を分けることで、保守性の高いアプリケーションを構築できます。

// Controller層: トランザクションなし
@RestController
public class OrderController {

    private final OrderService orderService;

    @PostMapping("/orders")
    public ResponseEntity<Order> createOrder(@RequestBody Order order) {
        Order created = orderService.createOrder(order);
        return ResponseEntity.ok(created);
    }
}

// Service層: トランザクション管理の中心
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    @Transactional
    public Order createOrder(Order order) {
        // ビジネスロジック実行
        // トランザクション境界はこのメソッドの開始から終了まで
        return orderRepository.save(order);
    }
}

// Repository層: トランザクション指定なし(Serviceで管理)
public interface OrderRepository extends JpaRepository<Order, Long> {
}

このアーキテクチャには以下のメリットがあります。

  • Controller層にトランザクションを配置しない: HTTPリクエスト処理全体がトランザクション化されると、処理時間が長くなりやすく、データベース接続を長時間保持してしまいます
  • Service層でビジネスロジックとトランザクションを一元管理: 複数のRepositoryを組み合わせた処理も、Service層で適切にトランザクション境界を制御できます
  • Repository層は個別にトランザクションを持たない: Service層で管理することで、複数のRepository操作を1つのトランザクションにまとめられます

その他の重要な指針

  • トランザクションは短く保つ: トランザクションが長いとロックを長時間保持し、他の処理をブロックします。必要最小限の範囲にとどめてください
  • デフォルト設定で困らない場合は明示的な属性指定を避ける: コードをシンプルに保つため、特別な要件がない限り@Transactionalのみで十分です
  • 複雑なトランザクション制御が必要な場合は設計を見直す: 複数のトランザクション境界が入り組んでいる場合、ビジネスロジックの分割や非同期処理への切り出しを検討してください
// シンプルな場合
@Transactional
public void createOrder(Order order) {
    orderRepository.save(order);
}

// 特別な要件がある場合のみ明示
@Transactional(
    propagation = Propagation.REQUIRES_NEW,
    isolation = Isolation.REPEATABLE_READ,
    rollbackFor = Exception.class
)
public void processPayment(Payment payment) {
    // 金額計算など厳密な処理
}

まとめ

この記事では、Spring Bootの@Transactionalアノテーションを使ったトランザクション管理について解説しました。

重要なポイントは以下の通りです。

  • デフォルトではRuntimeExceptionでのみロールバック。checked例外にはrollbackForが必要
  • 伝播レベルはREQUIREDとREQUIRES_NEWを理解すれば実務で十分
  • 分離レベルは通常デフォルト(READ_COMMITTED)で問題なし。厳密な整合性が必要な場合のみREPEATABLE_READ以上を検討
  • self-invocationではトランザクションが効かない。別クラスに分割して解決
  • 読み取り専用処理にはreadOnly=trueでパフォーマンス最適化
  • トランザクション境界はServiceレイヤーに配置するのが定石

トランザクション管理を正しく理解することで、データ整合性を保ちながら、保守性の高いアプリケーションを構築できます。

よくある質問

@Transactionalはクラスレベルとメソッドレベルのどちらに付けるべきですか?

基本的にはメソッドレベルを推奨します。全メソッドがトランザクション必須の場合のみクラスレベルを検討してください。

checked例外でもロールバックさせる方法は?

@Transactional(rollbackFor = Exception.class)を指定します。詳細はロールバックが効かない典型的な失敗パターンと対処法を参照してください。

同じクラス内でトランザクション付きメソッドを呼んでも効かないのはなぜ?

Spring AOPはプロキシベースで動作するため、同じクラス内からの呼び出し(self-invocation)ではプロキシを経由せず、トランザクションが適用されません。別のServiceクラスに分割することで解決できます。

REQUIREDとREQUIRES_NEWの違いと使い分けは?

REQUIREDは既存のトランザクションに参加し、REQUIRES_NEWは常に新しいトランザクションを開始します。メインの処理が失敗しても独立してコミットしたい場合(監査ログ、通知処理など)にREQUIRES_NEWを使用します。

トランザクション分離レベルはいつ変更すべきですか?

金額計算や在庫管理など、同一トランザクション内で同じデータを複数回読み取る必要があり、厳密な整合性が求められる場合のみ、REPEATABLE_READ以上への変更を検討してください。通常はデフォルトで十分です。

readOnly=trueを設定すると具体的にどんな効果がありますか?

Hibernateでは変更検知をスキップし、FlushModeをMANUALに設定します。これによりメモリ使用量が削減され、パフォーマンスが向上します。データベース側でも読み取り専用最適化が行われる場合があります。

トランザクションが意図通り動作しているか確認する方法は?

application.propertieslogging.level.org.springframework.transaction=DEBUGを設定することで、トランザクションの開始、コミット、ロールバックがログに出力されます。