注文処理でエラーが起きたのに、在庫だけ減ってしまった経験はありませんか?あるいは、@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)の種類と使い分け

トランザクション伝播レベルは、既存のトランザクションが存在する場合にどう振る舞うかを制御します。7種類ありますが、実務では REQUIREDREQUIRES_NEW を理解しておけばほとんどのケースに対応できます。

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で例外を捕捉する
    }
}

なお、「本体処理のコミット後に実行したい」処理には、REQUIRES_NEWの代わりに@TransactionalEventListenerでイベント駆動にする設計も有効です。詳しくはSpring BootのApplicationEventでモジュール間を疎結合にする方法を参照してください。

その他の伝播レベル

  • NESTED: セーブポイントを使ったネストトランザクションを作り、内側だけをロールバックできます。データベース側のセーブポイント対応が必要です(PostgreSQL、MySQL/MariaDB、Oracle、H2などは対応)。
  • SUPPORTS: トランザクションがあれば参加し、なくても実行します。
  • NOT_SUPPORTED: トランザクションを一時停止して実行します。長時間処理でコネクションを占有したくない場合に使います。
  • MANDATORY: 既存トランザクションが必須で、なければ例外をスローします。
  • NEVER: トランザクションが存在すると例外をスローします。

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

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

  • READ_UNCOMMITTED: 未コミットのデータも読めます(Dirty Read)。実務ではほぼ使いません。
  • READ_COMMITTED: コミット済みのデータのみ読めます。多くのデータベースでデフォルトですが、同じトランザクション内でも読み取り結果が変わる可能性があります(Non-Repeatable Read)。
  • REPEATABLE_READ: 同一トランザクション内で同じクエリの結果が保証されます。範囲検索への挿入で結果が変わるPhantom Readは仕様上は残りますが、MySQLのInnoDBではこれも防ぎます。
  • SERIALIZABLE: 完全な分離を保証しますが、パフォーマンスへの影響が大きくなります。

金額計算など厳密な整合性が必要な処理では、REPEATABLE_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と一致する
}

実務での指針はシンプルで、通常は デフォルト(READ_COMMITTED) で十分です。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("金額が不正です");
    }
}

この非対称な仕様はEJB時代からの慣習で、checked例外は「呼び出し側が回復可能なビジネス例外(コミットしてよい)」、unchecked例外は「回復不能なシステム例外(ロールバックすべき)」という前提の設計です。実務ではこの区別が実態に合わないことが多いため、迷ったら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);
    }
}

なお、このself-invocationの制約は@Transactionalに限らず、@Async@Retryableなどプロキシベースのアノテーション全般に共通します。リトライ処理での同じ落とし穴はSpring Bootの@Retryableでリトライ処理を実装するで解説しています。

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

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

ロールバックと例外設計は表裏一体です。例外処理の詳細はSpring BootのREST APIにおける例外処理の実装方法で解説しています。

トランザクション境界とは何か・どこに引くべきか

こうした失敗パターンの多くは、突き詰めると「トランザクション境界の設計ミス」に行き着きます。

トランザクション境界 とは、トランザクションが開始されてからコミットまたはロールバックされるまでの範囲のことです。この範囲内のデータベース操作は「すべて成功」か「すべてなかったこと」のどちらかになります。

境界設計の指針はシンプルで、業務として「まとめて成功・まとめて失敗」すべき単位に1つの境界を引く ことです。実装上は、ユースケースを表すService層のpublicメソッドが境界の単位になります。

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Order order) {
        // 「注文確定」というユースケース全体が1つの境界
        orderRepository.save(order);        // 注文の保存
        stockRepository.decrease(order);    // 在庫の引き当て
        // どちらかが失敗すれば両方ロールバックされる
    }
}

よくあるアンチパターンは3つです。

  • 境界が細かすぎる: Service層に@Transactionalがないパターン。Spring Data JPAのRepositoryメソッドはSimpleJpaRepository@Transactionalで保護されているため、1メソッド呼び出し単位の短いトランザクションで実行されます。上の例なら、在庫引き当てで例外が起きても注文は保存済みのままになり、冒頭で挙げた「エラーが起きたのに在庫だけ減った」状態を招きます。複数の更新を伴うユースケースでは必ずService層に境界を引いてください。
  • 境界が広すぎる: Controller層やバッチ全体を1トランザクションにするパターン。外部API呼び出しやファイルI/Oまで境界に含まれ、コネクションとロックを長時間保持してスループットを悪化させます。外部呼び出しはトランザクションの外に出すのが原則です。
  • 境界が意図とズレている: self-invocationにより、境界を引いたつもりの場所で実際にはトランザクションが開始されていないパターン。前セクションの失敗パターン2がこれに該当します。

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

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

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

