Springの@Transactionalアノテーションは、宣言的トランザクション管理を実現する便利な機能です。しかし、この便利さの裏には「効いているつもりが効いていない」という厄介な落とし穴が存在します。特に、自己呼び出し問題(同一クラス内での@Transactionalメソッド呼び出し)は、多くの開発者が遭遇するトラブルの代表格です。本記事では、Spring @Transactionalが効かない典型パターン、Springのプロキシベースのトランザクション管理の仕組み、そして自己呼び出し問題の具体的な解決策を解説します。トランザクション管理の内部動作を理解することで、本番環境での予期せぬデータ不整合を未然に防ぎましょう。

実行環境と前提条件

本記事のサンプルコードは、以下の環境で動作確認しています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Data JPAに含まれる)
データベース PostgreSQL 16.x または H2 Database(開発用)
ビルドツール Maven または Gradle

事前に以下の準備を完了してください。

  • JDK 17以上のインストール
  • Spring Boot + Spring Data JPAプロジェクトの基本構成
  • spring-boot-starter-data-jpa依存関係の追加

Springのプロキシベーストランザクション管理の仕組み

@Transactionalが効かないパターンを理解するには、まずSpringがどのようにトランザクション管理を実現しているかを把握する必要があります。

プロキシパターンによるAOP実装

Springの@Transactionalは、AOP(Aspect-Oriented Programming)の仕組みを利用して実装されています。具体的には、@Transactionalが付与されたBeanに対してプロキシオブジェクトを生成し、このプロキシがトランザクションの開始・コミット・ロールバックを制御します。

sequenceDiagram
    participant Client as 呼び出し元
    participant Proxy as プロキシ<br>(トランザクション制御)
    participant Target as 実際のBean

    Client->>Proxy: メソッド呼び出し
    Note over Proxy: トランザクション開始
    Proxy->>Target: 実メソッド実行
    Target-->>Proxy: 戻り値
    alt 正常終了
        Note over Proxy: コミット
    else 例外発生
        Note over Proxy: ロールバック
    end
    Proxy-->>Client: 戻り値

プロキシの種類

Springは2種類のプロキシ方式をサポートしています。

プロキシ方式 条件 特徴
JDKダイナミックプロキシ インターフェースを実装している場合 インターフェースのメソッドのみプロキシ可能
CGLIBプロキシ インターフェースを実装していない場合 クラスを継承してプロキシを生成

Spring Boot 2.0以降では、デフォルトでCGLIBプロキシが使用されます。これはspring.aop.proxy-target-class=trueがデフォルト設定となっているためです。

flowchart TD
    A[Spring Bootアプリケーション起動] --> B{Beanに@Transactional<br>が付与されている?}
    B -->|Yes| C{インターフェースを<br>実装している?}
    B -->|No| D[通常のBeanとして登録]
    C -->|Yes| E[JDKダイナミックプロキシ生成]
    C -->|No| F[CGLIBプロキシ生成]
    E --> G[DIコンテナにプロキシを登録]
    F --> G

プロキシが介在しない呼び出し

重要なポイントは、プロキシを経由しない呼び出しではトランザクション制御が行われないということです。これが@Transactionalが効かない問題の根本原因です。

@Transactionalが効かない典型パターン

実務で遭遇しやすい「@Transactionalが効かない」パターンを見ていきましょう。

パターン1: 自己呼び出し(同一クラス内での呼び出し)

最も頻繁に遭遇する問題が自己呼び出しです。同一クラス内で@Transactionalメソッドを呼び出すと、プロキシを経由せずに直接メソッドが実行されるため、トランザクションが適用されません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Service
public class OrderService {

    private final OrderRepository orderRepository;

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

    // 外部から呼び出されるメソッド
    public void processOrder(Long orderId) {
        // ビジネスロジック...
        
        // 自己呼び出し - @Transactionalが効かない!
        this.updateOrderStatus(orderId, OrderStatus.PROCESSING);
    }

    @Transactional
    public void updateOrderStatus(Long orderId, OrderStatus status) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        order.setStatus(status);
        orderRepository.save(order);
        
        // 例外が発生してもロールバックされない
        if (status == OrderStatus.FAILED) {
            throw new OrderProcessingException("注文処理に失敗しました");
        }
    }
}

この問題が発生する理由を図で示します。

sequenceDiagram
    participant Client as Controller
    participant Proxy as OrderServiceプロキシ
    participant Target as OrderService実体

    Client->>Proxy: processOrder()
    Note over Proxy: トランザクション開始なし<br>(@Transactionalなし)
    Proxy->>Target: processOrder()
    Target->>Target: this.updateOrderStatus()<br>【直接呼び出し】
    Note over Target: プロキシを経由していない<br>トランザクション開始されない
    Target-->>Proxy: 戻り値
    Proxy-->>Client: 戻り値

