development/spring

[Spring] Reflect + Aop 를 활용한 로그 처리

bokshiri 2024. 9. 23. 13:24

Spring에서는 Reflection을 개발자가 쉽게 사용할 수 있도록 개발용, 테스트용 ReflectionUtils을 제공한다.

본 글에서는 ReflectionUtils과 Spring Aop의 @Around Advice를 활용하여 요청과 응답 데이터에 대한 로그를 남기는 작업을 진행한다. 


개발환경

id 'java'
id 'org.springframework.boot' version '3.3.3'
id 'io.spring.dependency-management' version '1.1.6'

...

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

...

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

 

개발목표

Spring Aop의 @Around Adivce를 활용하여 @Controller 메서드 수행 전/후로 로그를 기록한다.

로그의 대상이 되는 데이터는 두 가지 방식으로 나누어 구현한다.

  • 컨트롤러의 반환 값 (객체) 전체
  • 사용자가 지정한 임의의 필드

Aspect에서는 반환된 객체의 정확한 타입을 알 수 없으니, 스프링에서 제공하는 ReflectionUtils 를 활용하여 사용자가 지정한 필드를 식별하여 로그를 기록한다.

Aop의 PointCut은 패키지 기준이 아닌 @ReflectionLogs 어노테이션을 만들어 필요한 곳에만 적용할 수 있도록 한다.

특정 필드를 식별하여 로그를 남기는 경우, 반환된 객체의 필드가 Collection인지, String을 포함한 Wrapper Class인지 구분하여 처리할 필요성이 있다. Collection 타입인 경우 배열 반복을 통해 로깅 대상 필드값 전체를 연결하여 콘솔에 출력한다.

 

Reflection을 사용하기 위해 어노테이션의 필수값으로 다음과 같은 항목이 필요하다.

 

  • depth (Integer) : 로그를 남기고자 하는 필드의 depth를 의미한다. 대상이 되는 필드가 반환 객체의 1 depth에 존재할 경우 1로, 반환 객체 안에 또 다른 객체로 감싸진 채로 존재한다면 타겟 필드를 감싸고 있는 객체의 depth만큼 1씩 증가하여 전달한다.
  • names (String[]) : Reflection을 사용하기 위해서는 구체적인 필드명을 반드시 알아야 한다. 1 depth의 필드명 부터 순차적으로 배열에 데이터를 담아준다.
  • type (Enum) : 위에서 언급한 것처럼 로그를 기록하는 방법은 두 가지이다. 한 가지는 반환된 객체 전체에 대한 로그를 남기는 것이고, 다른 하나는 반환 객체의 특정 필드만을 대상으로 하는 것이다. 어노테이션을 선언할 때 어떤 타입의 로깅을 진행할지 Enum 형태로 입력을 받는다.

 


구현

먼저 필수적으로 입력받아야 할 필드를 포함한 어노테이션을 선언한다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ReflectionLogs {

    int depth() default 1;

    String[] names() default {};

    LogType type() default LogType.ALL;


    enum LogType {
        ALL("A", "전체 대상 로그 처리"),
        PART_FIELD("PF", "특정 필드 로그 처리"),
        ;

        private final String code;
        private final String description;

        LogType(String code, String desc) {
            this.code = code;
            this.description = desc;
        }
    }
}

 

그 후 해당 어노테이션에 적용할 부가 기능을 정의해야 한다. Aspect 클래스를 추가한 후 부가 기능을 정의한다.

...

private static final String BODY = "body";

@Around("@annotation(com.example.demo.aop.annotation.ReflectionLogs)")
    public Object reflectionLogs(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[AOP Logs] Method Request ... : {}", joinPoint.getSignature().getName());

        // Target Method 수행
        Object result = joinPoint.proceed();

        // Target Method의 @ReflectionLogs Annotation 정보를 읽어온다.
        Method method = getCallMethod(joinPoint);

        if(!ObjectUtils.isEmpty(method)) {
            //Controller에서 반환된 객체는 ResponseEntity로 감싸져 있기 때문에,
            //접근하고자 하는 실제 반환 객체를 얻기 위해 Reflection으로 데이터를 꺼내온다.
            Object realObject = getFieldObject(result, BODY, true);
            ReflectionLogs annotation = method.getAnnotation(ReflectionLogs.class);
            
            int depth = annotation.depth(); // 대상 필드의 depth
            String[] names = annotation.names(); // reflection 처리를 위한 field name
            ReflectionLogs.LogType type = annotation.type(); // enum type

            // enum type을 기반으로 구현체를 찾은 후, 수행한다.
            LogStrategy strategy = findLogStrategy(type);
            strategy.process(depth, realObject, names);
        }

        log.info("[AOP Logs] Method Response ... : {}", joinPoint.getSignature().getName());

        return result;
    }

 

