@Transactional を付けたのに例外が出てもロールバックされない。@Cacheable を付けたのに毎回DBにクエリが飛ぶ。@Async を付けたのになぜか同期で動く。この3つ、一見バラバラの症状に見えますが、実は原因はほぼ同じです。

どれも Spring AOPのプロキシを通らずにメソッドが呼ばれている だけなんですよね。

この記事はプロキシの仕組みを網羅する記事ではなく、「効かないときに何を見て、どう直すか」に絞った逆引きです。まず診断リストで自分のコードがどのパターンかを当てて、該当する節に飛んでください。

まず診断リストで当てはめる

症状実際に起きていること
ロールバックされないTransactionInterceptor が呼ばれていない
キャッシュされず毎回DBに行くCacheInterceptor が呼ばれていない
同期で実行されるAsyncExecutionInterceptor が呼ばれていない

インターセプターはプロキシの中にいるので、プロキシを通らなければ何も起きません。プロキシを通らなくなる典型パターンは次の5つです。

  1. 同じクラスの中から this.method() で呼んでいる(self-invocation)
  2. privatefinal のメソッドに付けている
  3. クラスが final でCGLIBがサブクラスを作れない
  4. new で作ったインスタンスや static メソッド
  5. @EnableAsync / @EnableCaching などの有効化漏れ

どれにも当てはまらなければ、記事後半の「似ているが原因が異なるケース」を見てください。

なぜ「外からの呼び出し」だけ効くのか

Spring AOPは、Beanそのものを書き換えるのではなく、Beanを包む プロキシ を作ってコンテナに登録します。他のBeanに注入されるのは実Beanではなくこのプロキシです。

呼び出し元Bean
   │ userService.register()

[プロキシ] ── TransactionInterceptor / CacheInterceptor / AsyncExecutionInterceptor


[実Bean] UserService.register()
   │ this.saveWithTx()  ← プロキシを通らず実Beanを直接呼ぶ

[実Bean] UserService.saveWithTx()  … アノテーションは無視される

外部から userService.register() と呼ぶと、プロキシがインターセプターを動かしてから実Beanへ処理を委譲します。ところが実Beanの中で this.saveWithTx() と書くと、this は実Beanそのものなので、プロキシの層をまるごと素通りしてしまいます。

AOPの用語(Aspect、Advice、JoinPoint)そのものはAOPの基礎記事に任せて、ここでは「プロキシを通っているかどうか」だけに集中しましょう。

パターン1: 同一クラス内呼び出し(self-invocation)

圧倒的に多いのがこれです。再現コードを見てみましょう。

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void register(User user) {
        // 同じクラス内の呼び出し = this.saveWithTx(user)
        saveWithTx(user);
    }

    @Transactional
    public void saveWithTx(User user) {
        userRepository.save(user);
        throw new IllegalStateException("わざと失敗");
    }
}

register() を外から呼ぶと、saveWithTx()@Transactional は無視されます。トランザクションが張られていないので、save() の時点で自動コミットされ、例外を投げてもロールバックされません。

@Cacheable でも @Async でも構造は同じです。

public List<User> findAllTwice() {
    findAll(); // 毎回DBに行く
    return findAll();
}

@Cacheable("users")
public List<User> findAll() {
    return userRepository.findAll();
}

public void notify(User user) {
    sendMail(user); // 呼び出し元スレッドで同期実行される
}

@Async
public void sendMail(User user) {
    log.info("thread={}", Thread.currentThread().getName());
}

sendMail() のログを見ると、task-1 ではなく http-nio-8080-exec-1 のようなリクエストスレッド名が出ます。これがプロキシを通っていない証拠です。

@Retryable も同じ理屈でリトライされません。詳しくはSpring Retryの記事で触れています。

パターン2: privateメソッド・finalメソッドに付けている

Spring Bootのデフォルトプロキシは、対象クラスのサブクラスを動的生成するCGLIBです。サブクラスでオーバーライドできないメソッドはインターセプトできません。

@Transactional
private void saveInternal(User user) { // 静かに無視される
    userRepository.save(user);
}

@Transactional
public final void saveFinal(User user) { // これも無視される
    userRepository.save(user);
}

コンパイルエラーにはなりません。IntelliJ IDEAなら「Methods annotated with @Transactional must be overridable」と警告してくれますが、CLIビルドでは何も言われないので厄介です。final メソッドはSpringが起動ログに「Final method … cannot get proxied via CGLIB」と出してくれるので、そちらも手がかりになります。

なお、Spring Framework 6.0以降はクラスベースプロキシに限り protected やパッケージプライベートのメソッドでも @Transactional が動くようになりました。とはいえ公式は引き続き public を推奨しているので、迷わず public にしておきましょう。

public に直したら、そのメソッドを同じクラス内から呼んでいないか(パターン1)も一緒に確認してください。ここが二重に絡んでいることが多いです。

パターン3: finalクラスでCGLIBがサブクラス化できない