this.updateOrderStatus()は、プロキシではなく実体のメソッドを直接呼び出しているため、@Transactionalのインターセプトが発生しません。

パターン2: privateメソッドへの@Transactional

privateメソッドに@Transactionalを付与しても、トランザクションは適用されません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class PaymentService {

    private final PaymentRepository paymentRepository;

    public PaymentService(PaymentRepository paymentRepository) {
        this.paymentRepository = paymentRepository;
    }

    public void processPayment(PaymentRequest request) {
        // 何らかの前処理...
        executePayment(request);
    }

    // privateメソッド - @Transactionalが効かない!
    @Transactional
    private void executePayment(PaymentRequest request) {
        Payment payment = new Payment(request);
        paymentRepository.save(payment);
        
        // 例外が発生してもロールバックされない
        externalPaymentGateway.charge(request);
    }
}

理由: CGLIBプロキシはクラスを継承してプロキシを生成しますが、privateメソッドはオーバーライドできないため、プロキシがインターセプトできません。

パターン3: 非Spring管理Bean(newでインスタンス化)

Springコンテナが管理していないオブジェクトでは、@Transactionalは動作しません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 非Spring管理 - @Transactionalが効かない!
public class ManualOrderProcessor {

    private final OrderRepository orderRepository;

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

    @Transactional
    public void process(Order order) {
        orderRepository.save(order);
        // トランザクションは適用されない
    }
}

@Service
public class OrderController {

    private final OrderRepository orderRepository;

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

    public void handleOrder(Order order) {
        // newでインスタンス化 - Springの管理外
        ManualOrderProcessor processor = new ManualOrderProcessor(orderRepository);
        processor.process(order); // トランザクションなし
    }
}

理由: newでインスタンス化されたオブジェクトはSpring DIコンテナの管理外であり、プロキシが生成されません。

パターン4: finalクラス・finalメソッドへの@Transactional

finalクラスやfinalメソッドに@Transactionalを付与しても効果がありません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// finalクラス - CGLIBがサブクラスを作成できない
@Service
public final class FinalOrderService {

    @Transactional
    public void createOrder(Order order) {
        // トランザクションが効かない可能性あり
    }
}

@Service
public class OrderService {

    // finalメソッド - オーバーライドできない
    @Transactional
    public final void createOrder(Order order) {
        // トランザクションが効かない
    }
}

理由: CGLIBはターゲットクラスを継承してプロキシを生成しますが、finalクラスは継承できず、finalメソッドはオーバーライドできません。

自己呼び出し問題の解決策

最も頻出する自己呼び出し問題に対する解決策を、推奨度順に紹介します。

解決策1: コンポーネントの分離(推奨)

最もシンプルで推奨される方法は、トランザクションが必要な処理を別のコンポーネントに分離することです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// トランザクション処理を担当する専用サービス
@Service
public class OrderStatusUpdater {

    private final OrderRepository orderRepository;

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

    @Transactional
    public void updateStatus(Long orderId, OrderStatus status) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        order.setStatus(status);
        orderRepository.save(order);

        if (status == OrderStatus.FAILED) {
            throw new OrderProcessingException("注文処理に失敗しました");
        }
    }
}

// メインのサービスクラス
@Service
public class OrderService {

    private final OrderStatusUpdater orderStatusUpdater;

    public OrderService(OrderStatusUpdater orderStatusUpdater) {
        this.orderStatusUpdater = orderStatusUpdater;
    }

    public void processOrder(Long orderId) {
        // ビジネスロジック...
        
        // 別コンポーネント経由 - プロキシを通るためトランザクションが効く
        orderStatusUpdater.updateStatus(orderId, OrderStatus.PROCESSING);
    }
}
sequenceDiagram
    participant Client as Controller
    participant ServiceProxy as OrderServiceプロキシ
    participant Service as OrderService実体
    participant UpdaterProxy as OrderStatusUpdaterプロキシ
    participant Updater as OrderStatusUpdater実体

    Client->>ServiceProxy: processOrder()
    ServiceProxy->>Service: processOrder()
    Service->>UpdaterProxy: updateStatus()
    Note over UpdaterProxy: トランザクション開始
    UpdaterProxy->>Updater: updateStatus()
    Updater-->>UpdaterProxy: 戻り値
    Note over UpdaterProxy: コミット
    UpdaterProxy-->>Service: 戻り値
    Service-->>ServiceProxy: 戻り値
    ServiceProxy-->>Client: 戻り値

メリット:

  • 単一責任の原則に沿った設計になる
  • テストが容易になる
  • コードの意図が明確になる

解決策2: ApplicationContextからの自己参照取得

