본문 바로가기

STUDY/Spring

Spring Boot | AOP를 활용해 request마다 로그 출력하기 ( REST API )

AOP를 통해 모든 request parameter(혹은 requset body)와 response를 로그를 찍어보기로 한다.
현재 컨트롤러에서 수동으로 각각의 endpoint마다 로그를 찍는 코드가 추가되어 있는데, AOP를 이용하면 좋을 것 같았다!

의존성 추가

build.gradle에 의존성을 추가해준다.

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

Aspect작성

aop라는 패키지를 생성하고, 패키지 하위에 LoggingAspect라는 클래스를 만들었다..

@Aspect     // AOP 사용
@Component  // Bean 으로 등록 
public class LoggingAspect {

}

Pointcut

controller 패키지 하위의 모든 public 메서드와 매칭시킨다.
@annotation을 이용하면 애노테이션별로 매칭시킬 수도 있다. @PostMapping과 매칭시켜서 POST요청에만 로그를 찍는다던지..

/* controller 패키지에 포함된 public 메서드와 매칭 */
@Pointcut("within(test.rest.api.controller..*)")
public void onRequest() { }

참고로 애노테이션으로 매칭시키는 방법이다.

// POST
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")

// GET
@Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")

Advice

Advice는 실제로 실행될 내용을 적는 부분이다.

  • Logger를 매칭되어 실행될 메서드의 클래스를 통해 생성한다
  • result에는 해당 메서드의 return value가 반환된다 (ResponseEntity)
  • joinPoint.proceed()를 통해 프록시가 호출되고, 프록시가 실제 메서드를 호출한다
  • finally 블록에서 requstURI, parameters, response 로그를 찍는다
 /* Pointcut 과 매칭되는 메서드의 실행 전, 후에 실행
 *  @Around advice 는 꼭 proceed()가 필요하다. */
@Around("onRequest()")
public Object logAction(ProceedingJoinPoint joinPoint) throws Throwable{
    Class clazz = joinPoint.getTarget().getClass();
    Logger logger = LoggerFactory.getLogger(clazz);
    Object result = null;
    try {
        result = joinPoint.proceed(joinPoint.getArgs());
        return result;
    } finally {
        logger.info(getRequestUrl(joinPoint, clazz));
        logger.info("parameters" + JSON.toJSONString(params(joinPoint)));
        logger.info("response: " + JSON.toJSONString(result, true));
    }
}

getRequestUrl()

JoinPoint와 joinPoint가 실행된 class를 이용해 요청 URI를 구한다.

HttpServletRequset를 통해 구하는 방법도 있지만, 이 방법을 택했다.

  • class에 선언된 @RequestMapping의 value를 구한다 == beseURL
  • 해당 메서드에 선언된 애노테이션을 찾는다 (filter)
  • 매칭된 애노테이션을 통해 URL을 구한다
private String getRequestUrl(JoinPoint joinPoint, Class clazz) {
  MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
  Method method = methodSignature.getMethod();
  RequestMapping requestMapping = (RequestMapping) clazz.getAnnotation(RequestMapping.class);
  String baseUrl = requestMapping.value()[0];

  String url = Stream.of( GetMapping.class, PutMapping.class, PostMapping.class, 
              PatchMapping.class, DeleteMapping.class, RequestMapping.class)
              .filter(mappingClass -> method.isAnnotationPresent(mappingClass))
              .map(mappingClass -> getUrl(method, mappingClass, baseUrl))
              .findFirst().orElse(null);
  return url;
}

/* httpMETHOD + requestURI 를 반환 */
private String getUrl(Method method, Class<? extends Annotation> annotationClass, String baseUrl){
  Annotation annotation = method.getAnnotation(annotationClass);
  String[] value;
  String httpMethod = null;
  try {
      value = (String[])annotationClass.getMethod("value").invoke(annotation);
      httpMethod = (annotationClass.getSimpleName().replace("Mapping", "")).toUpperCase();
  } catch (IllegalAccessException | NoSuchMethodException | InvocationTargetException e) {
      return null;
  }
   return String.format("%s %s%s", httpMethod, baseUrl, value.length > 0 ? value[0] : "") ;
}

params

joinPoint.getArgs()는 해당 메서드의 인자값들을 반환한다. 반환 받은 값을 이용해서 파라미터 맵을 만든다.

/* printing request parameter or request body */
private Map params(JoinPoint joinPoint) {
  CodeSignature codeSignature = (CodeSignature) joinPoint.getSignature();
  String[] parameterNames = codeSignature.getParameterNames();
  Object[] args = joinPoint.getArgs();
  Map<String, Object> params = new HashMap<>();
  for (int i = 0; i < parameterNames.length; i++) {
      params.put(parameterNames[i], args[i]);
  }
  return params;
}

출력되는 모습

모든 요청마다 uri와 parameter, response를 자동으로 로그 출력한다.