Spring AOPチュートリアル:ロギングとセキュリティのロジックをJavaのビジネスコードから分離する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

問題:サービスメソッドがやりすぎている

典型的なJavaのサービスメソッドを見てみましょう:

public class OrderService {
    public Order placeOrder(User user, Cart cart) {
        // セキュリティチェック
        if (!user.hasRole("CUSTOMER")) {
            throw new AccessDeniedException("権限がありません");
        }
        // ロギング
        logger.info("placeOrderがユーザーによって呼び出されました: " + user.getId());
        // 実際のビジネスロジック
        Order order = createOrderFromCart(cart);
        orderRepository.save(order);
        // 再度ロギング
        logger.info("注文が確定しました: " + order.getId());
        return order;
    }
}

このパターンを50個のサービスメソッドに適用すると、メンテナンスの悪夢が始まります。すべてのメソッドが権限チェックで始まり、入口と出口でログを記録し、監査のためにtry-catchでラップされます。実際のビジネスロジックは、機能とは無関係な定型コードの層に埋もれてしまいます。

AOPはまさにこの問題のために生まれました。Springの実装は、フレームワークの再設定やバイトコードの設定なしに動作します。方法を見ていきましょう。

クイックスタート:5分でAOPを動かす

ステップ1:依存関係を追加する

Spring Bootはspring-boot-starter-aopにAOPサポートをバンドルしています。pom.xmlに追加してください:

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

Gradleの場合:

implementation 'org.springframework.boot:spring-boot-starter-aop'

ステップ2:AspectJの自動プロキシを有効にする(必要な場合)

Spring Bootはこれを自動的に有効にします。通常のSpringの場合、設定クラスに追加してください:

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {
}

ステップ3:最初のアスペクトを作成する

@Aspect
@Component
public class LoggingAspect {

    private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class);

    @Before("execution(* com.example.service.*.*(..))")
    public void logMethodEntry(JoinPoint joinPoint) {
        logger.info("呼び出し中: {}", joinPoint.getSignature().getName());
    }
}

これだけです。serviceパッケージのすべてのメソッドが、エントリ時にログを記録するようになりました。サービスクラスへの変更は一切不要です。OrderServiceは純粋なビジネスロジックに戻りました。

詳細解説:コアコンセプトを理解する

横断的関心事 — 根本的な原因

ロギング、セキュリティ、キャッシュ、トランザクション管理 — これらはどれも単一の機能に属しません。コードベース全体にまたがります。オブジェクト指向設計には、繰り返しなしにこれらを取り出すためのクリーンなメカニズムがないため、必要とするすべてのクラスで定型コードとして積み重なってしまいます。

AOPは新しい抽象化を導入します:アスペクトとは、マッチングルールに基づいて既存のコードの周りで実行されるコードです。理解するために重要な3つの用語があります:

  • Join Point(結合点) — 実行中の特定の瞬間(例えばメソッド呼び出し)
  • Pointcut(ポイントカット) — どのJoin Pointをインターセプトするかを選択する述語
  • Advice(アドバイス) — それらのJoin Pointで実行されるコード(前、後、または前後)

ポイントカット式

execution(...)式はポイントカットを定義する最も一般的な方法です。構文を見てみましょう:

execution(modifiers? return-type declaring-type? method-name(params) throws?)

実践的な例:

// serviceパッケージのすべてのメソッド
"execution(* com.example.service.*.*(..))"

// publicメソッドのみ
"execution(public * com.example.service.*.*(..))"

// 'get'で始まる名前のメソッド
"execution(* get*(..))"

// すべてのサブパッケージのメソッド
"execution(* com.example..*.*(..))"

ポイントカットに名前を付けて組み合わせることもできます:

@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceLayer() {}

@Pointcut("execution(* com.example.repository.*.*(..))")
public void repositoryLayer() {}

@Before("serviceLayer() || repositoryLayer()")
public void logAll(JoinPoint joinPoint) {
    // 両方のレイヤーで実行
}

Adviceの種類

Spring AOPは5種類のAdviceを提供しており、それぞれインターセプトされたメソッドに対して異なるタイミングで実行されます:

@Aspect
@Component
public class AuditAspect {

    // メソッド実行前
    @Before("execution(* com.example.service.*.*(..))")
    public void before(JoinPoint jp) {
        System.out.println("実行前: " + jp.getSignature());
    }

    // メソッド実行後 — 結果に関わらず
    @After("execution(* com.example.service.*.*(..))")
    public void after(JoinPoint jp) {
        System.out.println("実行後: " + jp.getSignature());
    }

    // 正常に返却された場合のみ
    @AfterReturning(pointcut = "execution(* com.example.service.*.*(..))", returning = "result")
    public void afterReturning(JoinPoint jp, Object result) {
        System.out.println("戻り値: " + result);
    }