アーキテクチャ上、コンポーネントを分離できない場合は、ApplicationContextから自分自身のプロキシを取得する方法があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
public class OrderService implements ApplicationContextAware {

    private final OrderRepository orderRepository;
    private ApplicationContext applicationContext;

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

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    public void processOrder(Long orderId) {
        // ApplicationContextから自分自身のプロキシを取得
        OrderService self = applicationContext.getBean(OrderService.class);
        
        // プロキシ経由での呼び出し - トランザクションが効く
        self.updateOrderStatus(orderId, OrderStatus.PROCESSING);
    }

    @Transactional
    public void updateOrderStatus(Long orderId, OrderStatus status) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        order.setStatus(status);
        orderRepository.save(order);
    }
}

より簡潔に書く場合は、@Lazyを使った自己注入も可能です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderService self; // 自己参照

    public OrderService(OrderRepository orderRepository,
                       @Lazy OrderService self) {
        this.orderRepository = orderRepository;
        this.self = self;
    }

    public void processOrder(Long orderId) {
        // プロキシ経由での呼び出し
        self.updateOrderStatus(orderId, OrderStatus.PROCESSING);
    }

    @Transactional
    public void updateOrderStatus(Long orderId, OrderStatus status) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        order.setStatus(status);
        orderRepository.save(order);
    }
}

注意点:

  • 循環依存を避けるため@Lazyが必要
  • コードの可読性が低下する可能性がある
  • 設計の問題を覆い隠してしまう恐れがある

解決策3: AspectJによるLTW/CTW(上級者向け)

プロキシベースではなく、バイトコード織り込み(Weaving)を使用することで、自己呼び出しでも@Transactionalを機能させることができます。

1
2
3
4
5
6
// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework:spring-aspects'
    implementation 'org.aspectj:aspectjweaver:1.9.22'
}
1
2
3
4
5
6
// 設定クラス
@Configuration
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class TransactionConfig {
    // AspectJモードでトランザクション管理を有効化
}
1
2
# application.properties
spring.jpa.properties.hibernate.current_session_context_class=org.springframework.orm.hibernate5.SpringSessionContext

AspectJを使用する場合、JVMの起動時に-javaagentオプションでAspectJエージェントを指定する必要があります。

1
java -javaagent:path/to/aspectjweaver.jar -jar myapp.jar

メリット:

  • 自己呼び出しでも@Transactionalが機能する
  • privateメソッドにも適用可能

デメリット:

  • 設定が複雑
  • デバッグが困難になる場合がある
  • ビルド時間が増加する可能性がある

チェック例外でのロールバック設定

@Transactionalのデフォルト動作では、非チェック例外(RuntimeException)のみロールバックされます。チェック例外(Exception)が発生した場合は、デフォルトではロールバックされません。

デフォルト動作の確認

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
public class OrderService {

    private final OrderRepository orderRepository;

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

    @Transactional
    public void createOrder(OrderRequest request) throws OrderValidationException {
        Order order = new Order(request);
        orderRepository.save(order);
        
        // チェック例外 - デフォルトではロールバックされない
        if (!order.isValid()) {
            throw new OrderValidationException("注文内容が不正です");
        }
    }
}

// チェック例外
public class OrderValidationException extends Exception {
    public OrderValidationException(String message) {
        super(message);
    }
}

チェック例外でもロールバックする設定

rollbackFor属性を使用して、特定の例外でロールバックするように設定できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
public class OrderService {

    private final OrderRepository orderRepository;

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

    // チェック例外でもロールバックする
    @Transactional(rollbackFor = OrderValidationException.class)
    public void createOrder(OrderRequest request) throws OrderValidationException {
        Order order = new Order(request);
        orderRepository.save(order);
        
        if (!order.isValid()) {
            throw new OrderValidationException("注文内容が不正です");
        }
    }

    // すべての例外でロールバックする
    @Transactional(rollbackFor = Exception.class)
    public void createOrderStrict(OrderRequest request) throws Exception {
        Order order = new Order(request);
        orderRepository.save(order);
        
        externalValidationService.validate(order); // チェック例外をスローする可能性
    }
}

ロールバック設定の一覧

属性 説明 使用例
rollbackFor 指定した例外クラスでロールバック @Transactional(rollbackFor = Exception.class)
rollbackForClassName 例外クラス名(文字列)でロールバック @Transactional(rollbackForClassName = "java.io.IOException")
noRollbackFor 指定した例外クラスではロールバックしない @Transactional(noRollbackFor = BusinessException.class)
noRollbackForClassName 例外クラス名(文字列)でロールバックしない @Transactional(noRollbackForClassName = "...")

複数の例外を指定する

