Spring AOPは、ログを出す処理や権限チェックなど「どこでも共通でやりたい処理」を、業務ロジックとは別にまとめて書ける仕組みです。この記事では、Spring AOPの考え方を分かりやすく説明しながら、Spring Bootでの入れ方と基本的な書き方をサンプル付きで紹介します。

Spring AOPを一言でいうと

Spring AOPは、メソッドが動く前後に「追加の処理」を差し込める仕組みです。

例えばこんな処理は、どの機能にも必要になりがちです。

  • いつ、どのメソッドが呼ばれたかをログに出す
  • 実行にかかった時間を測る
  • 権限がある人だけ通す
  • 失敗したときに共通のエラー処理をする

こういった処理を、各メソッドに毎回書くとコードが散らかります。Spring AOPを使うと、共通処理を一箇所にまとめて、必要な場所にだけ適用できます。

まず覚える用語

最初は用語が多く見えますが、意味はシンプルです。

  • Aspect
    共通処理をまとめたクラスです。ログや計測などの「まとめ役」です。
  • Pointcut
    どこに適用するかの条件です。「このパッケージのこのメソッドだけ」みたいに絞ります。
  • Advice
    実際に差し込む処理そのものです。「前に動かす」「後に動かす」などの種類があります。

Spring AOPでは、基本的に「メソッドが呼ばれるタイミング」に処理を差し込むと覚えると理解しやすいです。

Spring Bootで使えるようにする

この記事はSpring Boot 3.x(Spring Framework 6.x、Java 17以上)を前提にしています。

Spring Bootなら、依存関係を足すだけでだいたい準備完了です。

依存関係の追加

Gradleの場合です。

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-aop'
}

Mavenの場合です。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

実行時間を測ってログに出してみよう

ここでは「このメソッドは時間を測りたい」という場所にだけ、計測を付けてみます。

目印になるアノテーションを作る

このアノテーションを付けたメソッドだけを計測します。

package com.example.demo.aop;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {
}

アスペクトを書く

@Aspect を付けると「AOP用のクラスです」とSpringに伝えられます。@Around は「前後どちらにも処理を入れたい」ときによく使います。

package com.example.demo.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TimingAspect {

  private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);

  @Around("@annotation(com.example.demo.aop.Timed)")
  public Object measure(ProceedingJoinPoint pjp) throws Throwable {
    long start = System.nanoTime();

    try {
      // ここで本来のメソッドを実行します
      return pjp.proceed();
    } finally {
      long elapsedMs = (System.nanoTime() - start) / 1_000_000;
      log.info("method={} elapsedMs={}", pjp.getSignature().toShortString(), elapsedMs);
    }
  }
}

ポイントはここです。

  • @Around の中で pjp.proceed() を呼ぶと、本来のメソッドが実行される
  • proceed() の前に書けば「前の処理」、後に書けば「後の処理」になる

使いたいメソッドに付ける

package com.example.demo.service;

import com.example.demo.aop.Timed;
import org.springframework.stereotype.Service;

@Service
public class GreetingService {

  @Timed
  public String hello(String name) {
    return "Hello " + name;
  }
}

これで hello が呼ばれるたびに、実行時間がログに出ます。業務ロジック側に「計測のためのコード」を書かなくていいのが嬉しいところです。

差し込むタイミングの種類

よく使うものだけ覚えれば大丈夫です。

  • @Before
    メソッドの前に動く
  • @AfterReturning
    正常に終わった後に動く
  • @AfterThrowing
    例外で終わった後に動く
  • @After
    成功でも失敗でも最後に動く
  • @Around
    前後まとめて書ける

迷ったら @Around を選ぶことが多いです。前後の両方に書けて、戻り値もそのまま返せるからです。

例外のときだけログを出す

「失敗したときだけログを出したい」なら @AfterThrowing が読みやすいです。

@AfterThrowing(
  pointcut = "execution(* com.example.demo..*Service.*(..))",
  throwing = "ex"
)
public void logError(Exception ex) {
  log.warn("service error", ex);
}

ただし、REST APIでクライアントに返すエラーレスポンスまで整えたいなら、AOPより @RestControllerAdvice に寄せる方が素直です。スタックトレースのログ出力やtraceIDの付与まで含めた実装は Spring BootのGlobalExceptionHandlerを本番運用向けに実装する で解説しています。

