Engineering
Glossvation Engineering
Web・モバイル

Spring AOP 実装パターン — トランザクション・ログ・権限を本流から剥がす

横断的関心事 (クロスカッティングコンサーン) を綺麗に分離する Spring AOP の使い所と Aspect/JoinPoint/Advice/Pointcut の全体像、プロダクションで効くユースケース 4 選。

Glossvation ·

AOP 全体像

どういう問題を解くのか

ログ出力・トランザクション境界・権限チェック・監査 — これらは 「どのメソッドでも同じように必要になるが、ドメインロジックとは関係ない」 横断的関心事 (Cross-Cutting Concerns) です。

毎メソッドに手書きすると:

  • ドメインロジックが本来の意図から逸脱して読みにくくなる
  • 書き忘れが発生する
  • 仕様変更時に大量のメソッドを修正することになる

AOP は、これら横断的関心事を 「別のモジュール (Aspect)」 に切り出し、メソッド呼び出しに自動で挟み込む しくみです。

Spring の @Transactional / @Cacheable / @PreAuthorize — どれも Spring AOP の上に成り立っています。

用語の整理

用語意味例
Aspect横断的関心事をまとめたクラスTransactionAspect, AuditAspect
JoinPointAdvice を挟み込める地点 (メソッド呼び出し等)OrderService#create の呼び出し
AdviceJoinPoint で実行する具体コード開始ログを出す、トランザクション開始
Pointcutどの JoinPoint に Advice を当てるか指定する式within(com.sample.service..*)
WeavingAdvice を実際のコードに組み込む処理Spring は実行時プロキシで実現

Spring AOP の制約: Spring Bean に対する メソッド呼び出し のみが JoinPoint。フィールドアクセスや new は介入できません。より強力な介入 (フィールド・コンストラクタ) が必要なら AspectJ を使います。

Advice の 5 種類

@Aspect
@Component
public class AuditAspect {

    // 前処理
    @Before("execution(* com.example.service..*(..))")
    public void before(JoinPoint jp) { ... }

    // 後処理 (成功/失敗問わず)
    @After("execution(* com.example.service..*(..))")
    public void after(JoinPoint jp) { ... }

    // 正常終了したときだけ (戻り値を受け取れる)
    @AfterReturning(pointcut = "execution(* ..*(..))", returning = "result")
    public void afterReturning(JoinPoint jp, Object result) { ... }

    // 例外が出たときだけ
    @AfterThrowing(pointcut = "execution(* ..*(..))", throwing = "ex")
    public void afterThrowing(JoinPoint jp, Throwable ex) { ... }

    // 全部包む (もっとも強力、proceed() を呼ばないと本来処理が走らない)
    @Around("execution(* ..*(..))")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();
        } finally {
            long elapsed = System.nanoTime() - start;
            // メトリクス送信など
        }
    }
}

Pointcut 指示子チートシート

指示子何を選ぶか例
within(...)指定クラス内の全メソッドwithin(com.example.controller..*)
execution(...)メソッドシグネチャで指定execution(public * com.example..service.*.*(..))
target(...)指定型のインスタンスのメソッド呼び出しtarget(org.springframework.data.repository.Repository+)
args(...)引数の型で指定args(java.lang.String, ..)
@annotation(...)指定アノテーションが付いたメソッド@annotation(org.springframework.transaction.annotation.Transactional)
bean(name)Bean 名で指定bean(userService)

複数条件は && / || / ! で合成できます:

@Pointcut(
    "within(com.example.service..*) && " +
    "!@annotation(com.example.NoAudit)"
)
public void auditedServices() {}

よく使う Pointcut は @Pointcut で名前を付けて再利用。

実用例 4 選

1. API 呼び出しの構造化ログ

@Aspect
@Component
public class ApiLogAspect {
    private static final Logger log = LoggerFactory.getLogger(ApiLogAspect.class);

    @Around("within(@org.springframework.web.bind.annotation.RestController *)")
    public Object log(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        String sig = pjp.getSignature().toShortString();
        try {
            Object result = pjp.proceed();
            log.info("api_ok sig={} ms={}", sig, System.currentTimeMillis() - start);
            return result;
        } catch (Throwable t) {
            log.warn("api_fail sig={} ms={} err={}", sig, System.currentTimeMillis() - start, t.toString());
            throw t;
        }
    }
}

2. 独自アノテーションで監査ログ

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited { String action(); }

@Aspect
@Component
public class AuditAspect {
    @AfterReturning("@annotation(audited)")
    public void record(JoinPoint jp, Audited audited) {
        // DB か監査サーバに書き出す
    }
}

呼び出し側:

@Audited(action = "USER_DELETE")
public void deleteUser(String id) { ... }

3. リトライ

spring-retry を使う方が確実ですが、学習用:

@Around("@annotation(retryable)")
public Object retry(ProceedingJoinPoint pjp, Retryable retryable) throws Throwable {
    int attempts = retryable.max();
    Throwable last = null;
    for (int i = 0; i < attempts; i++) {
        try { return pjp.proceed(); } catch (Throwable t) { last = t; }
    }
    throw last;
}

4. メトリクス収集

Micrometer と組み合わせ、@Timed 付きメソッドの実行時間を Prometheus にエクスポート。

落とし穴

1. 自クラスからの this.method() 呼び出しは効かない

Spring AOP はプロキシ経由なので、同じクラス内の別メソッドを this.foo() で呼ぶと プロキシを通らず Advice が効きません 。

@Service
public class OrderService {
    @Transactional
    public void outer() {
        this.inner(); // ← inner の @Transactional は効かない!
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { ... }
}

対策: @Autowired で自分自身のプロキシを注入、または AspectJ コンパイル時 Weaving に切り替える。

2. Pointcut が広すぎてパフォーマンス低下

execution(* ..*(..)) のような無差別 Pointcut は、起動時のマッチング時間が爆発します。 可能な限り within(package..*) で絞る のが鉄則。

3. Advice 内の例外で本来の動作が止まる

監査ログの保存に失敗しただけで業務トランザクションが落ちたら本末転倒。 Aspect 内の例外は原則 try/catch で握りつぶす (ログだけ出して本流を通す)。

4. @Around で proceed() を呼び忘れる

@Around の中で pjp.proceed() を呼ばないと、本来のメソッドが実行されません。レビューで必ずチェック。

まとめ

Spring AOP は ドメインロジックを汚さずに横断的機能を足す ための強力な道具です。ただし 「なんでも AOP で書く」 と可読性が落ちるので、ログ / 監査 / メトリクス / 権限 のような 「明らかに共通」 な関心事に限定するのが実務的。

カスタム Aspect を書く前に、Spring が標準提供している @Transactional / @Cacheable / @Async / @Scheduled / @PreAuthorize / @Validated で済まないか必ず確認を。

関連記事