Interceptor 또는 AOP를 활용하는 부분이라 나는 AOP 구현을 선택했다.
조건
AOP를 사용하여 구현하기
- 어드민 API 메서드 실행 전후에 요청/응답 데이터를 로깅합니다.
- 로깅 내용에는 다음이 포함되어야 합니다:
- 요청한 사용자의 ID
- API 요청 시각
- API 요청 URL
- 요청 본문(RequestBody)
- 응답 본문(ResponseBody)
- @Around 어노테이션을 사용하여 어드민 API 메서드 실행 전후에 요청/응답 데이터를 로깅합니다.
- 요청 본문과 응답 본문은 JSON 형식으로 기록하세요.
- 로깅은 Logger 클래스를 활용하여 기록
변경 파일 상세 설명
AccessCheckAOP
역할
- 어드민 전용 API 접근 시점에 실행되는 AOP
- 요청 정보 수집 로그로 기록
- 컨트롤러/서비스 로직과 로깅 관심사를 명확히 분리
Pointcut
@Pointcut("@annotation(org.example.expert.config.OnlyAdmin)")
public void adminApi() {}
- @OnlyAdmin 어노테이션이 붙은 메서드만 타겟
- URL, 패키지명에 의존하지 않고 선언적으로 어드민 API 식별
- 어드민 API가 추가되더라도 AOP 수정 없이 어노테이션만 부착하면 확장 가능
Around Advice (accessLogToInfo 메서드)
@Around("adminApi()")
public Object executionTime(ProceedingJoinPoint joinPoint) throws Throwable
HttpServletRequest
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
- AOP에서는 메서드 시그니처만 접근 가능하므로 HTTP 정보(userId, URI 등)는 RequestContextHolder를 통해 획득
- Filter/인증 단계의 request attribute 재사용
유저 정보
Long userId = (Long) request.getAttribute("userId");
- request attribute 에서 userId 가져옴
메타 데이터 정보
- 요청 시각 : LocalDateTime.now()
- 요청 URI : request.getRequestURI()
RequestBody
- AOP는 @RequestBody 어노테이션을 직접 인식 X -> args 배열 순회하며 RequestBody 찾음
- String,Number, AuthUser(인증 객체)제외 후 dto.request에 속한 객체가 RequestBody라고 판단
- DTO를 JSON으로 직렬화해 로그 기록 추가
OnlyAdmin
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OnlyAdmin {
}
- 어드민 전용 API를 식별하기 위한 커스텀 메서드 어노테이션
- AOP Pointcut 기준
- METHOD 레벨에서만 사용
CommentAdminController / UserAdminController
@OnlyAdmin
@DeleteMapping("/admin/comments/{commentId}")
@OnlyAdmin
@PatchMapping("/admin/users/{userId}")
- @OnlyAdmin 적용
AccessCheckAOP 전체 코드
@Aspect
@Component
@Slf4j
public class AccessCheckAOP {
@Pointcut("@annotation(org.example.expert.config.OnlyAdmin)")
public void adminApi() {}
@Around("adminApi()")
public Object accessLogToInfo(ProceedingJoinPoint joinPoint) throws Throwable {
StringBuilder logInfo = new StringBuilder();
//유저 ID, 요청 URI 정보는 시그니처에 없어서 RequestContextHolder의 getRequestAttributes 메서드를 사용
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
Long userId = (Long) request.getAttribute("userId");
logInfo.append(String.format("유저 ID : %s/ ",userId));
LocalDateTime accessTime = LocalDateTime.now();
logInfo.append(String.format("요청 시각: %s/ ",accessTime));
logInfo.append(String.format("요청 URI : %s/ ",request.getRequestURI()));
//request body 가져오기
Object[] args = joinPoint.getArgs();
for (Object arg : args) {
if (arg == null) continue;
if (arg instanceof String || arg instanceof Number) continue;
if (arg instanceof AuthUser) continue;
//이 시점에는 @RequestBody 확률 올라감
String packageName = arg.getClass().getPackageName();
if (packageName.contains("dto.request")) {
logInfo.append(String.format("Request Body : %s",getRequestBody(arg)));
}
}
log.info(logInfo.toString());
// 실제 메서드 실행 -> Filter에서 doFilter 와 비슷함.
return joinPoint.proceed();
}
public String getRequestBody(Object arg) throws IOException {
//JSON으로 직력화해서 로그로 남김
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.writeValueAsString(arg);
}
}