    // 例外がスローされた場合のみ
    @AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))", throwing = "ex")
    public void afterThrowing(JoinPoint jp, Exception ex) {
        System.out.println("例外 " + jp.getSignature() + ": " + ex.getMessage());
    }

    // メソッドをラップ — 実行を制御できる
    @Around("execution(* com.example.service.*.*(..))")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        Object result = pjp.proceed(); // 実際のメソッドを呼び出す
        long elapsed = System.currentTimeMillis() - start;
        System.out.println(pjp.getSignature() + " の実行時間: " + elapsed + "ms");
        return result;
    }
}

@Aroundは最も柔軟です。引数の変更、メソッドの完全なスキップ、戻り値の変更が可能です。タイミング計測、キャッシュ、リトライロジックに使用してください。

応用編:カスタムアノテーションを使ったセキュリティアスペクト

カスタムアノテーションとAOPを組み合わせることで、宣言的なメソッドレベルのセキュリティが実現できます。Spring Securityは標準的な認証フローをカバーしますが、権限ルールがドメイン固有の場合、このパターンによってサービスクラスをチェックコードから完全に解放できます。

アノテーションを定義する

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

メソッドに適用する

@Service
public class AdminService {

    @RequiresRole("ADMIN")
    public void deleteUser(Long userId) {
        // ビジネスロジックのみ — 権限チェックの定型コードなし
        userRepository.deleteById(userId);
    }
}

セキュリティアスペクトを書く

@Aspect
@Component
public class SecurityAspect {

    @Autowired
    private AuthService authService;

    @Before("@annotation(requiresRole)")
    public void checkRole(JoinPoint jp, RequiresRole requiresRole) {
        String requiredRole = requiresRole.value();
        User currentUser = authService.getCurrentUser();

        if (!currentUser.hasRole(requiredRole)) {
            throw new AccessDeniedException(
                "ユーザー " + currentUser.getId() + " に必要なロール: " + requiredRole
            );
        }
    }
}

ポイントカットとして@annotation(requiresRole)を使うと、@RequiresRoleを持つメソッドにマッチし、アノテーションインスタンスをバインドして値を読み取れます。サービスクラスはクリーンなままです。ロールロジックが変更された場合、アスペクトを一度更新するだけで完了です。

実践的なヒント:本番環境で本当に重要なこと

AOPが効果を発揮し始めるのは、同じ横断的コードを重複させたサービスメソッドが10〜20個になった頃です。その規模になると、新しいメソッドを書くたびに決断が必要になります:定型コードを繰り返すか、一元化するか。実践的な一元化の姿を見てみましょう。

AOPが効果的な場面

  • メソッドの開始・終了・実行時間のロギング
  • カスタムアノテーションによる権限・ロールチェック
  • 監査ログ — 誰がいつ何を呼び出したか
  • 一時的な障害(ネットワーク呼び出し、外部API)に対するリトライロジック
  • サービス境界での入力サニタイズ

AOPを避けるべき場面

  • 横断的な動作が1〜2箇所にしか存在しない場合 — 共有ユーティリティメソッドの方がシンプルで追跡しやすい
  • チームメンバーがAOPに不慣れな場合 — 見えないインターセプションがデバッグを混乱させる
  • 戻り値を複雑な方法で変更する必要があるビジネスルール — 代わりに明示的なサービスコンポジションを使用する

自己呼び出しの落とし穴

これは誰もが最終的にハマる落とし穴です。Spring beanの中のメソッドが同じクラスの別のメソッドを呼び出すと、AOPはインターセプトしません — 呼び出しがプロキシを完全にバイパスするためです。

// @RequiresRoleはここではチェックされない
@Service
public class OrderService {
    public void placeOrder(Cart cart) {
        processOrder(cart); // 自己呼び出し — プロキシなし、アスペクトなし
    }

    @RequiresRole("CUSTOMER")
    public void processOrder(Cart cart) { ... }
}

解決策は2つあります:@Autowired OrderService selfでサービス自身に自己注入してself.processOrder(cart)を呼び出す方法か、アノテーション付きメソッドを別のサービスクラスに移す方法です。2つ目の方がクリーンで、循環依存の問題を避けられます。

パフォーマンス

Spring AOPはインターフェースにはJDKダイナミックプロキシ、具象クラスにはCGLIBを使用します。測定されたオーバーヘッドは通常、インターセプトされた呼び出し1回あたり2〜5マイクロ秒です。ロギングやセキュリティチェックにとっては誤差の範囲です。毎秒数千のリクエストを処理するタイトなループやエンドポイントでのみ問題になります。アスペクトを軽量に保ちましょう。

整理された結果

横断的関心事を抽出した後のサービスクラスを見ると、違いはすぐに分かります。ビジネスロジックだけです。ロギングの定型コードも、権限ガードも、監査用のtry-catchブロックもありません。これらはそれぞれ独自のアスペクトファイルに、一箇所にまとめられています。

まず@Beforeでロギングから始めましょう。実行時間の計測が必要になったら@Aroundを追加します。細かい制御が必要な場合はセキュリティ用のカスタムアノテーションアスペクトを構築します。それぞれの部品は独立して機能します — 10番目に書くサービスメソッドも、アスペクトファイルに一切触れることなくすべての機能を享受できます。

Share: