본문 바로가기

PROGRAMMING/SPRING

[SPRING] AOP를 활용한 로그 중앙화 (feat.@Aspect, @Pointcut)

문제 상황

Controller나 Scheduler가 호출하는 클래스의 메서드가 추가 될 때마다 같은 포멧의 로그코드가 같이 반복적으로 추가된다.

슬슬 한번 정리해야 할 때가 온듯하다.

 

해결 과정

1. build.gradle 추가

implementation 'org.springframework.boot:spring-boot-starter-aop'
// ip정보를 가져오려면 필요
implementation 'org.springframework.boot:spring-boot-starter-web'

 

2. LogAspect.class 생성

@Aspect
@Log4j2
@Component
public class LogAspect {
// @Pointcut

// @Around
}

 

3. 이제 2번에서 생성한 클래스에 원하는 내용을 채워주면 된다.

@Pointcut는 지정된 조건에 따라 메서드들을 선택하는 데 사용한다.

예를 들어, 특정 패키지나 클래스의 모든 메서드, 혹은 특정 이름의 메서드들에 대해 포인트컷을 설정할 수 있다.

 

@Around는

메서드 실행 전후에 코드를 실행할 수 있도록 하는 어드바이스로

메서드 호출을 가로채어, 메서드 실행 전후에 추가 작업을 수행하거나, 메서드 실행 자체를 제어할 수 있다.

 

요약하면 

@Pointcut@Around는 AOP에서 함께 사용되어 특정 메서드들에 대해 원하는 작업을 실행하기 위한 도구로 

@Pointcut어떤 메서드들에 어드바이스를 적용할지를 정의하고

@Around는 포인트컷으로 지정된 메서드들에 실제 동작을 수행한다.

 

간단한 예제로 LogAspect를 채워보면 아래와 같다.

 

package prj.blockchain.api.log;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.log4j.Log4j2;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Aspect
@Log4j2
@Component
public class LogAspect {

    @Pointcut("execution(* prj.my.api..*Controller.*(..))")
    public void controller() {
    }

    // Around advice: 지정된 Pointcut의 메서드가 호출될 때마다 실행
    @Around("controller()")
    public Object controllerLogAround(ProceedingJoinPoint joinPoint) throws Throwable {
        // IP 주소 가져오기
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String clientIp = getClientIp(request);

        // 메서드 시작 시 로그 기록
        log.info("Entering method: {}.{}() with arguments = {} from IP = {}",
                joinPoint.getSignature().getDeclaringTypeName(),
                joinPoint.getSignature().getName(),
                joinPoint.getArgs(),
                clientIp);

        long startTime = System.currentTimeMillis(); // 시작 시간 기록

        try {
            Object result = joinPoint.proceed(); // 실제 메서드 호출
            long elapsedTime = System.currentTimeMillis() - startTime; // 실행 시간 계산

            // 메서드 종료 시 로그 기록
            log.info("Method {}.{}() executed successfully in {} ms with result = {} from IP = {}",
                    joinPoint.getSignature().getDeclaringTypeName(),
                    joinPoint.getSignature().getName(),
                    elapsedTime,
                    result,
                    clientIp);
            return result;
        } catch (Throwable throwable) {
            long elapsedTime = System.currentTimeMillis() - startTime; // 실행 시간 계산

            // 예외 발생 시 로그 기록
            log.error("Exception in method: {}.{}() after {} ms with cause = {} from IP = {}",
                    joinPoint.getSignature().getDeclaringTypeName(),
                    joinPoint.getSignature().getName(),
                    elapsedTime,
                    throwable.getCause() != null ? throwable.getCause() : "NULL",
                    clientIp);
            throw throwable;
        }
    }

    // 클라이언트 IP 주소를 얻는 메서드
    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_CLIENT_IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

 

@Pointcut("execution(* prj.my.api..*Controller.*(..))")

예제의 @Pointcut은 prj.my.api 패키지와 그 하위 패키지 내에서 Controller로 끝나는 모든 클래스의 모든 메서드를 타겟으로 설정한다는 의미로 조금 더 자세히 들여다 보면 이렇다.

 

execution(*): 반환 타입을 의미하며, 모든 반환 타입(예: void, int, String 등)의 메서드를 대상으로 한다는 뜻

prj.my.api..: prj.my.api 패키지와 그 하위 모든 패키지를 포함, ..은 재귀적으로 모든 하위 패키지를 포함한다는 의미

*Controller: Controller로 끝나는 모든 클래스를 의미

.*(..): 해당 클래스의 모든 메서드를 의미

 

자세한 설정 및 설명은 공식 문서로 확인할 수 있다.

https://docs.spring.io/spring-framework/reference/core/aop/ataspectj/pointcuts.html

 

Declaring a Pointcut :: Spring Framework

During compilation, AspectJ processes pointcuts in order to optimize matching performance. Examining code and determining if each join point matches (statically or dynamically) a given pointcut is a costly process. (A dynamic match means the match cannot b

docs.spring.io

 

@Around("controller()")

예제의 @Around는 안에 정의된 pointcut이 가리키는 메서드가 실행 될 때 실행 될 로직을 정의 하면 된다.

 

하나의 로직을 여러 pointcut에 적용하고싶다면

@Around("pointcut1() || pointcut2()")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
    // 어드바이스 로직
    return joinPoint.proceed();
}

 

요런식으로도 작성 할 수 있다.

예제 코드에 있듯이 주로 로깅, 실행 시간 측정, 예외 처리 등을 수행에 활용된다.

 

Around Advice Official DOCS

https://docs.spring.io/spring-framework/reference/core/aop/ataspectj/advice.html#aop-ataspectj-around-advice

 

Declaring Advice :: Spring Framework

What happens when multiple pieces of advice all want to run at the same join point? Spring AOP follows the same precedence rules as AspectJ to determine the order of advice execution. The highest precedence advice runs first "on the way in" (so, given two

docs.spring.io