본문 바로가기
Spring

[Spring-AOP]특정 api 로깅하기 - requestBody를 어떻게꺼내??

by 어떻게말이름이히힝 2025. 1. 3.

Admin 권한을 필요로 하는 API의 로그를 남기려고 한다.

1. 요청 시각

2. 요청 URL

3. 요청 사용자 ID

4. 요청 본문

5. 응답 본문

로그에 필요한 데이터는 이 5가지다.

 

다른 방법(Intercepter를 사용한다던가)이 있지만,

메소드를 감싸는 형태로 실행되게끔 @Aspect어노테이션을 사용했다.

 

(Aspect를 사용하는 Aspect-Oriented Programming은 다른 글에서 알아보도록 하자, 이곳에서는 메소드를 감싼다고 생각만 하면 충분히 로깅 기능을 구현할 수 있다.)

 

일단 첫번째로 어노테이션을 사용하려면 의존성을 추가해줘야된다.

의존성을 추가해주고 intellij IDEA 기준으로 오른쪽 코끼리 모양의 새로고침을 해주면 의존성을 다시 빌드 해준다.

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

 

 

 

로깅을 할 AdminTrace 객체의 전문은 이렇다. 추가적으로 Filter에서도 변환이 필요하다.(ContentCachingRequestWrapper를 사용하기 위해)

public class AdminTrace {

  private final Logger logger = LoggerFactory.getLogger(AdminTrace.class.getName());

  @Around("execution(public * org.example.expert.domain.comment.controller.CommentAdminController.deleteComment(..))||"
      + "execution(public * org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
  public Object adminTracelog(ProceedingJoinPoint joinPoint) throws Throwable {

    ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
    HttpServletResponse response = attributes.getResponse();
    
    logger.info("ADMIN 기능 실행");
    logger.info("요청 시각 : {}", LocalDateTime.now());
    logger.info("요청 URL : {}", request.getRequestURI());
    logger.info("요청 사용자 ID : {}", request.getAttribute("userId"));
    logger.info("요청 본문 : {} ", getRequestBody(request));
    //메서드 실행
    Object result = joinPoint.proceed();

    //실행후
   logger.info("응답 본문 : {} ", ((ResponseEntity) result).getBody());
    logger.info("실행 완료");

    return result;
  }

  private String getRequestBody(HttpServletRequest request) throws IOException {
    ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request;
    byte[] contentAsByteArray = wrapper.getContentAsByteArray();
    return new String(contentAsByteArray, wrapper.getCharacterEncoding());
  }

}

 

간단하게 요청 본문과, 응답 본문을 빼면 Filter 를 건드릴 필요도 없이 쉬워진다. 

아래 코드를 먼저 해석해보자.

 

1. LoggerFactory.getLogger는 현재 클래스(AdminTrace)와 연결된 로깅 객체를 생성한다. 

이 로거를 통해 로그 메시지를 출력하게 된다.

 

2. Around 어노테이션은 내가 감쌀걸 설정한다. 여기서는 deleteComment 기능과 changeUserRole의 기능만 작성했지만, 패키지 내의 모든 메소드에 적용할 때 등 사용할 수 있다. 이 작성하는 걸 "Pointcut표현식"이라고 하는데, 작성 방법이 굉장히 복잡하니 GPT에게 맡기도록하자. (따로 정리하면 링크를 첨부하도록 하겠다.. 쓸 때 그때그때 찾아보는 것도 방법이다.)

 

3. ProceedingJoinPoint : joinPoint는 AOP에서 메서드 실행, 예외 처리 등 특정 지점에 대상 객체와 관련된 정보를 제공하는데 이ProceedingJoinPoint는 조금 더 확장된 개념으로 proceed() 메소드를 추가로 제공한다.

- proceed() : 다음 어드바이스나 타겟 메소드 호출(여기서는 타겟 메소드가 실행됨)

- getSignature() : 호출되는 메소드의 정보를 반환

- getTarget() : 대상 객체를 반환

- getArgs() : 전달된 매개변수 목록을 반환

- toString() : 조인 포인트의 간략한 정보를 문자열로 반환

 

4. ServletRequestAttributes : RequestAttributes 의 서블릿 환경에서의 구현, 내부적으로 HttpServletRequest랑 HttpServletResponse를 래핑해서 쉽게 다룰 수 있도록 도와줌.

- getRequest() : 현재 요청 (HttpServletRequest) 객체를 반환

- getResponse() : 현재 응답(HttpServletResponse) 객체를 반환

- getSession(boolean allowCreate) : 현재 세션(HttpSession) 객체를 반환, 필요 시 새로 생성할지 여부 설정

 

RequestAttributes : Http 요청과 관련된 속성(attribute)을 관리하기 위한 인터페이스

 

5. RequestContextHolder : 현재 HTTP 요청과 관련된 정보를 접근할 수 있도록 도와줌

- getRequestAttributes() : 현재 스레드에 바인딩된 'RequestAttributes'를 반환하며, 없으면 null을 반환한다.

- currentRequestAttributes() : 현재 쓰레드에 바인딩된 'RequestAttributes'를 반환하며, 없으면 IllegalStateException 발생

- setRequestAttributes(RequestAttributes attributes) : 현재 쓰레드에 새로운 'RequestAttributes'를 설정

- restRequestAttributes() :현재 쓰레드에 저장된 요청 정보를 제거

 

6. Objects.requireNonNull : 요청정보가 없으면 NPE를 방지하기 위해 사용

 

7. logger.info() 로그 메세지 출력 {} 안에 인자로 넣은게 담겨서 출력됨

 

8. joinPoint.proceed() : 타겟 메소드가 실행됨

 

public class AdminTrace {

  private final Logger logger = LoggerFactory.getLogger(AdminTrace.class.getName());

  @Around("execution(public * org.example.expert.domain.comment.controller.CommentAdminController.deleteComment(..))||"
      + "execution(public * org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
  public Object adminTracelog(ProceedingJoinPoint joinPoint) throws Throwable {

    ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
    HttpServletResponse response = attributes.getResponse();
    
    logger.info("ADMIN 기능 실행");
    logger.info("요청 시각 : {}", LocalDateTime.now());
    logger.info("요청 URL : {}", request.getRequestURI());
    logger.info("요청 사용자 ID : {}", request.getAttribute("userId"));
    //메서드 실행
    Object result = joinPoint.proceed();

    //실행후
    logger.info("실행 완료");
    
    return result;
  }
}

 

Admin 기능인 유저의 권한을 admin 혹은 user로 변경하는 기능(changeUserRole)을 실행하게 되면 이렇게 된다.

 

 

자 이제 처음으로 돌아가서 요청 본문과 응답 본문 출력을 해줄 메소드들을 모셔왔다.

Filter에서 건드려야하는 부분은 역할을 확인하는 부분인데, 어짜피 목적은 admin 기능을 사용하는 것에 대한 로그이기 때문에 이 안에서 변환 해주었다.

 

1. ContentCachingRequestWrapper : Http 요청의 내용을 캐싱해서 여러 번 읽을 수 있도록 도와주는 건데 왜 여러번 읽어야하냐??? 일반적으로 Http 요청의 InputStream은 한번만 읽을 수 있기 때문이다.. 만약 필터에서 이 데이터를 읽어버리면 컨트롤러에서 해당 데이터를 사용하지 못할 경우가 생기니 조심해야한다! 

- getContentAsByteArray() : 캐싱된 요청 바디 데이터를 바이트 배열로 반환

- getInputStream() : 요청 바디의 InputStream을 반환하고 내부적으로 캐싱 작업을 수행

- getReader() : 요청 바디의 Reader를 반환하고 내부적으로 캐싱 작업을 수행

 

만약! 필터에서 아래 코드처럼 래핑 안해준 상태로 request를 넘겨버리면 ClassCastException이 발생하니 주의하자!

 

2. 바이트 배열으로 반환해주기때문에 사람이 읽을 수 있는 문자로 변환하는 과정을 거친다. 

if (url.startsWith("/admin")) {
        // 관리자 권한이 없는 경우 403을 반환합니다.
        if (!UserRole.ADMIN.equals(userRole)) {
          httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 없습니다.");
          return;
        }

        ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(httpRequest);
 
        chain.doFilter(requestWrapper, response);
        return;
      }
private String getRequestBody(HttpServletRequest request) throws IOException {
    ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request;
    byte[] contentAsByteArray = wrapper.getContentAsByteArray();
    return new String(contentAsByteArray, wrapper.getCharacterEncoding());
  }

 

타겟 메소드 실행 중 예외가 발생하더라도 이런식으로 구성을 하면

응답 본문과 실행 완료라는 메시지를 빼고 출력이 된다.

 

더 나아가서 생각해볼 것은 보통 프로그램에는 로그 파일이라는게 있다.

무수한 글자들과 에러코드 등이 있는 그 텍스트 파일.

파일 입출력이나 어떤 로그 시스템을 사용해서 텍스트파일에 저장되게 만들 수 있지 않을까? 라는 생각을 마지막으로 글을 마친다.