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());
}
타겟 메소드 실행 중 예외가 발생하더라도 이런식으로 구성을 하면
응답 본문과 실행 완료라는 메시지를 빼고 출력이 된다.
더 나아가서 생각해볼 것은 보통 프로그램에는 로그 파일이라는게 있다.
무수한 글자들과 에러코드 등이 있는 그 텍스트 파일.
파일 입출력이나 어떤 로그 시스템을 사용해서 텍스트파일에 저장되게 만들 수 있지 않을까? 라는 생각을 마지막으로 글을 마친다.
'Spring' 카테고리의 다른 글
RequestDto에서 @NotBlank시 Error 발생 (0) | 2025.03.27 |
---|---|
Spring Event - 최종 프로젝트 관련 내용 포함 (0) | 2025.03.27 |
[Spring] ArgumentResolver란? + 유저 정보 넘겨주기 (0) | 2025.01.17 |
[Spring] Spring Security - 필터를 Spring Security Filter로 갈아끼워보자! (0) | 2025.01.16 |