위에서 정의한 어노테이션이 붙은 메서드에 Aop를 적용할 수 있도록 @Around Advice를 추가하였다. 메서드의 수행 절차는 다음과 같다.

 

1. joinPoint.proceed() : 타겟 메서드를 수행하고, 반환 값을 Object 타입으로 받는다. 

2. getCallMethod(joinPoint) : joinPoint 객체를 활용하여 Proxy로 호출된 메서드의 정보를 찾는다. 

3. getFieldObject(result, BODY, true) : 반환된 객체는 컨트롤러에서 ResponseEntity 형태로 감싸져 있기 때문에, 내부의 Body 객체를 획득하기 위해 Reflection을 활용하여 ResponseEntity의 Body를 가져온다.

4. method.getAnnotation(ReflectionLogs.class) : 메서드에 선언된 어노테이션의 정보를 읽어온다. 해당 정보를 통해 Logging 타겟이 되는 필드를 찾는다.

5. findLogStrategy(type) : Enum 타입에 맞는 구현체를 찾는다. 예제 소스에서는 전체 로깅, 부분 로깅 (특정 필드) 두 가지가 있다.

6. strategy.process(depth, realObject, names) : 어노테이션의 정보와 메서드의 반환 객체를 매개변수로 받아 로깅 처리를 한다.


1. joinPoint.proceed() : 타겟 메서드를 수행하고, 반환 값을 Object 타입으로 받는다. 

  Proxy Bean에서 Target Bean의 메서드를 수행한 후 반환 값을 돌려받는 과정이다. 어노테이션은 컨트롤러의 메서드에서 사용할 것이므로 ResponseEntity 형태의 객체를 돌려받는다.

 

2. getCallMethod(joinPoint) : joinPoint 객체를 활용하여 Proxy로 호출된 메서드의 정보를 찾는다.

    /**
     * 호출된 Method 정보를 조회한다.
     * */
    private Method getCallMethod(ProceedingJoinPoint joinPoint) {

        // 타깃 메서드의 name을 추출한 후
        // 객체의 전체 메서드에서 타깃 메서드와 일치하는 것을 찾아 반환한다.
        String methodName = joinPoint.getSignature().getName();
        Class<?> targetBean = joinPoint.getTarget().getClass();
        Method[] methods = ReflectionUtils.getDeclaredMethods(targetBean);

        return Arrays.stream(methods)
                .filter(method -> methodName.equals(method.getName()))
                .findFirst()
                .orElse(null);
    }

  어노테이션이 선언된 객체의 타입을 찾은 후에 해당 클래스에 선언된 전체 메서드를 가져온다. 그 후 반복을 통해 타겟 메서드와 일치하는 Method를 찾아 반환한다.

 

3. getFieldObject(result, BODY, true) : 반환된 객체는 컨트롤러에서 ResponseEntity 형태로 감싸져 있기 때문에, 내부의 Body 객체를 획득하기 위해 Reflection을 활용하여 ResponseEntity의 Body를 가져온다.

    /**
     * 객체와 필드명을 받아 Object를 조회한다.
     * */
    private Object getFieldObject(Object data, String name, boolean superClass) {
        // ResponseEntity의 Body는 해당 객체가 아닌,
        // 상속받는 HttpEntity에 존재하므로 Field를 가져올 때 SuperClass 타입을 지정해야 한다.
        Field field = ReflectionUtils.findField(superClass ? data.getClass().getSuperclass() : data.getClass(), name);
        field.setAccessible(true);
        return ReflectionUtils.getField(field, data);
    }

반환 객체와 필드의 이름을 String 형태로 받아 Field 정보를 반환한다.

  superClass 파라미터를 둔 이유는, ResponseEntity를 사용할 경우 실제로 사용자가 반환한 객체는 Body 필드 안에 들어있기 때문이다. 해당 필드는 ResponseEntity가 아닌 부모 클래스 HttpEntity안에 존재하기 때문에 Reflection으로 접근할 때 해당 클래스가 아닌 SuperClass를 조회해야 한다. getFieldObject() 메서드는 공통으로 사용할 것이므로, boolean 타입의 파라미터를 받아 SuperClass를 조회하는 경우와 그렇지 않은 경우를 구분하여 처리한다.

 