どこに適用するかの書き方

大きく分けると2つです。

  • 文字のルールで場所を指定する
  • アノテーションで場所を指定する

文字のルールで指定する

例えば「Serviceで終わるクラスの全メソッド」を対象にしたいなら、次のように書けます。

@Around("execution(* com.example.demo..*Service.*(..))")
public Object aroundServices(ProceedingJoinPoint pjp) throws Throwable {
  return pjp.proceed();
}

ただし、範囲が広すぎると「思ってないところにも効く」ことがあります。最初は狭い範囲で試すのがおすすめです。

アノテーションで指定する

実務では「付けたところだけに効かせたい」ことが多いので、アノテーション指定はかなり使いやすいです。

@Around("@annotation(com.example.demo.aop.Timed)")
public Object timedOnly(ProceedingJoinPoint pjp) throws Throwable {
  return pjp.proceed();
}

Pointcutを組み合わせて絞り込む

条件は &&|| でつなげられます。よく使う指定子は、この4つを覚えておけば十分です。

  • execution
    メソッドのシグネチャで指定する。一番よく使う
  • within
    クラスやパッケージの範囲で指定する
  • @annotation
    アノテーションが付いたメソッドだけに絞る
  • bean
    Bean名で指定する。bean(*Service) のようにワイルドカードも使える

例えば「serviceパッケージの中で、かつ @Timed が付いたメソッドだけ」なら次のように書けます。

@Around("within(com.example.demo.service..*) && @annotation(com.example.demo.aop.Timed)")

同じ条件を複数のAdviceで使い回すなら、@Pointcut で名前を付けておくと読みやすくなります。

@Aspect
@Component
public class ServiceAspect {

  private static final Logger log = LoggerFactory.getLogger(ServiceAspect.class);

  @Pointcut("execution(* com.example.demo..*Service.*(..))")
  public void serviceMethods() {}

  @Before("serviceMethods()")
  public void logStart(JoinPoint jp) {
    log.info("start {}", jp.getSignature().toShortString());
  }

  @AfterThrowing(pointcut = "serviceMethods()", throwing = "ex")
  public void logError(Exception ex) {
    log.warn("service error", ex);
  }
}

条件を1か所にまとめておけば、対象を広げたり狭めたりするときに @Pointcut の中身だけ直せば済みます。

ProceedingJoinPointで引数や戻り値を扱う

@Around に渡ってくる ProceedingJoinPoint は、「本来のメソッドを実行する」以外にも使い道があります。

  • getSignature()
    クラス名やメソッド名を取り出す。ログの文言を組み立てるときに使う
  • getArgs()
    呼び出し時の引数を配列で受け取る
  • proceed(Object[] args)
    引数を差し替えて本来のメソッドを実行する

例えば、文字列の引数から前後の空白を取り除いてから本来の処理に渡す、といったことができます。

@Around("@annotation(com.example.demo.aop.Trimmed)")
public Object trimArgs(ProceedingJoinPoint pjp) throws Throwable {
  Object[] args = pjp.getArgs();
  for (int i = 0; i < args.length; i++) {
    if (args[i] instanceof String s) {
      args[i] = s.trim();
    }
  }
  return pjp.proceed(args);
}

戻り値も、proceed() の結果を加工してから返せます。ただ、引数や戻り値を書き換えるAdviceは「呼び出し側から見えない魔法」になりやすいので、使うときはチーム内で共有しておきましょう。

Spring AOPはproxyで動いている

書き方を押さえたところで、仕組みを1つだけ知っておくと、次のセクションのつまずきどころが腑に落ちます。

Spring AOPは、Aspectの対象になるBeanをそのまま登録するのではなく、そのBeanを包んだ「proxy」を代わりに登録します。ほかのBeanから呼び出すと、まずproxyが受け取り、Adviceを実行してから中身の本物のメソッドを呼びます。つまり、Adviceが動くのは「proxyを通った呼び出し」だけです。