1
2
3
4
5
6
7
8
@Transactional(rollbackFor = {
    OrderValidationException.class,
    PaymentException.class,
    InventoryException.class
})
public void processOrder(OrderRequest request) throws Exception {
    // 複数のチェック例外が発生する可能性がある処理
}

よくある誤解とアンチパターン

誤解1: @Transactionalを付ければ必ずトランザクションが効く

前述の通り、プロキシを経由しない呼び出しでは@Transactionalは効きません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// アンチパターン
@Service
public class BadService {
    
    public void publicMethod() {
        // 自己呼び出し - トランザクションが効かない
        internalMethod();
    }
    
    @Transactional
    public void internalMethod() {
        // ...
    }
}

誤解2: 例外をcatchすればロールバックを防げる

例外が@Transactionalメソッドの外に伝播しなければロールバックは発生しません。しかし、これは意図しないデータ不整合を招く可能性があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// アンチパターン - 例外を握りつぶしてロールバックを防ぐ
@Transactional
public void riskyMethod() {
    try {
        // 例外が発生する可能性のある処理
        repository.save(entity);
        externalService.call(); // 例外発生
    } catch (Exception e) {
        // ログだけ出して握りつぶし - 危険!
        log.error("エラー発生", e);
    }
    // saveは成功、externalServiceは失敗という不整合状態になる可能性
}

誤解3: readOnly = trueならSELECTしか実行できない

readOnly = trueヒントであり、強制ではありません。Hibernate/JPAはこのヒントを使ってフラッシュモードを最適化しますが、INSERT/UPDATE/DELETEの実行を完全には防止しません。

1
2
3
4
5
@Transactional(readOnly = true)
public void readOnlyMethod() {
    Entity entity = repository.findById(1L).orElseThrow();
    entity.setName("変更"); // ダーティチェックにより更新される可能性あり(実装依存)
}

誤解4: 非同期メソッドでも@Transactionalが効く

@Async@Transactionalを組み合わせる場合、実行スレッドが異なるため注意が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 注意が必要なパターン
@Service
public class AsyncService {

    @Async
    @Transactional
    public void asyncMethod() {
        // 非同期で実行される
        // トランザクションは新しいスレッドで開始される
    }
}

@Transactional問題の検出方法

1. ログレベルの設定

トランザクションの動作を確認するため、ログレベルを設定します。

1
2
3
# application.properties
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG

2. TransactionSynchronizationManagerの活用

プログラムからトランザクション状態を確認できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Service
public class TransactionDebugService {

    public void checkTransactionStatus() {
        boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
        String txName = TransactionSynchronizationManager.getCurrentTransactionName();
        boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        
        System.out.println("Transaction Active: " + isActive);
        System.out.println("Transaction Name: " + txName);
        System.out.println("ReadOnly: " + isReadOnly);
    }
}

3. テストでの検証

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@SpringBootTest
@Transactional
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void トランザクションがロールバックされることを確認() {
        // Given
        Long orderId = 1L;
        
        // When & Then
        assertThrows(OrderProcessingException.class, () -> {
            orderService.updateOrderStatus(orderId, OrderStatus.FAILED);
        });
        
        // トランザクションがロールバックされ、ステータスが変更されていないことを確認
        Order order = orderRepository.findById(orderId).orElseThrow();
        assertNotEquals(OrderStatus.FAILED, order.getStatus());
    }
}

まとめと実践Tips

@Transactionalが効かないパターンの整理

パターン 原因 解決策
自己呼び出し プロキシを経由しない コンポーネント分離、自己参照取得
privateメソッド オーバーライドできない publicに変更、コンポーネント分離
非Spring管理Bean プロキシが生成されない DIを使用する
finalクラス/メソッド CGLIBが継承できない finalを除去
チェック例外 デフォルトでロールバックされない rollbackFor属性を指定

実践Tips

  1. 設計段階でトランザクション境界を明確にする

    • サービス層のpublicメソッドをトランザクション境界とする
    • 内部処理は別コンポーネントに分離する
  2. チェック例外を使用する場合は必ずrollbackForを検討する

    • ビジネス例外でもロールバックが必要かどうかを明確にする
  3. テストでトランザクション動作を検証する

    • 例外発生時のロールバック動作を必ずテストする
  4. ログを活用してトランザクション動作を可視化する

    • 開発環境ではDEBUGログを有効にする
  5. コードレビューで自己呼び出しをチェックする

    • this.での@Transactionalメソッド呼び出しに注意する

@Transactionalは強力な機能ですが、プロキシベースの実装を理解していないと思わぬ落とし穴にはまります。本記事で解説したパターンと解決策を押さえておくことで、トランザクション管理に関するトラブルを未然に防ぎ、信頼性の高いアプリケーションを構築できます。

参考リンク