[Advanced-10] 스프링 AOP 구현

2026. 1. 2. 21:33·Spring/Core

1. 프로젝트 생성 및 기본 설정

1.1. 스프링 부트 프로젝트 생성 방법

스프링 AOP를 학습하기 위한 프로젝트를 생성하는 과정이다. 스프링 부트 스타터 사이트(https://start.spring.io)를 사용하여 프로젝트를 생성한다.

필요한 설정값:

  • Project: Gradle Project (빌드 도구 선택)
  • Language: Java (프로그래밍 언어)
  • Spring Boot: 2.5.x (스프링 부트 버전)
  • Project Metadata:
    • Group: hello (프로젝트 그룹 ID)
    • Artifact: aop (프로젝트 이름)
    • Name: aop (프로젝트 표시 이름)
    • Package name: hello.aop (기본 패키지명)
    • Packaging: Jar (패키징 방식)
    • Java: 11 (자바 버전)
  • Dependencies: Lombok (롬복 라이브러리 추가)

중요한 점:

  • 이번 프로젝트에서는 스프링 웹 기술은 사용하지 않음
  • 스프링 프레임워크의 핵심 모듈들은 별도의 설정 없이 자동으로 추가됨
  • AOP 기능을 사용하기 위해 별도의 의존성을 추가해야 함

1.2. build.gradle 설정

plugins {
    id 'org.springframework.boot' version '2.5.5'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'hello'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'org.springframework.boot:spring-boot-starter-aop' // 직접 추가한 AOP 의존성

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // 테스트에서 lombok 사용
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
}

test {
    useJUnitPlatform()
}

1.3. 프로젝트 설정 확인

프로젝트 생성 후 기본 메인 클래스(AopApplication.main())를 실행하여 스프링 부트 실행 로그가 출력되는지 확인한다. 스프링 웹 프로젝트를 추가하지 않았기 때문에 서버는 실행되지 않지만, 정상적으로 스프링 애플리케이션이 초기화되는지 확인한다.


2. 예제 프로젝트 만들기

2.1. OrderRepository 구현

AOP를 적용할 대상이 되는 리포지토리 클래스를 구현한다.

package hello.aop.order;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

@Slf4j
@Repository
public class OrderRepository {

    public String save(String itemId) {
        log.info("[orderRepository] 실행");

        // 저장 로직
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }

        return "ok";
    }
}

클래스 설명:

  • @Slf4j: 롬복의 로깅 애노테이션, SLF4J 로거 자동 생성
  • @Repository: 스프링의 컴포넌트 스캔 대상으로 지정, DAO 역할의 빈으로 등록
  • save() 메서드: 아이템 저장 기능을 수행하는 핵심 비즈니스 로직
  • 특수한 경우("ex" 입력 시) 예외를 발생시켜 AOP의 예외 처리 기능을 테스트

2.2. OrderService 구현

비즈니스 로직을 처리하는 서비스 클래스를 구현한다.

package hello.aop.order;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class OrderService {

    private final OrderRepository orderRepository;

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

    public void orderItem(String itemId) {
        log.info("[orderService] 실행");
        orderRepository.save(itemId);
    }
}

클래스 설명:

  • @Service: 스프링의 컴포넌트 스캔 대상으로 지정, 비즈니스 로직을 처리하는 서비스 빈으로 등록
  • 생성자 주입을 통해 OrderRepository 의존성 주입
  • orderItem() 메서드: 주문 처리의 핵심 비즈니스 로직

2.3. AopTest 테스트 클래스

AOP 적용 여부와 기능을 테스트하는 클래스를 구현한다.

package hello.aop;

import hello.aop.order.OrderRepository;
import hello.aop.order.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
@SpringBootTest
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void aopInfo() {
        log.info("isAopProxy, orderService={}", AopUtils.isAopProxy(orderService));
        log.info("isAopProxy, orderRepository={}", AopUtils.isAopProxy(orderRepository));
    }

    @Test
    void success() {
        orderService.orderItem("itemA");
    }

    @Test
    void exception() {
        assertThatThrownBy(() -> orderService.orderItem("ex"))
            .isInstanceOf(IllegalStateException.class);
    }
}

테스트 설명:

  1. aopInfo() 테스트:
    • AopUtils.isAopProxy()를 사용하여 AOP 프록시가 적용되었는지 확인
    • 현재는 AOP 관련 코드를 작성하지 않았으므로 false 반환
  2. success() 테스트:
    • 정상적인 흐름으로 주문 처리
    • "itemA"를 파라미터로 전달하여 정상적으로 실행되는지 확인
  3. exception() 테스트:
    • 예외 발생 시나리오 테스트
    • "ex"를 파라미터로 전달하여 예외가 발생하는지 확인

초기 실행 결과:

[orderService] 실행
[orderRepository] 실행


3. 스프링 AOP 구현1 - 시작

3.1. 가장 기본적인 AOP 구현

@Aspect 애노테이션을 사용한 가장 기본적인 AOP 구현 방법을 살펴본다.

AspectV1 클래스:

package hello.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;

