카테고리 없음

[Nbcam] 심화 주차 Level5 AOP를 활용해 특정 API 로깅 구현

limitation01 2025. 12. 4. 00:03

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);
    }

}