Spring 숙련 (AOP)

KimGwangmin·2026년 9월 14일

AOP (Aspect-Oriented Programming)

  • AOP: 여러 곳에 반복되는 공통 기능을 핵심 비즈니스 로직과 분리해 적용하는 프로그래밍 방식
  • Aspect: AOP에서 공통 기능과 기능이 적용될 대상, 시점을 묶은 모듈

AOP는 개념, Aspect는 그 개념의 구현체

  • JoinPoint: 부가 기능을 끼워넣을 수 있는 실행 지점
  • Advice: JoinPoint에서 수행할 공통 작업의 내용, 실행 타이밍
    • @Before: 메서드 실행 전
    • @After: 메서드 실행 후
      • finally 블록
      • 결과 무관(예외가 발생하든 안하든 실행)
      • 반환값 접근 불가
    • @AfterReturning: 메서드가 정상 반환된 이후
      • try 블럭의 성공 지점
      • 예외 발생 시 실행 안함
      • 반환값 접근 가능
    • @AfterThrowing: 예외가 발생했을 때
    • @Around: 메서드 호출 전후 전체를 감싸서 가로챔
      • 가장 강력한 Advice
      • 첫 매개변수로 ProceedingJoinPoint 전달받음
      • 해당 매개변수는 proceed() 메서드 보유
      • joinPoint.proceed()를 직접 호출해야 실제 비즈니스 메서드 실행 (호출 안하면 원본 메서드가 아예 실행되지 않음)
  • Pointcut: 여러 JoinPoint 중 어디에 적용할지 선별
    • execution 표현식
      • 메서드 접근 제어자, 반환 타입, 패키지 경로, 메서드 명, 파라미터 조합한 패턴 매칭
    • @annotation (커스텀 어노테이션 매칭)
      • 패키지 경로 무관 특정 어노테이션이 붙은 메서드만 골라서 실행
      • @Transactional 어노테이션이 이런 방식이다.
    • @Pointcut 어노테이션
      • 동일한 포인트컷 표현식 재사용

예: 같은 코드가 메서드마다 반복될 때

서비스 메서드마다 시작 시각과 종료 시각을 재는 코드를 넣는다고 가정하자.

public List<PostResponse> findRecent(int limit) {
	// 1. 이 두 줄이
    long start = System.nanoTime();
    try {
        return postRepository.findRecent(limit).stream().map(PostResponse::from).toList();
    } finally {
		// 2. 메서드마다 반복됩니다
        log.warn("{}ms", (System.nanoTime() - start) / 1_000_000);
    }
}

long start = System.nanoTime();와 log.warn("{}ms", (System.nanoTime() - start) / 1_000_000);은 이 메서드의 본질이 아니다.

  • 이 메서드가 하는 일은 최근 게시글을 읽어오는 한 줄인데, 시간을 재는 코드가 본문의 절반을 차지하여 가독성을 해친다.
  • 시간을 재는 일은 여러 메서드에 똑같이 필요한 일인데, 모든 메서드에 이렇게 적용하기도 번거롭다.

실습

AOP 사용을 위해선 build.gradle에 다음 의존성을 추가해야 한다. (Spring Boot 4 버전 이후 기준)

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

execution 표현식 사용

Aspect 정의 코드

@Slf4j
@Aspect
@Component
public class ExecutionTimeAspect {

    // Pointcut (어디서 실행할지): execution 표현식
    // .(점 하나): 정확히 현재 패키지 아래 계층 1개
    // ..(점 두개): 현재 패키지 포함 하위 모든 계층
    // 마지막의 *.(..)는 메서드, 파라미터. 이 경우 어떤 이름의 메서드든, 몇 개의 어떤 타입의 파라미터를 받든 모두 적용한다는 의미
    @Around("execution(public * com.example.board..service..*.*(..))")
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.nanoTime();
        try {
            return joinPoint.proceed(); // JoinPoint
        } finally {
            long tookMs = (System.nanoTime() - start) / 1_000_000;
            if (tookMs >= 100) {
                log.warn("느린 호출 {} — {}ms", joinPoint.getSignature().toShortString(), tookMs);
            }
        }
    }
}

호출부

// 패키지 경로: com.example.board.service
// Aspect의 Pointcut에 포함됨
@Slf4j
@Service
public class PostService {
    public String findRecent(
            int limit
    ) {
        try {
            Thread.sleep(120); // 느린 조회를 흉내 냅니다.
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
        }
        log.info("본문 실행: findRecent({})", limit);
        return "게시글 " + limit + "건";
    }
}

해당 API를 보내면 다음과 같이 Aspect에서 정의된 로그가 발생한다.

커스텀 어노테이션 방식

어노테이션 정의

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

포인트컷 지정

@Slf4j
@Aspect
@Component
public class ExecutionTimeAspect {
	// @ExecutionTime 어노테이션이 달린 메서드만 가로채서 실행
    @Around("@annotation(ExecutionTime)")
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.nanoTime();

        try {
            return joinPoint.proceed();
        } finally {
            long tookMs = (System.nanoTime() - start) / 1_000_000;

            if (tookMs >= 100) {
                log.warn(
                    "느린 호출 {} — {}ms",
                    joinPoint.getSignature().toShortString(),
                    tookMs
                );
            }
        }
    }
}

서비스 메서드에 부착

@Service
public class BoardService {
    @ExecutionTime
    public List<Board> findAll() {
        // ...
    }
    
    @ExecutionTime
    public Board findById(Long id) {
        // ...
    }
}

AOP 작동 핵심 원리: 프록시

  • 프록시 객체는 원본 객체와 동일한 인터페이스를 구현한다. 즉 호출자 입장에선 원본과 프록시를 구분하지 못한다.
  • 프록시 객체는 원본 객체의 껍데기 역할을 하여, 원본 객체로의 요청을 중간에서 가로챈다.
  • 이를 통해 원본 객체의 코드를 전혀 건드리지 않고, 호출 앞뒤로 새로운 기능을 추가할 수 있다.

스프링 AOP의 동작 흐름

  1. 애플리케이션 실행 및 스프링 컨테이너 로딩 시, 설정된 AOP 정보를 확인
  2. Pointcut에 매칭되는 클래스에 대해, 스프링이 런타임에 자동으로 프록시 객체를 동적 생성(자동 프록시 생성기)
  3. 스프링 컨테이너가 원본 객체 대신 프록시 객체를 빈으로 등록
  4. 다른 곳에서 해당 빈을 주입받아 메서드 호출 시, 원본 객체가 아닌 프록시 객체의 메서드 호출
  5. 프록시 객체는 정의된 대로 작업을 수행
    • 사전 작업(Before, Around)
    • 원본 호출(ProceedingJoinPoint.proceed())
    • 사후 작업(After/Around)
    • 결과 반환

0개의 댓글