クラス自体が final だと、CGLIBはサブクラスを作れません。この場合は静かに無視されるのではなく、起動時に落ちます。

Caused by: org.springframework.aop.framework.AopConfigException:
  Could not generate CGLIB subclass of class com.example.UserService:
  Common causes of this problem include using a final class or a non-visible class

Javaで意図的に final を付けることは少ないですが、Kotlinは要注意です。Kotlinのクラスはデフォルトで final なので、kotlin-spring(allopen)プラグインを入れていないと同じエラーになります。

修正は final を外すか、インターフェースを切ってJDK Dynamic Proxyに切り替えるかのどちらかです。

パターン4: newで生成したインスタンス・staticメソッド

プロキシはDIコンテナがBeanを作るときに差し込まれます。自分で new したオブジェクトは素のクラスなので、どのアノテーションも効きません。

// NG: プロキシではない素のUserService
UserService service = new UserService(userRepository);
service.saveWithTx(user); // ロールバックされない

テストコードでこれをやって「トランザクションが効かない」と悩むケースをよく見ます。static メソッドもインスタンスを経由しないので同様に対象外です。

必ず @Autowired かコンストラクタインジェクションで受け取りましょう。Bean登録の基本は@Componentの記事にまとめています。

パターン5: @EnableXxxや依存の有効化漏れ

Spring Bootが自動でやってくれる範囲と、自分で書く必要がある範囲を整理します。

アノテーション自動で有効になる条件自分で書くもの
@TransactionalTransactionManager があれば自動(JPA/JDBC starter導入で自動設定)なし。DataSourceが無いと動かない
@Cacheable自動では有効にならない@EnableCaching が必須
@Async自動では有効にならない@EnableAsync が必須
@Retryable自動では有効にならないspring-retry + spring-boot-starter-aop + @EnableRetry

@EnableCaching@EnableAsync を忘れても、エラーも警告も出ません。@Cacheable は素通りし、@Async は同期で動くだけです。

@EnableCaching を付ければ、Spring Bootは CacheManager が無くても ConcurrentMapCacheManager を自動設定してくれます。Caffeineなどに切り替える設定やTTLはキャッシュの記事、Executorの設定は非同期処理の記事を参照してください。

効いているかを事実で確かめる

推測で直すと二度手間になるので、プロキシを通っているかを事実で確認しましょう。手段は4つあります。

@Component
@RequiredArgsConstructor
public class ProxyCheckRunner implements CommandLineRunner {

    private final UserService userService;

    @Override
    public void run(String... args) {
        // (a) プロキシかどうか
        log.info("isAopProxy={}, isCglibProxy={}, class={}",
                AopUtils.isAopProxy(userService),
                AopUtils.isCglibProxy(userService),
                userService.getClass().getName());
    }
}

プロキシなら class=com.example.UserService$$SpringCGLIB$$0 のようにクラス名に $$SpringCGLIB$$ が入ります。デバッガで変数を見るだけでも分かります。

メソッドの中では次の2つが便利です。

@Transactional
public void saveWithTx(User user) {
    // (c) トランザクションが実際に張られているか
    log.info("txActive={}", TransactionSynchronizationManager.isActualTransactionActive());
    userRepository.save(user);
}

@Async
public void sendMail(User user) {
    // (d) Executorのスレッド(task-1など)で動いているか
    log.info("thread={}", Thread.currentThread().getName());
}

そして (b) のTRACEログです。

logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.cache=TRACE

プロキシを通っていれば Getting transaction for [com.example.UserService.saveWithTx]No cache entry for key '...' in cache(s) [users] といった行が出ます。何も出なければ、インターセプターまで届いていません。ログレベルの設定方法はロギングの記事を見てください。

回避策の比較

self-invocationが原因だと分かったら、直し方は5通りあります。

回避策可読性テスト容易性循環参照リスク設定コスト
別Beanへ切り出すなしなし
自己注入(@Lazy / ObjectProvider)ありなし
TransactionTemplate などのAPIなしなし
AopContext.currentProxy()×なしexposeProxy=true
AspectJモードなし高い

第一選択は別Beanへの切り出しです。AspectJモード(mode = AdviceMode.ASPECTJ)はプロキシに頼らずバイトコードを織り込むのでself-invocationも解決しますが、コンパイル時またはロード時ウィービングのビルド設定が重く、この記事では採用しません。AopContext.currentProxy()@EnableAspectJAutoProxy(exposeProxy = true) が必要で、コードの意図が分かりにくくなるため、最後の手段と考えてください。

回避策1: 別Beanへ切り出す(推奨)

トランザクション境界を持つメソッドを別クラスに移して、外から呼ぶ形にします。

@Service
@RequiredArgsConstructor
public class UserRegistrationTx {

    private final UserRepository userRepository;