@Slf4j
@Aspect
public class AspectV1 {

    // hello.aop.order 패키지와 하위 패키지
    @Around("execution(* hello.aop.order..*(..))")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); // join point 시그니처
        return joinPoint.proceed();
    }
}

3.2. 주요 구성 요소 상세 설명

3.2.1. @Aspect 애노테이션

  • 이 클래스가 AOP를 처리하는 Aspect임을 나타냄
  • AspectJ 프레임워크에서 제공하는 애노테이션
  • 스프링 AOP에서도 동일한 애노테이션을 사용

3.2.2. @Around 애노테이션

  • 역할: 메서드 실행 전후에 실행되는 어드바이스(Advice)
  • 특징:
    • 가장 강력한 어드바이스 타입
    • 메서드 실행 전후 모두 제어 가능
    • 반환값 변경 가능
    • 예외 처리 가능

3.2.3. 포인트컷 표현식

"execution(* hello.aop.order..*(..))"
구성 요소 내용 상세 설명
execution 선언부 메서드 실행 조인 포인트를 매칭하는 포인트컷 지시자(PCD)
* (첫 번째) 반환 타입 모든 반환 타입을 허용 (public, protected 등 접근 제어자는 생략됨)
hello.aop.order.. 패키지 범위 hello.aop.order 패키지와 그 하위 패키지(..)를 포함
* (두 번째) 메서드 이름 해당 패키지 내 모든 클래스의 모든 메서드 이름에 매칭
(..) 파라미터 파라미터의 개수와 타입에 제한이 없음을 의미

3.2.4. ProceedingJoinPoint

  • 역할: 어드바이스에서 대상 메서드를 실행할 수 있는 객체
  • 주요 메서드:
    • getSignature(): 대상 메서드의 정보(시그니처)를 반환
    • proceed(): 대상 메서드를 실행하고 결과를 반환

3.3. AOP 적용하기

@Aspect 클래스는 스프링 빈으로 등록해야 AOP가 적용된다. 등록 방법에는 여러 가지가 있다.

테스트 클래스에 @Import로 등록:

package hello.aop;

import hello.aop.order.OrderRepository;
import hello.aop.order.OrderService;
import hello.aop.order.aop.AspectV1;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
@Import(AspectV1.class)  // AspectV1을 스프링 빈으로 등록
@SpringBootTest
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    // ... 테스트 메서드들
}

스프링 빈 등록 방법 비교:

방법 설명 사용 시나리오
@Import 설정 파일을 임포트할 때 사용, 빈 등록 가능 테스트 환경, 설정 분리
@Component 컴포넌트 스캔 대상으로 등록 일반적인 애플리케이션
@Bean 설정 클래스에서 직접 빈 정의 조건부 빈 등록, 복잡한 초기화

3.4. AOP 적용 결과

적용 후 테스트 실행 결과:

[log] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행

AOP 동작 방식 이해:

  1. AOP 적용 전:
    클라이언트 → OrderService.orderItem() → OrderRepository.save()
    
  2. AOP 적용 후:
    클라이언트 → [AOP 프록시] → [AspectV1.doLog()] → OrderService.orderItem() → [AspectV1.doLog()] → OrderRepository.save()
    

3.5. 스프링 AOP와 AspectJ의 관계

중요한 개념 구분:

  1. 스프링 AOP는 AspectJ의 문법을 차용:
    • @Aspect, @Around, 포인트컷 표현식 등은 AspectJ에서 가져옴
    • 하지만 구현 방식은 다름
  2. 구현 방식 차이:
    • AspectJ: 컴파일 타임, 로드 타임 위빙을 사용
    • 스프링 AOP: 프록시 패턴을 사용한 런타임 위빙
  3. 의존성 관계:
    • aspectjweaver.jar 라이브러리 필요
    • spring-boot-starter-aop에 이미 포함됨
    • 스프링은 AspectJ의 애노테이션과 인터페이스만 사용

4. 스프링 AOP 구현2 - 포인트컷 분리

4.1. 포인트컷을 별도로 분리하는 이유

기존의 AspectV1에서는 포인트컷 표현식을 @Around 애노테이션에 직접 작성했다. 이 방식에는 다음과 같은 한계가 있다:

  1. 재사용 불가능: 같은 포인트컷을 여러 어드바이스에서 사용할 수 없음
  2. 가독성 저하: 복잡한 포인트컷 표현식이 코드에 직접 포함되어 가독성이 떨어짐
  3. 유지보수 어려움: 포인트컷 변경 시 모든 어드바이스를 수정해야 함

4.2. AspectV2: 포인트컷 분리 구현

package hello.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Slf4j
@Aspect
public class AspectV2 {

    // hello.aop.order 패키지와 하위 패키지
    @Pointcut("execution(* hello.aop.order..*(..))")  // pointcut expression
    private void allOrder() {}  // pointcut signature

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }
}

4.3. @Pointcut 애노테이션 상세 설명

4.3.1. 기본 문법

@Pointcut("포인트컷_표현식")
접근제어자 반환타입 포인트컷_시그니처() {}