4. method.getAnnotation(ReflectionLogs.class) : 메서드에 선언된 어노테이션의 정보를 읽어온다. 해당 정보를 통해 Logging 타겟이 되는 필드를 찾는다.

  2번 단계에서 반환받은 Method 객체를 이용하여 메서드에 선언된 ReflectionLogs 어노테이션의 정보를 읽어온다. 이 정보들을 토대로 반환객체에서 특정 필드를 찾아 로그를 남길 것이다.

 

5. findLogStrategy(type) : Enum 타입에 맞는 구현체를 찾는다. 예제 소스에서는 전체 로깅, 부분 로깅 (특정 필드) 두 가지가 있다.

@FunctionalInterface
public interface LogStrategy {

    void process(int depth, Object data, String... names);

}
    /**
     * Enum Type에 따라 구현체를 반환한다.
     *
     * ALL - 전체 로그를 기록한 후 종료한다.
     * PART_FIELD - 특정 필드를 뽑은 후 기록한다.
     * */
    private LogStrategy findLogStrategy(ReflectionLogs.LogType type) {
        return ReflectionLogs.LogType.ALL == type ? getAllStrategy() : getPartialStrategy();
    }

LogStrategy 이름의 FunctionalInterface를 정의한 후 전체 로깅 / 특정 필드 로깅 구현체를 각각 만들어준다. 

 

6. strategy.process(depth, realObject, names) : 어노테이션의 정보와 메서드의 반환 객체를 매개변수로 받아 로깅 처리를 한다.

private LogStrategy getAllStrategy() {
        return (depth, data, names) -> {
            log.info("All Type Strategy ... depth: {}, Object: {}, names: {}", depth, data, names);
            // TODO... Repository 호출 혹은 정보를 활용하여 후처리 가능..
        };
    }

    private LogStrategy getPartialStrategy() {
        return (depth, data, names) -> {
            log.info("Partial Type Strategy ... depth: {}, Object: {}, names: {}", depth, data, names);

            for(int i=0; i<depth; i++) {
                // 마지막 depth를 수행할 때 로그를 기록한다.
                if(i == depth - 1) {
                    String logs;
                    // Object Type이 일반 객체인지, Collection인지 확인한 후 로그를 기록한다.
                    if(data instanceof Collection<?> collection) {
                        logs = collection.stream().map(obj -> String.valueOf(getFieldObject(obj, names[depth-1], false)))
                                .collect(Collectors.joining(","));
                    } else {
                        logs = String.valueOf(getFieldObject(data, names[i], false));
                    }

                    log.info("Partial Type Strategy Logs... Field Data: {}", logs);
                    break;
                }
                data = getFieldObject(data, names[i], false);
            }
        };
    }

  전체를 대상으로 로그를 남기는 경우, 별도의 처리를 해주지 않고 인자로 받은 객체를 로그로 남겨준다. 단, 컨트롤러에서 사용하는 DTO에 반드시 @Tostring을 오버라이딩 해주도록 하자.

  특정 필드에 대해 로그를 남기는 경우에는 어노테이션을 사용할 때 입력 받은 값을 사용하여 Reflection으로 원하는 필드를 뽑아내야 한다. 먼저, 사용자가 등록한 depth 만큼 반복을 돌린다. 마지막 index 이전까지는 모두 타겟 필드를 감싸고 있는 객체이므로, Reflection으로 해당 객체를 조회하며 depth를 타고 내려간다. 마지막 index의 경우에는 해당 객체의 필드가 타겟이 되므로 값을 가져오기 위한 코드를 추가하였다.

  먼저 fieldName을 기반으로 객체의 타입이 Collection인지 그 외의 타입인지 구분한다. 전자인 경우에는 stream 반복을 통해 필드의 값을 Reflection으로 추출한 후 ',' 구분자를 사용하여 join한 후 로그를 남긴다. 후자인 경우엔 단일 값 필드를 가져온 후 로그를 기록한다.

 

전체 소스 Github Link

https://github.com/kwonseonwoo/aop_logs