Spring Frameworkにおける宣言型トランザクション管理では、@Transactionalアノテーションの動作がtry-catch-finally構文と組み合わさると予期せぬ挙動を示す場合があります。特にfinallyブロック内で例外が発生した際のロールバック挙動は誤解されやすく、データ整合性に深刻な影響を及ぼす可能性があります。
トランザクションロールバックの基本メカニズム
Springのトランザクション管理はメソッドの戻り値または投げられた例外に基づいて判断されます。プロキシが参照できるのはメソッド外部に伝播した最終的な例外のみです。
| 実行結果 | トランザクション動作 |
|---|---|
| 正常終了 | コミット |
| 非検査例外(RuntimeException/Error) | ロールバック |
| 検査例外(Checked Exception) | コミット(デフォルト) |
コアロジックはTransactionAspectSupportクラスに実装されています。メソッド内部の例外処理フローはプロキシ層からは見えません。
Java言語のfinallyブロック例外処理規則
トランザクションの挙動を理解する前に、Java言語自体の例外処理ルールを確認します:
public void exceptionExample() {
try {
throw new AccountCreationException("アカウント登録失敗");
} finally {
throw new ResourceReleaseException("リソース解放エラー");
}
// 実際にはResourceReleaseExceptionが外部に伝播
// AccountCreationExceptionは失われる
}
finallyブロックで例外がスローされると、tryブロック内の例外は上書きされ、外部にはfinallyの例外のみが伝播します。
実践的なシナリオ分析
シナリオA:正常処理と正常なfinally
@Transactional
public void accountRegistration() {
try {
accountRepository.save(new Account("田中"));
} finally {
resourceManager.release();
}
}
結果:トランザクションは正常コミットされます。メソッドが正常終了するため。
シナリオB:非検査例外と正常なfinally
@Transactional
public void paymentProcessing() {
try {
paymentService.execute();
throw new InsufficientFundsException("残高不足");
} finally {
auditLogger.log();
}
}
結果:ロールバックが発生します。finallyブロックが例外をスローしないため、InsufficientFundsExceptionがプロキシに伝播します。
シナリオC:検査例外による上書き(最も危険)
@Transactional
public void dataImport() throws IOException {
try {
dataRepository.store(new ImportData("顧客データ"));
throw new DataValidationException("フォーマット不正");
} finally {
throw new IOException("ファイルクローズ失敗");
}
}
結果:トランザクションがコミットされます!finallyブロックのIOException(検査例外)がDataValidationExceptionを上書きするため、プロキシはデフォルトでロールバックを実行しません。データが不正状態で永続化される危険性があります。
検証実験の実装例
@SpringBootTest
class TransactionVerificationTest {
@Autowired
private AccountService accountService;
@Test
void validateDangerousScenario() {
try {
accountService.importData();
} catch (IOException e) {
// 検査例外のみがキャッチされる
}
// データが存在する=ロールバックされない
assertTrue(accountRepository.existsByName("顧客データ"));
}
}
@Service
class AccountService {
@Transactional
public void importData() throws IOException {
try {
accountRepository.save(new Account("顧客データ"));
throw new DataValidationException("データ形式エラー");
} finally {
throw new IOException("リソース解放失敗");
}
}
}
安全な実装パターン
パターン1:finally内部の例外キャッチ
@Transactional
public void safeOperation() {
try {
businessLogic.execute();
} finally {
try {
resourceManager.cleanup();
} catch (Exception e) {
logger.warn("クリーンアップ警告", e);
}
}
}
finallyブロック内で発生した例外を内部で処理し、外部への伝播を防ぎます。
パターン2:rollbackForの明示的指定
@Transactional(rollbackFor = Exception.class)
public void securedTransaction() {
try {
process();
} finally {
releaseResources();
}
}
すべての例外でロールバックを実行するよう明示的に指定します。特にfinallyブロックを使用する場合は必須の設定です。
パターン3:try-with-resourcesの活用
@Transactional
public void resourceManagedOperation() {
try (FileHandle handle = resourceFactory.open()) {
handle.process();
}
}
リソース管理を言語機能に委譲することで、finallyブロックの例外上書きリスクを回避します。スローされた例外はsuppressed exceptionとして保持されます。