4.3.2. 주요 규칙

  1. 반환 타입: 반드시 void이어야 함
  2. 메서드 내용: 비워둠 (구현부가 없음)
  3. 포인트컷 시그니처: 메서드 이름과 파라미터를 합친 것
  4. 접근 제어자:
    • 내부에서만 사용: private
    • 외부에서 참조: public

4.3.3. 포인트컷 시그니처

  • allOrder(): 포인트컷을 식별하는 이름
  • 직관적인 이름을 사용하여 가독성을 높임
  • "주문과 관련된 모든 기능"이라는 의미를 담음

4.4. 어드바이스에서 포인트컷 사용 방법

// 방법 1: 포인트컷 시그니처 직접 사용
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
    // ...
}

// 방법 2: 포인트컷 표현식 직접 사용 (AspectV1 방식)
@Around("execution(* hello.aop.order..*(..))")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
    // ...
}

4.5. 포인트컷 분리의 장점

4.5.1. 재사용성 향상

@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) { /* 로깅 */ }

@Around("allOrder()")
public Object doTransaction(ProceedingJoinPoint joinPoint) { /* 트랜잭션 */ }
  • 하나의 포인트컷을 여러 어드바이스에서 사용 가능

4.5.2. 가독성 향상

  • 복잡한 표현식이 메서드 시그니처로 대체됨
  • 코드의 의도가 명확해짐

4.5.3. 유지 보수성 향상

  • 포인트컷 변경 시 한 곳만 수정하면 됨
  • 오류 가능성 감소

4.5.4. 외부 참조 가능

  • 다른 클래스의 포인트컷도 참조 가능

4.6. 테스트 적용

AopTest 수정:

// @Import(AspectV1.class)  // 주석 처리
@Import(AspectV2.class)      // AspectV2 적용
@SpringBootTest
public class AopTest {
    // ... 테스트 코드
}

실행 결과:

AspectV1과 동일한 결과를 보이지만, 내부적으로는 포인트컷이 분리되어 관리되고 있음

[log] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행


5. 스프링 AOP 구현3 - 어드바이스 추가

5.1. 복잡한 AOP 시나리오 구현

실제 프로젝트에서는 단순한 로깅 외에도 다양한 횡단 관심사를 처리해야 한다. 트랜잭션 관리를 예로 들어 여러 개의 어드바이스를 조합하는 방법을 알아본다.

5.2. AspectV3: 트랜잭션 관리 추가

package hello.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Slf4j
@Aspect
public class AspectV3 {

    // 포인트컷 1: hello.aop.order 패키지와 하위 패키지
    @Pointcut("execution(* hello.aop.order..*(..))")
    public void allOrder() {}

    // 포인트컷 2: 클래스 이름 패턴이 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    private void allService() {}

    // 어드바이스 1: 로깅 (모든 주문 관련 메서드에 적용)
    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }

    // 어드바이스 2: 트랜잭션 (주문 관련 + 서비스 클래스에 적용)
    @Around("allOrder() && allService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e) {
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
        }
    }
}

5.3. 포인트컷 표현식 상세 분석

5.3.1. allOrder() 포인트컷

  • execution(* hello.aop.order..*(..))
  • 적용 대상: hello.aop.order 패키지와 모든 하위 패키지의 모든 메서드
  • 예시 적용 클래스: OrderService, OrderRepository

5.3.2. allService() 포인트컷

  • execution(* *..*Service.*(..))
  • 구성 요소 분석:
    • : 모든 반환 타입
    • ..*Service: 모든 패키지에서 이름이 Service로 끝나는 클래스
    • .*(..): 모든 메서드
  • 예시 적용 클래스: OrderService
  • 비적용 클래스: OrderRepository

5.3.3. 포인트컷 조합 연산자

연산자 설명 예시
&& (AND) 두 포인트컷 모두 만족 allOrder() && allService()
`   ` (OR)
! (NOT) 포인트컷을 만족하지 않음 !allService()

5.4. 트랜잭션 어드바이스 동작 방식

트랜잭션의 일반적인 흐름:

  1. 트랜잭션 시작
  2. 비즈니스 로직 실행
  3. 성공 시: 트랜잭션 커밋
  4. 실패 시: 트랜잭션 롤백
  5. 항상 실행: 리소스 정리

어드바이스 구현 패턴:

try {
    // 트랜잭션 시작
    Object result = joinPoint.proceed();  // 비즈니스 로직 실행
    // 트랜잭션 커밋
    return result;
} catch (Exception e) {
    // 트랜잭션 롤백
    throw e;
} finally {
    // 리소스 정리
}

5.5. AOP 적용 대상 분석

클래스 doLog() 적용 doTransaction() 적용 이유

OrderService ✅ ✅ allOrder() && allService() 만족
OrderRepository ✅ ❌ allOrder()는 만족 but allService()는 만족하지 않음

5.6. 테스트 실행 결과

AopTest 수정:

// @Import(AspectV1.class)
// @Import(AspectV2.class)
@Import(AspectV3.class)  // AspectV3 적용
@SpringBootTest
public class AopTest {
    // ... 테스트 코드
}

