Spring FrameworkやSpring Bootでデータベースの更新処理を書く際、魔法の呪文のように使われる @Transactional アノテーション。
「これをメソッドに付けておけば、処理の途中でエラーが起きても自動的にロールバック(処理の取り消し)されるから安心!」
…そう信じ切っていた時期が私にもありました。
しかしある日、処理の途中で例外が発生したにもかかわらず、「前半の更新だけがDBに保存され、後半の更新が失敗する」という中途半端なデータ不整合が発生。本番環境のデータを狂わせてしまう事故を引き起こしました。
今回は、なぜ @Transactional を付けたのにロールバックされなかったのか、その原因と対策をまとめます。
事故の全貌:エラーが起きたのにDBにコミットされていた
状況
ECサイトの「ポイント決済処理」を実装していた時のことです。
1つの取引で複数のテーブルを更新するため、一連の処理をまとめたサービスメソッドを作成し、頭に @Transactional を付与しました。
処理の流れは以下の通りです。
- ユーザーのポイント残高を減算する(DB更新)
- 注文履歴テーブルにレコードを挿入する(DB挿入)
- 外部の決済・通知APIを呼び出す(ここで例外発生!)
コードのイメージは以下のような形でした。
@Service
public class OrderService {
@Autowired
private PointRepository pointRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentApiClient paymentApiClient;
@Transactional
public void processOrder(OrderRequest request) {
// 1. ポイント減算
pointRepository.reducePoint(request.getUserId(), request.getAmount());
// 2. 注文履歴保存
orderRepository.save(new Order(request));
// 3. 外部API呼び出し(例外が発生する可能性がある)
paymentApiClient.sendPaymentNotification(request);
}
}
「@Transactional が付いているから、3で例外が出れば1も2も自動的に取り消されるはず!」と思い込んでテストを実行しました。
発生した悲劇
外部APIの通信エラーが発生した際、チームのDB担当から連絡が入りました。
同僚:「決済エラーになったログが出ているのに、ユーザーのポイントだけ減って注文履歴が残っていません!」
私:「えっ、@Transactional を付けているので全ロールバックされるはずでは…!?」
確認すると、3の処理で例外がスローされて処理が中断したにもかかわらず、1のポイント減算処理だけがデータベースに正常コミットされてしまっていました。
結果として、「お金(ポイント)だけ払わされて、商品注文が完了していない」という最悪の不整合データが残ってしまったのです。
考察:なぜロールバックされなかったのか?(3つの罠)
原因を調査した結果、Springのトランザクション制御(AOP)に関する 3つの大きな勘違い が判明しました。
1. デフォルトでは「非チェック例外(RuntimeException)」しかロールバックされない
Springの @Transactional は、デフォルトでは RuntimeException(非チェック例外)および Error が発生した時のみ ロールバックを行います。
ファイルI/Oやネットワーク通信、SQL処理などで発生する IOException や SQLException などの チェック例外(Exception を直接継承したクラス) がスローされた場合、Springは「業務上の想定内エラー」とみなし、ロールバックせずにコミットしてしまいます。
// IOException などのチェック例外が発生しても、デフォルトではロールバックされない!
@Transactional
public void processOrder(OrderRequest request) throws IOException {
// ...
throw new IOException("通信エラー"); // これだとコミットされてしまう!
}
2. try-catch で例外を捕まえて「握りつぶして」いた
エラーログを綺麗に残そうとして、メソッド内で try-catch を行っていたことも原因の1つでした。
@Transactional
public void processOrder(OrderRequest request) {
try {
pointRepository.reducePoint(...);
orderRepository.save(...);
paymentApiClient.sendPaymentNotification(...);
} catch (Exception e) {
// ログだけ出して例外を再スロー(throw)していない!
log.error("決済処理でエラーが発生しました", e);
}
}
Springのトランザクションは、メソッドから例外が外へスローされるのを検知してロールバックを行います。
try-catch で例外をキャッチし、ログを出力しただけで処理を終了してしまうと、Springからは「メソッドが正常に終了した」と見えてしまい、そのままコミット処理が走ってしまいます。
3. 同一クラス内(Self-invocation)でのメソッド呼び出し
別のアプローチとして、内部のプライベートメソッドに @Transactional を付けて呼び出していた箇所もありました。
@Service
public class OrderService {
// 外部から呼ばれるコントローラー用メソッド(@Transactional なし)
public void execute(OrderRequest request) {
// 同一クラス内の @Transactional メソッドを呼び出す
this.doTransaction(request);
}
@Transactional
public void doTransaction(OrderRequest request) {
// DB更新処理...
}
}
Springの @Transactional は、AOP(プロキシオブジェクト) という仕組みで動いています。
外部から OrderService のメソッドが呼ばれる時はプロキシを経由してトランザクションが開始されますが、同一クラス内のメソッドから this.doTransaction() のように直接呼び出した場合、プロキシを経由しないため @Transactional アノテーションが完全に無視されます。
失敗から得た「ロールバック事故を防ぐ対策」
これらの罠を踏まえ、チームで以下の実装ルールを徹底するようにしました。
1. rollbackFor = Exception.class を明示的に指定する
チェック例外も含めて、あらゆる例外が発生した際に確実にロールバックされるよう、アノテーションに属性を付与します。
// チェック例外(Exception全般)が発生しても必ずロールバックさせる
@Transactional(rollbackFor = Exception.class)
public void processOrder(OrderRequest request) throws Exception {
// ...
}
プロジェクト全体のルールとして、@Transactional を書く際は必ず rollbackFor = Exception.class をセットで記述することを義務付けました。
2. try-catch したら必ず再スローするか、明示的にロールバック指定する
ログ出力などのために try-catch を行う場合は、最後に必ず例外を再スローします。
@Transactional(rollbackFor = Exception.class)
public void processOrder(OrderRequest request) {
try {
// DB更新処理...
} catch (Exception e) {
log.error("処理失敗", e);
// 必ず例外を再スローしてSpringに伝える!
throw e;
}
}
もし例外を再スローしたくない場合は、以下のように手動でロールバックフラグを立てる必要があります。
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
3. トランザクション境界となるメソッドは別クラスに分ける
同一クラス内呼び出し(Self-invocation)による無効化を防ぐため、トランザクションが必要な処理は適切な Service クラスとして分離・切り出しを行うように設計を修正しました。
失敗から得た教訓・まとめ
| ハマりやすい罠 | 原因 | 改善後の対策 |
|---|---|---|
| チェック例外でコミットされる | デフォルトは RuntimeException のみロールバック |
rollbackFor = Exception.class を明示指定 |
try-catch でロールバックされない |
例外が外に伝わらず「正常終了」とみなされる | catch 内で必ず throw e; する |
| 内部呼び出しでアノテーションが効かない | AOPプロキシを経由しない(Self-invocation) | トランザクション用Serviceクラスを別切りする |
「アノテーションを付けたから安心」とフレームワークの仕組みをブラックボックスにしたまま使うと、本番環境で深刻なデータ不整合を引き起こします。
- フレームワークが裏でどう動いているか(AOPプロキシの仕組み)
- デフォルト動作の仕様(どの例外でロールバックされるか)
これらをしっかり理解して、安全なトランザクション設計を心がけていきましょう!
株式会社ONE WEDGE
【ITエンジニアに、IT業界に貢献する企業】
株式会社ONE WEDGEは、Webシステム開発・SES・AI/DX支援を行うIT企業です。生成AIを活用した業務効率化や次世代システム開発にも注力しており、企業の課題解決だけでなく、エンジニア一人ひとりの成長にも本気で向き合っています。また、技術は「一人で学ぶもの」ではなく「仲間と成長するもの」と考え、社内外でのコミュニティづくりにも力を入れています。
https://onewedge.co.jp/