    @Transactional
    public void save(User user) {
        userRepository.save(user);
        throw new IllegalStateException("わざと失敗");
    }
}

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRegistrationTx registrationTx;

    public void register(User user) {
        registrationTx.save(user); // 別Beanのプロキシ経由なのでロールバックされる
    }
}

「トランザクションの中でやること」と「その前後の処理」がクラス単位で分かれるので、責務も明確になりますし、ユニットテストでは UserRegistrationTx をモックに差し替えられます。

回避策2: 自己注入(@Lazy / ObjectProvider)

事情があってクラスを分割できないなら、プロキシ経由の自分自身を注入して呼びます。

@Service
public class UserService {

    private final UserRepository userRepository;

    // プロキシ経由で自分を呼ぶための自己注入(self-invocation回避)
    @Autowired
    @Lazy
    private UserService self;

    public void register(User user) {
        self.saveWithTx(user); // プロキシを通る
    }

    @Transactional
    public void saveWithTx(User user) { /* ... */ }
}

自分自身を注入するので循環参照になります。Spring Boot 2.6以降は循環参照がデフォルトで禁止されており、@Lazy を付けないと起動時に「The dependencies of some of the beans in the application context form a cycle」で落ちます。コンストラクタインジェクションで自分を受け取るのは避けてください。

ObjectProvider を使う書き方も同じ効果です。

private final ObjectProvider<UserService> selfProvider;

public void register(User user) {
    selfProvider.getObject().saveWithTx(user);
}

どちらにしても「なぜ自分を注入しているのか」が読み手に伝わらないので、コメントで意図を残しておきましょう。Beanの生成順序や循環参照の背景はBeanスコープの記事で扱っています。

回避策3: TransactionTemplate・CacheManagerで直接制御する

アノテーションを使わずAPIで境界を書けば、プロキシ問題は根本から消えます。

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final TransactionTemplate transactionTemplate;
    private final CacheManager cacheManager;

    public void register(User user) {
        transactionTemplate.executeWithoutResult(status -> {
            userRepository.save(user);
            throw new IllegalStateException("ロールバックされる");
        });
    }

    public List<User> findAll() {
        Cache cache = cacheManager.getCache("users");
        List<User> cached = cache.get("all", List.class);
        if (cached != null) {
            return cached;
        }
        List<User> users = userRepository.findAll();
        cache.put("all", users);
        return users;
    }
}

@AsyncTaskExecutor を注入して CompletableFuture.supplyAsync(supplier, executor) に置き換えられます。境界がコードに見えるのがメリットで、記述量が増えるのがデメリットです。

JDK Dynamic ProxyとCGLIBの違いと関連エラー

プロキシには2種類あります。

  • JDK Dynamic Proxy はインターフェースベースです。インターフェース型でしか注入できません
  • CGLIB はクラスのサブクラスを生成します。具象クラス型でも注入できます

Spring Boot 2.0以降は spring.aop.proxy-target-class=true がデフォルトなので、インターフェースがあってもCGLIBが使われます。設定を明示するなら次の通りです。

# デフォルトはtrue(CGLIB)。falseにするとインターフェースがあるBeanはJDKプロキシになる
spring.aop.proxy-target-class=true

この設定が false になっているプロジェクトで、具象クラス型で注入しようとすると次のエラーになります。

BeanNotOfRequiredTypeException: Bean named 'userService' is expected to be of type
'com.example.UserService' but was actually of type 'jdk.proxy2.$Proxy87'

jdk.proxy...$Proxy という型名が出たら、JDK Dynamic Proxyに切り替わっているサインです。インターフェース型で注入するか、proxy-target-class=true に戻すかで解決します。AopUtils.isJdkDynamicProxy(bean) で確認できます。

似ているが原因が異なるケース

ここまで確認してプロキシは通っているのに動かないなら、原因は別のところにあります。

  • checked例外を投げてもロールバックされないのは仕様です。rollbackFor の指定が必要で、伝播レベルや readOnly の挙動と併せてトランザクション管理の記事で解説しています
  • キャッシュはされているのに毎回DBに行くなら、keycondition / unless の設定を疑ってください。キャッシュの記事が該当します
  • 非同期にはなっているが待たされるなら、Executorのスレッドプール枯渇の可能性があります。非同期処理の記事を確認してください

まとめ

アノテーションが効く条件は、突き詰めると3つだけです。

  • 別Beanから呼ばれていること(プロキシ経由の外部呼び出し)
  • public かつ final ではないメソッドであること
  • DIコンテナが管理しているBeanであること

これに @EnableXxx と依存の有効化を加えれば、今回の5パターンは全てカバーできます。迷ったら AopUtils.isAopProxy() とTRACEログで事実を確認してから直しましょう。直し方の第一選択は別Beanへの切り出し、どうしても分割できないときは @Lazy の自己注入です。

「アノテーションを付けたのに動かない」は、Springを触っていれば一度は通る道です。仕組みさえ分かってしまえば、次からは数分で原因にたどり着けるはずですよ。