정상 실행 결과 (success() 테스트):

[log] void hello.aop.order.OrderService.orderItem(String)
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

예외 발생 결과 (exception() 테스트):

[log] void hello.aop.order.OrderService.orderItem(String)
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 롤백] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

5.7. AOP 적용 전후 비교

AOP 적용 전:

클라이언트 → orderService.orderItem() → orderRepository.save()

AOP 적용 후:

클라이언트 → [doLog() → doTransaction()] → orderService.orderItem() → [doLog()] → orderRepository.save()

실행 흐름 분석:

  1. OrderService.orderItem() 호출 시:
    • doLog() 실행 (포인트컷: allOrder())
    • doTransaction() 실행 (포인트컷: allOrder() && allService())
    • 실제 orderItem() 메서드 실행
    • orderRepository.save() 호출
  2. OrderRepository.save() 호출 시:
    • doLog() 실행 (포인트컷: allOrder())
    • 실제 save() 메서드 실행
    • doTransaction()는 적용되지 않음 (allService() 조건 불만족)

5.8. 어드바이스 실행 순서 문제

현재 구현에서는 로깅 어드바이스가 트랜잭션 어드바이스보다 먼저 실행된다. 이 순서는 AOP 프록시 생성 시점이나 JVM 환경에 따라 달라질 수 있다. 일관된 순서를 보장하려면 명시적인 순서 지정이 필요하다.


6. 스프링 AOP 구현4 - 포인트컷 참조

6.1. 포인트컷의 재사용성 확장

여러 Aspect 클래스에서 동일한 포인트컷을 사용해야 하는 경우가 많다. 이때 포인트컷을 별도의 클래스로 분리하여 공용으로 사용할 수 있다.

6.2. Pointcuts 클래스: 포인트컷 집합

package hello.aop.order.aop;

import org.aspectj.lang.annotation.Pointcut;

public class Pointcuts {

    // 포인트컷 1: hello.aop.order 패키지와 하위 패키지
    @Pointcut("execution(* hello.aop.order..*(..))")
    public void allOrder() {}

    // 포인트컷 2: 타입 패턴이 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    public void allService() {}

    // 포인트컷 3: allOrder와 allService의 조합
    @Pointcut("allOrder() && allService()")
    public void orderAndService() {}
}

6.3. 외부 포인트컷 클래스 설계 원칙

  1. 공용 포인트컷 집합:
    • 관련된 포인트컷들을 한 곳에 모아 관리
    • 이름 규칙을 통일하여 일관성 유지
  2. 접근 제어자:
    • 다른 클래스에서 참조해야 하므로 public으로 선언
    • 내부에서만 사용하는 포인트컷은 private으로 선언 가능
  3. 포인트컷 조합:
    • 기본 포인트컷을 조합하여 새로운 포인트컷 생성
    • orderAndService()는 allOrder()와 allService()의 AND 조합

6.4. AspectV4Pointcut: 외부 포인트컷 사용

package hello.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;

@Slf4j
@Aspect
public class AspectV4Pointcut {

    // 외부 포인트컷 참조: 패키지명.클래스명.포인트컷시그니처()
    @Around("hello.aop.order.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }

    @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e) {
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
        }
    }
}

6.5. 외부 포인트컷 참조 문법

완전한 참조 형식:

@Around("패키지명.클래스명.포인트컷시그니처()")

예시:

// 정확한 패키지 경로 지정
@Around("hello.aop.order.aop.Pointcuts.allOrder()")

// 같은 패키지 내에 있을 경우 클래스명만으로 참조 가능
@Around("Pointcuts.allOrder()")

6.6. 외부 포인트컷 사용의 장점

  1. 중앙화된 관리:
    • 모든 포인트컷을 한 곳에서 관리
    • 변경이 필요할 때 한 곳만 수정하면 됨
  2. 일관성 유지:
    • 프로젝트 전체에서 동일한 포인트컷 정의 사용
    • 다른 개발자도 일관된 방식으로 포인트컷 참조
  3. 재사용성 극대화:
    • 여러 Aspect 클래스에서 동일한 포인트컷 공유
    • 중복 코드 제거
  4. 가독성 향상:
    • Aspect 클래스는 비즈니스 로직(어드바이스)에 집중
    • 포인트컷 정의는 별도 클래스로 분리

6.7. 테스트 적용

AopTest 수정:

// @Import(AspectV1.class)
// @Import(AspectV2.class)
// @Import(AspectV3.class)
@Import(AspectV4Pointcut.class)  // AspectV4Pointcut 적용
@SpringBootTest
public class AopTest {
    // ... 테스트 코드
}

실행 결과:

AspectV3와 동일한 결과를 보이지만, 포인트컷은 외부 클래스에서 관리됨

[log] void hello.aop.order.OrderService.orderItem(String)
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

6.8. 실무 적용 팁

6.8.1. 포인트컷 분류 체계:

// 도메인별 포인트컷 분리
public class OrderPointcuts { /* 주문 도메인 포인트컷 */ }
public class MemberPointcuts { /* 회원 도메인 포인트컷 */ }
public class CommonPointcuts { /* 공통 포인트컷 */ }