Hibernateでは変更検知(dirty checking)をスキップし、FlushModeをMANUALに設定するため、メモリ使用量の削減とパフォーマンス向上が見込めます。変更検知のコストは管理下のエンティティ数に比例するので、検索結果一覧やレポート生成のように大量のエンティティを読み込む処理で特に効果が大きいです。数件の読み取りでは体感差はほぼありませんが、「このメソッドは更新しない」という意図をコードに残せるため、読み取り専用メソッドには一律で付ける方針をおすすめします。

クラスレベルに@Transactional(readOnly = true)を付け、更新系メソッドにだけ@Transactionalを個別に付けて上書きするのも定番のパターンです。読み取りがデフォルトになるため、readOnlyの付け忘れを構造的に防げます。

@Service
@Transactional(readOnly = true)
public class OrderService {

    public List<Order> findOrders() {
        // クラスレベルのreadOnly=trueが適用される
        return orderRepository.findAll();
    }

    @Transactional  // メソッドレベルが優先され、書き込み可能
    public Order createOrder(Order order) {
        return orderRepository.save(order);
    }
}

readOnly=trueのまま更新するとどうなるか

「readOnlyを付けたのに更新できてしまった」「逆に本番だけエラーになった」という混乱がよくあります。挙動はJPA実装とデータベース(JDBCドライバ)の組み合わせで決まります。

  • Hibernateレイヤー: FlushModeがMANUALのため、エンティティを書き換えても変更検知によるUPDATE文は発行されません。エラーにならず 黙って無視される のがポイントで、テストで気づきにくい典型パターンです。
  • JPQL/ネイティブクエリの明示的な更新: 変更検知を経由しないため実行されようとします。止めてくれるかどうかはデータベース側の設定次第です。
  • データベースレイヤー: PostgreSQLではトランザクションがREAD ONLYモードになるため、UPDATE実行時に「cannot execute UPDATE in a read-only transaction」エラーになります。MySQL(Connector/J)でも読み取り専用コネクションへの更新は例外になります。

つまり「readOnlyなら更新は必ずエラーになるから安全」とは言い切れず、経路によっては更新が静かに消えます。readOnlyは安全装置ではなく最適化ヒントと捉え、読み取り処理と更新処理はメソッド(クラス)レベルで分離しておくのが確実です。

readOnlyと伝播レベルの組み合わせの落とし穴

readOnly設定が実際に反映されるのは、そのメソッドで新しいトランザクションが開始されたときだけ です。デフォルトのREQUIREDで既存トランザクションに参加した場合、内側メソッドのreadOnly指定は無視され、外側トランザクションの設定が引き継がれます。

@Service
public class ReportService {

    // 単体で呼ばれれば読み取り専用トランザクション
    @Transactional(readOnly = true)
    public List<Order> findRecentOrders() {
        return orderRepository.findTop100ByOrderByCreatedAtDesc();
    }
}

@Service
public class OrderService {

    @Transactional  // 書き込みトランザクション
    public void closeDailyOrders() {
        // 既存の書き込みトランザクションに「参加」するため、
        // findRecentOrders()のreadOnly=trueは効かない
        List<Order> orders = reportService.findRecentOrders();
        // ...
    }
}

逆に、readOnlyトランザクションの内側から更新を伴うメソッドがREQUIREDで参加すると、読み取り専用のまま更新しようとして失敗します。「readOnlyが効いているかどうかは、そのメソッドではなくトランザクションを開始したメソッドで決まる」と覚えておくと、この種のバグを早期に疑えますよ。

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」といったログが出力され、境界が意図した場所で引かれているかを確認できます。

テストコードでの確認

テストクラスやテストメソッドに付けた@Transactionalは、アプリケーションコードとは別の文脈で動作します。テストではデフォルトでロールバックされるため、テスト間でデータが干渉しません。実際にコミットしてデータベースの状態を確認したい場合は、@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;

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

この自動ロールバックをRepository層のテストで活用する方法はSpring Bootの@DataJpaTestでRepository層のスライステストを書く方法、テスト全般の書き方はSpring BootのテストをJUnitとMockitoで書く方法をご覧ください。

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

トランザクション境界は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処理全体がトランザクション化されてコネクションを長時間保持してしまい、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)で問題なし
  • self-invocationではトランザクションが効かない。別クラスに分割して解決
  • 読み取り専用処理にはreadOnly=trueでパフォーマンス最適化
  • トランザクション境界はServiceレイヤーに、1ユースケース1トランザクションで配置する

正しく理解して、データ整合性と保守性を両立したアプリケーションを構築していきましょう。