proxyの作り方は2種類あります。

  • JDK Dynamic Proxy
    インターフェースをもとにproxyを作る。インターフェース型でしか受け取れない
  • CGLIB
    クラスを継承したサブクラスとしてproxyを作る。インターフェースがなくても使えるが、final なクラスやメソッドには効かない

Spring Bootでは spring.aop.proxy-target-class のデフォルトが true なので、特に設定しなければCGLIBが使われます。「インターフェースを切っていないServiceにもAOPが効く」のはこのためです。

AspectJとの違い

「Spring AOP」と「AspectJ」は混同されやすいですが、別物です。

  • Spring AOP
    実行時にproxyを作って差し込む。対象はSpringが管理するBeanのメソッド呼び出しだけ。追加の設定なしで使える
  • AspectJ
    コンパイル時やクラスロード時にバイトコードを書き換える(ウィービング)。Bean以外のクラスやフィールドアクセス、コンストラクタにも効く。専用のコンパイラやエージェントの設定が必要

Spring AOPは、@Aspect@Around といったAspectJのアノテーションとPointcutの記法を借りているだけで、動作原理はproxyです。spring-boot-starter-aop を入れると aspectjweaver も一緒に入りますが、これはアノテーションとPointcut式の解釈に使われているだけで、ウィービングが有効になるわけではありません。

普通のWebアプリならSpring AOPで十分です。Bean以外のクラスにも効かせたい、といった要件が出てきたときに初めてAspectJを検討すれば大丈夫です。

よくあるつまずきどころ

Spring AOPは便利ですが、仕組みの都合で引っかかりやすい点があります。

同じクラスの中で呼ぶと効かないことがある

同じクラスの中で別メソッドを呼ぶとき、AOPが効かない場合があります。

public void a() {
  this.b(); // この呼び方だと、AOPが効かないことがある
}

対策としては、次のどちらかがよく使われます。

  • メソッドを別クラスに切り出して、Bean同士で呼ぶようにする
  • AOPに頼らない設計にする

「AOPは、Springが管理しているBeanの呼び出し経由で効く」と覚えておくと、原因が見つけやすいです。

privateメソッドに付けても期待通りにならない

AOPは基本的に「外から呼ばれるメソッド」に効かせるものだと考えると安全です。サービスの入口になる public メソッドに付けるのが無難です。

トランザクションも似た仕組み

@Transactional も、内部的には同じような考え方で動いています。例外を握りつぶしたり、途中で飲み込んだりすると、想定と違う結果になることがあります。

AOPで例外ログを出すときは「例外をそのまま投げ直すのか」を意識すると事故が減ります。

AOPを使うべき場面・使わない場面

便利な仕組みですが、何でもAOPに寄せると逆に読みにくくなります。
実務では次の基準で切り分けると失敗しにくいです。

使うべき場面

  • ログ出力、計測、監査のような横断関心
  • セキュリティチェックや共通例外ハンドリング
  • 複数モジュールで同じ前処理/後処理が必要な場合

使わない方がよい場面

  • 業務ルールそのもの(例: 料金計算、在庫引当)
  • 実行順序が複雑で、明示的な呼び出しの方が読みやすい処理
  • 影響範囲を厳密に追跡したいコアドメイン処理

ドメインロジックは通常コードに、横断処理はAOPに寄せると責務分離がきれいになります。

「登録が終わったらメールを送る」のような業務フロー同士のつなぎも、AOPで見えなくするより、イベントとして明示した方が後から追いやすいです。この設計については Spring BootのApplicationEventでモジュール間を疎結合にする方法 で解説しています。

本番運用での注意点

  • Pointcutは最初から広げすぎない(誤爆を防ぐ)
  • ログ出力はPII(個人情報)を出さない
  • @Aroundで例外を握りつぶさない
  • 負荷計測用途ではログ量を制御する(サンプリング等)

特に「全Serviceに一括適用」のPointcutは便利ですが、副作用が見えにくくなるため段階的に適用範囲を広げるのがおすすめです。

まとめ

Spring AOPは、ログや計測などの共通処理を、業務ロジックから切り離して書ける仕組みです。Spring Bootならスターターを追加するだけで始められます。

まずは、カスタムアノテーションを作って「付けたところだけに効かせる」形から試すと理解しやすいです。慣れてきたら、適用範囲を少しずつ広げていくのがおすすめです。