6.8.2. 포인트컷 네이밍 규칙

  • 도메인명 + 대상: allOrder(), orderServiceMethods()
  • 조합 포인트컷: orderAndService(), orderButNotRepository()

6.8.3. 포인트컷 문서화

/**
 * 주문 도메인의 모든 메서드를 대상으로 하는 포인트컷
 * - 적용 대상: hello.aop.order 패키지와 하위 패키지
 * - 예외: none
 */
@Pointcut("execution(* hello.aop.order..*(..))")
public void allOrder() {}

7. 스프링 AOP 구현5 - 어드바이스 순서

7.1. 어드바이스 순서 제어의 필요성

AOP에서 여러 어드바이스가 적용될 때 실행 순서가 중요한 경우가 많다. 예를 들어:

  1. 트랜잭션 vs 로깅: 트랜잭션 내부의 실행 시간만 측정하고 싶다면 트랜잭션 시작 후 로깅이 실행되어야 함
  2. 보안 vs 로깅: 보안 검증 실패 시 로깅이 먼저 실행되어야 디버깅에 도움됨
  3. 캐싱 vs 검증: 캐싱 전에 입력값 검증이 먼저 실행되어야 함

7.2. 순서 제어의 문제점

기본적으로 스프링 AOP는 어드바이스 실행 순서를 보장하지 않는다. 순서는 다음과 같은 요인에 의해 결정될 수 있다:

  1. AOP 프록시 생성 시점
  2. 빈 등록 순서
  3. JVM 실행 환경
  4. AspectJ 버전

7.3. @Order 애노테이션을 이용한 순서 제어

스프링은 @Order 애노테이션을 통해 어드바이스 실행 순서를 제어할 수 있다. 중요한 점은 @Order가 클래스 수준에서 적용된다는 것이다.

@Order 애노테이션 규칙:

  • 숫자가 작을수록 높은 우선순위 (먼저 실행)
  • 동일한 @Aspect 클래스 내의 어드바이스는 순서를 지정할 수 없음
  • 순서를 지정하려면 Aspect를 별도의 클래스로 분리해야 함

7.4. AspectV5Order: 순서 제어 구현

package hello.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;

@Slf4j
public class AspectV5Order {

    // Aspect 1: 로깅 (낮은 우선순위)
    @Aspect
    @Order(2)  // 숫자가 클수록 나중에 실행
    public static class LogAspect {

        @Around("hello.aop.order.aop.Pointcuts.allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[log] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }
    }

    // Aspect 2: 트랜잭션 (높은 우선순위)
    @Aspect
    @Order(1)  // 숫자가 작을수록 먼저 실행
    public static class TxAspect {

        @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
        public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
            try {
                log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
                Object result = joinPoint.proceed();
                log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
                return result;
            } catch (Exception e) {
                log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
                throw e;
            } finally {
                log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
            }
        }
    }
}

7.5. 정적 내부 클래스 사용 이유

  1. 단일 파일 관리: 관련 Aspect들을 한 파일에서 관리
  2. 네임스페이스 격리: LogAspect, TxAspect로 명확히 구분
  3. 의존성 명시: 두 Aspect가 밀접하게 관련되어 있음을 표현

7.6. 순서 제어 메커니즘

실행 순서 결정 과정:

  1. 스프링은 모든 @Aspect 빈을 수집
  2. @Order 값으로 정렬
  3. 숫자가 작은 순서대로 어드바이스 체인에 추가
  4. 어드바이스 체인 순서대로 실행

예상 실행 흐름:

TxAspect.doTransaction() 시작 (Order 1)
  → LogAspect.doLog() 시작 (Order 2)
    → 실제 비즈니스 메서드 실행
  ← LogAspect.doLog() 종료
← TxAspect.doTransaction() 종료

7.7. 테스트 적용

AopTest 수정:

// @Import(AspectV4Pointcut.class)
@Import({AspectV5Order.LogAspect.class, AspectV5Order.TxAspect.class})
@SpringBootTest
public class AopTest {
    // ... 테스트 코드
}

중요: 두 개의 Aspect를 모두 import해야 함

실행 결과:

[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[log] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

순서 변화 관찰:

  • 이전: [log] → [트랜잭션 시작]
  • 이후: [트랜잭션 시작] → [log]

7.8. 실무 적용 시 고려사항

7.8.1. 순서 값 관리 전략

public interface AspectOrder {
    int TRANSACTION = 1;
    int LOGGING = 2;
    int SECURITY = 3;
    int CACHING = 4;
}

@Aspect
@Order(AspectOrder.TRANSACTION)
public class TransactionAspect { /* ... */ }

7.8.2. 복잡한 순서 제어

  • 3개 이상의 Aspect 간 순서 제어
  • 조건부 순서 변경
  • 동일 순위 Aspect 처리

7.8.3. 순서 관련 문제 해결

  • 순서가 보장되지 않는 경우 디버깅 방법
  • @Order 무시되는 경우 확인 사항

7.9. 주의사항

7.9.1. 동일 Aspect 내 순서 제어 불가

@Aspect
@Order(1)  // 클래스 수준에서만 적용됨
public class MyAspect {

    @Around("pointcut1()")  // 순서 지정 불가
    public Object advice1() { /* ... */ }

    @Around("pointcut2()")  // 순서 지정 불가
    public Object advice2() { /* ... */ }
}

7.9.2. 빈 등록 방식 영향

  • @Component vs @Bean vs @Import
  • 빈 등록 방식에 따라 순서가 달라질 수 있음

7.9.3. 프록시 타입 영향

  • JDK 동적 프록시 vs CGLIB 프록시
  • 프록시 생성 방식에 따라 순서 처리 방식이 다름

  1.  

8. 스프링 AOP 구현6 - 어드바이스 종류

8.1. 다양한 어드바이스 타입 소개

스프링 AOP는 다양한 상황에 맞춰 사용할 수 있는 5가지 어드바이스 타입을 제공한다. 각 어드바이스는 특정 시점에 실행되며, 목적에 맞게 선택하여 사용할 수 있다.

어드바이스 종류 요약:

어드바이스 실행 시점  주요 특징
@Around 메서드 호출 전후 가장 강력함, 모든 제어 가능
@Before 메서드 호출 전 간단한 사전 처리
@AfterReturning 메서드 정상 반환 후 반환값 활용 가능
@AfterThrowing 메서드 예외 발생 시 예외 정보 활용 가능
@After 메서드 종료 후 (finally) 성공/실패 관계없이 실행

8.2. AspectV6Advice: 모든 어드바이스 타입 구현

package hello.aop.order.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;

@Slf4j
@Aspect
public class AspectV6Advice {

    // 1. @Around: 가장 강력한 어드바이스
    @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            // @Before에 해당
            log.info("[around][트랜잭션 시작] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            // @AfterReturning에 해당
            log.info("[around][트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e) {
            // @AfterThrowing에 해당
            log.info("[around][트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            // @After에 해당
            log.info("[around][리소스 릴리즈] {}", joinPoint.getSignature());
        }
    }

    // 2. @Before: 메서드 실행 전
    @Before("hello.aop.order.aop.Pointcuts.orderAndService()")
    public void doBefore(JoinPoint joinPoint) {
        log.info("[before] {}", joinPoint.getSignature());
    }

    // 3. @AfterReturning: 메서드 정상 반환 후
    @AfterReturning(
        value = "hello.aop.order.aop.Pointcuts.orderAndService()",
        returning = "result"
    )
    public void doReturn(JoinPoint joinPoint, Object result) {
        log.info("[return] {} return={}", joinPoint.getSignature(), result);
    }

    // 4. @AfterThrowing: 메서드 예외 발생 시
    @AfterThrowing(
        value = "hello.aop.order.aop.Pointcuts.orderAndService()",
        throwing = "ex"
    )
    public void doThrowing(JoinPoint joinPoint, Exception ex) {
        log.info("[ex] {} message={}", joinPoint.getSignature(), ex.getMessage());
    }

    // 5. @After: 메서드 종료 후 (finally 블록과 유사)
    @After("hello.aop.order.aop.Pointcuts.orderAndService()")
    public void doAfter(JoinPoint joinPoint) {
        log.info("[after] {}", joinPoint.getSignature());
    }
}

8.3. JoinPoint 인터페이스 상세

모든 어드바이스는 JoinPoint(또는 ProceedingJoinPoint)를 첫 번째 파라미터로 받을 수 있다.

JoinPoint 주요 메서드:

메서드 반환 타입 설명
getArgs() Object[] 메서드 인수 배열
getThis() Object 프록시 객체
getTarget() Object 대상 객체 (실제 비즈니스 객체)
getSignature() Signature 조인 포인트 시그니처 (메서드 정보)
toString() String 조인 포인트 설명 문자열

ProceedingJoinPoint 추가 메서드:

  • proceed(): 다음 어드바이스나 타겟 메서드 호출
  • proceed(Object[] args): 인수를 변경하여 호출

8.4. 각 어드바이스 타입 상세 설명

8.4.1. @Around

  • 가장 강력한 어드바이스
  • 특징:
    • 메서드 실행 전후 모두 제어 가능
    • 조인 포인트 실행 여부 결정 가능 (proceed() 호출 여부)
    • 반환값 변경 가능
    • 예외 변환 가능
    • 트랜잭션처럼 복잡한 흐름 제어 가능
  • 주의사항:
    • 반드시 ProceedingJoinPoint를 파라미터로 받아야 함
    • proceed()를 호출하지 않으면 대상 메서드가 실행되지 않음
    • 여러 번 proceed() 호출 가능 (재시도 로직 구현 시 유용)

8.4.2. @Before

  • 메서드 실행 전에 실행
  • 특징:
    • 간단한 사전 검증, 로깅, 인증 등에 적합
    • JoinPoint만 파라미터로 받음
    • 대상 메서드 실행을 막을 수 없음
    • 예외 발생 시 대상 메서드 실행되지 않음
  • 사용 예시:
    @Before("execution(* com.example.service.*.*(..))")
    public void validateArguments(JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        // 인수 검증 로직
    }
    

8.4.3. @AfterReturning

  • 메서드가 정상적으로 반환된 후 실행
  • 특징:
    • returning 속성으로 반환값 받을 수 있음
    • 반환값은 조작할 수 없지만 확인은 가능
    • 파라미터 이름과 returning 값이 일치해야 함
  • 사용 예시:
    @AfterReturning(
        pointcut = "execution(* com.example.service.*.*(..))",
        returning = "result"
    )
    public void logReturnValue(JoinPoint joinPoint, Object result) {
        // 반환값 로깅
    }
    

8.4.4. @AfterThrowing

  • 메서드가 예외를 던질 때 실행
  • 특징:
    • throwing 속성으로 예외 객체 받을 수 있음
    • 특정 예외 타입만 처리하도록 제한 가능
    • 예외 처리 후 다시 예외를 던질 수 있음
  • 사용 예시:
    @AfterThrowing(
        pointcut = "execution(* com.example.service.*.*(..))",
        throwing = "ex"
    )
    public void handleException(JoinPoint joinPoint, RuntimeException ex) {
        // 예외 처리 로직
    }
    

8.4.5. @After

  • 메서드 종료 후 항상 실행 (finally 블록과 유사)
  • 특징:
    • 성공/실패 여부와 관계없이 실행
    • 리소스 정리, 로깅 등에 적합
    • JoinPoint만 파라미터로 받음
  • 사용 예시:
    @After("execution(* com.example.service.*.*(..))")
    public void cleanupResources(JoinPoint joinPoint) {
        // 리소스 정리 로직
    }
    

8.5. 어드바이스 실행 순서 규칙

스프링 5.2.7 버전부터 동일한 @Aspect 내에서의 어드바이스 실행 순서가 명시적으로 정의되었다.

실행 순서:

  1. @Around (진입)
  2. @Before
  3. 실제 메서드 실행
  4. @After
  5. @AfterReturning 또는 @AfterThrowing
  6. @Around (종료)

중요: 같은 종류의 어드바이스가 2개 이상 있으면 순서가 보장되지 않음. 이 경우 Aspect를 분리하고 @Order를 사용해야 함.

8.6. 테스트 적용

AopTest 수정:

// @Import({AspectV5Order.LogAspect.class, AspectV5Order.TxAspect.class})
@Import(AspectV6Advice.class)
@SpringBootTest
public class AopTest {
    // ... 테스트 코드
}

실행 결과:

[around][트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[before] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[orderRepository] 실행
[return] void hello.aop.order.OrderService.orderItem(String) return=null
[after] void hello.aop.order.OrderService.orderItem(String)
[around][트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[around][리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)

 

8.7. @Around 외 어드바이스가 존재하는 이유

@Around 하나로 모든 기능을 구현할 수 있음에도 다른 어드바이스 타입들이 존재하는 이유는 다음과 같다:

8.7.1. 실수 방지

// 잘못된 @Around 사용 예시
@Around("pointcut()")
public void doBefore(ProceedingJoinPoint joinPoint) {
    log.info("[before] {}", joinPoint.getSignature());
    // proceed() 호출을 잊어버림 → 대상 메서드 실행되지 않음!
}

// 올바른 @Before 사용
@Before("pointcut()")
public void doBefore(JoinPoint joinPoint) {
    log.info("[before] {}", joinPoint.getSignature());
    // proceed() 호출 필요 없음 → 실수 방지
}

8.7.2. 의도 명확화

  • @Before: "이 코드는 메서드 실행 전에만 작동한다"
  • @AfterReturning: "이 코드는 정상 반환 시에만 작동한다"
  • 코드만 보고도 개발자의 의도를 파악할 수 있음

8.7.3. 제약을 통한 안전성

  • 제약은 실수를 미연에 방지하는 가이드 역할
  • 적절한 제약은 코드의 안전성을 높임

8.7.4. 최적의 도구 선택 원칙

  • @Around: 복잡한 제어 흐름이 필요할 때
  • @Before/@After: 단순한 사전/사후 처리
  • @AfterReturning: 반환값 확인이 필요할 때
  • @AfterThrowing: 예외 처리 로직이 필요할 때

8.8. 실무 적용 가이드라인

  1. 어드바이스 선택 기준:
    • 가능한 한 특화된 어드바이스 사용 권장
    • @Around는 정말 필요할 때만 사용
    • 코드 의도를 명확히 표현하는 어드바이스 선택
  2. 에러 처리 패턴:
    @Aspect
    public class ErrorHandlingAspect {
    
        @AfterThrowing(
            pointcut = "execution(* com.example..*(..))",
            throwing = "ex"
        )
        public void handleServiceException(JoinPoint joinPoint, ServiceException ex) {
            // 서비스 예외 처리
        }
    
        @AfterThrowing(
            pointcut = "execution(* com.example..*(..))",
            throwing = "ex"
        )
        public void handleSystemException(JoinPoint joinPoint, SystemException ex) {
            // 시스템 예외 처리
        }
    }
    
  3. 성능 모니터링 패턴:
    @Aspect
    public class PerformanceAspect {
    
        @Around("execution(* com.example.service.*.*(..))")
        public Object measurePerformance(ProceedingJoinPoint joinPoint) throws Throwable {
            long startTime = System.currentTimeMillis();
    
            try {
                return joinPoint.proceed();
            } finally {
                long endTime = System.currentTimeMillis();
                log.info("{} 실행 시간: {}ms",
                    joinPoint.getSignature(),
                    endTime - startTime
                );
            }
        }
    }
    

9. 정리

9.1. 스프링 AOP 구현 핵심 포인트

  1. @Aspect 기반 구현: 스프링 AOP의 표준 구현 방식
  2. 포인트컷 분리: @Pointcut을 이용한 재사용성 향상
  3. 어드바이스 다양성: 목적에 맞는 적절한 어드바이스 선택
  4. 순서 제어: @Order를 통한 실행 순서 관리
  5. 외부 참조: 공용 포인트컷 클래스를 통한 중앙화 관리

9.2. 각 구현 단계별 학습 내용

구현 단계 주요 학습 내용 실무 적용 포인트
AspectV1 기본 AOP 구현, @Around 사용 단순 로깅, 기본 AOP 패턴
AspectV2 포인트컷 분리, @Pointcut 사용 코드 재사용성, 가독성 향상
AspectV3 다중 어드바이스, 포인트컷 조합 트랜잭션, 보안 등 복합 AOP
AspectV4 외부 포인트컷 참조 중앙화된 포인트컷 관리
AspectV5 어드바이스 순서 제어 실행 순서가 중요한 AOP
AspectV6 다양한 어드바이스 타입 목적에 맞는 어드바이스 선택

9.3. 실무 적용 시 권장 사항

  1. 코드 가독성 우선:
    • 명확한 포인트컷 시그니처 사용
    • 적절한 어드바이스 타입 선택
    • 과도한 @Around 사용 지양
  2. 재사용성 고려:
    • 공용 포인트컷 클래스 활용
    • 도메인별 Aspect 분리
    • 설정을 통한 AOP 활성화 제어
  3. 성능 영향 최소화:
    • 불필요한 포인트컷 지양
    • 어드바이스 내 무거운 작업 지양
    • 필요한 경우에만 AOP 적용
  4. 테스트 용이성:
    • 단위 테스트에서 AOP 비활성화 옵션 제공
    • AOP 동작을 검증하는 테스트 작성
    • 모의 객체(Mock)를 이용한 테스트

9.4. 다음 단계 학습 주제

  1. AOP 고급 기능:
    • 어드바이스 파라미터 바인딩
    • @DeclareParents를 이용한 인터페이스 도입
    • @Aspect 상속과 재사용
  2. 성능 최적화:
    • AOP 프록시 생성 비용 최적화
    • 런타임 성능 영향 분석
    • 컴파일 타임 위빙 고려
  3. 실무 패턴:
    • 트랜잭션 관리 AOP 패턴
    • 캐싱 AOP 패턴
    • 재시도(retry) AOP 패턴
    • 회로 차단기(circuit breaker) AOP 패턴
  4. 모니터링과 디버깅:
    • AOP 적용 여부 확인 방법
    • 실행 순서 디버깅
    • 성능 영향 모니터링

9.5. 마무리

 스프링 AOP는 애플리케이션의 다양한 횡단 관심사를 효과적으로 처리할 수 있는 강력한 도구이다. 적절히 활용하면 코드의 모듈성을 높이고, 유지보수성을 향상시키며, 반복적인 코드를 제거할 수 있다. 그러나 과도한 사용은 성능 저하와 디버깅 어려움을 초래할 수 있으므로, 항상 필요한 경우에만 적절한 수준으로 적용하는 것이 중요하다.

 

 이번 학습을 통해 스프링 AOP의 기본 개념부터 실무 적용 패턴까지 체계적으로 이해할 수 있었을 것이다. 실제 프로젝트에 적용할 때는 단계적으로 도입하고, 효과를 측정하며, 팀의 코딩 표준과 일치시키는 것이 성공적인 AOP 적용의 핵심이다.

'Spring > Core' 카테고리의 다른 글

[Advanced-12] 스프링 AOP - 실전 예제  (0) 2026.01.02
[Advanced-11] 스프링 AOP - 포인트 컷  (0) 2026.01.02
[Advanced-9] 스프링 AOP 개념  (0) 2026.01.02
[Advanced-8] @Aspect AOP  (0) 2026.01.02
[Advanced-7] 빈 후처리기  (0) 2026.01.02
'Spring/Core' 카테고리의 다른 글
  • [Advanced-12] 스프링 AOP - 실전 예제
  • [Advanced-11] 스프링 AOP - 포인트 컷
  • [Advanced-9] 스프링 AOP 개념
  • [Advanced-8] @Aspect AOP
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Advanced-10] 스프링 AOP 구현
상단으로

티스토리툴바