Admin API 접근 로그를 AOP로 남기기 (@Around + RequestBody/ResponseBody JSON 로깅)

E.NO·2026년 1월 24일

오늘은 어드민 전용 API에 접근할 때마다

  • 요청 사용자 ID
  • 요청 시각
  • 요청 URL
  • 요청 본문(RequestBody)
  • 응답 본문(ResponseBody)
    을 AOP(@Around) 로 로깅하는 기능을 구현했다.

요구사항 정리

어드민 사용자만 접근 가능한 특정 API에 접근할 때마다 로그 기록

대상 메서드:

  • org.example.expert.domain.comment.controller.CommentAdminController.deleteComment()
  • org.example.expert.domain.user.controller.UserAdminController.changeUserRole()

조건:

  • @Around로 메서드 실행 전/후 로깅
  • RequestBody / ResponseBody는 JSON 형식
  • Logger 사용

구현 코드 (최종)

package org.example.expert.common.aop;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.lang.reflect.Parameter;
import java.time.LocalDateTime;

@Aspect
@Component
@RequiredArgsConstructor
public class ApiAuditLoggingAspect {

    private static final Logger log = LoggerFactory.getLogger(ApiAuditLoggingAspect.class);

    private final ObjectMapper objectMapper;

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

        // 메서드 실행 전
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes == null ? null : attributes.getRequest();

        if (request == null) {
            log.warn("HttpServletRequest가 null 입니다.");
            return joinPoint.proceed();
        }

        String methodName = joinPoint.getSignature().getName();
        Long userId = (Long) request.getAttribute("userId");
        LocalDateTime requestTime = LocalDateTime.now();
        String requestURI = request.getRequestURI();

        Object requestBody = extractRequestBody(joinPoint);
        String requestBodyJson = safeToJson(requestBody);

        log.info("Request 정보: Method: {}, URI: {}, time: {}, userId: {}, RequestBody: {}",
                methodName, requestURI, requestTime, userId, requestBodyJson);

        // 메서드 실행
        Object result = joinPoint.proceed();

        // 메서드 실행 후
        LocalDateTime responseTime = LocalDateTime.now();
        Object responseBody = extractResponseBody(result);
        String responseBodyJson = safeToJson(responseBody);
        log.info("Response 정보: method: {}, time: {}, ResponseBody: {}",
                methodName, responseTime, responseBodyJson);

        return result;
    }

    private Object extractRequestBody(ProceedingJoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Parameter[] parameters = signature.getMethod().getParameters();
        Object[] args = joinPoint.getArgs();

        for (int i = 0; i < parameters.length; i++) {
            if (parameters[i].isAnnotationPresent(RequestBody.class)) {
                return args[i];
            }
        }
        return null;
    }

    private Object extractResponseBody(Object result) {
        if (result == null) return null; // void
        if (result instanceof ResponseEntity<?> re) return re.getBody();
        return result;
    }

    private String safeToJson(Object obj) {
        if (obj == null) return "null";
        try {
            return objectMapper.writeValueAsString(obj);
        } catch (Exception e) {
            return "\"<json-error:" + e.getClass().getSimpleName() + ">\"";
        }
    }
}

핵심 포인트 정리

1) 왜 AOP(@Around)를 썼나?

@Around는 메서드 실행 전/후를 모두 감쌀 수 있어서 요청 전 로그 + 응답 후 로그를 한 곳에서 처리하기 딱 좋다.

Object result = joinPoint.proceed();

이 코드가 실제 컨트롤러 메서드를 실행시키고, 실행 결과(응답 값)를 그대로 반환한다.

2) RequestBody는 왜 HttpServletRequest에서 읽지 않았나?

HttpServletRequest.getInputStream() 같은 걸로 Body를 다시 읽으려고 하면 이미 Spring MVC가 읽어서 DTO로 변환한 뒤라 비어있거나 예외가 날 수 있다.

그래서 joinPoint.getArgs()에서 직접 꺼내는 게 안정적이다.

다만 @PathVariable 같은 값도 args에 들어가기 때문에 정확하게 RequestBody만 추출하려면 어노테이션 기반으로 찾는다.

if (parameters[i].isAnnotationPresent(RequestBody.class)) {
    return args[i];
}

3) ResponseBody는 어디서 꺼내나?

ResponseBody는 joinPoint.proceed()의 결과 자체다.

  • void 반환이면 result == null
  • ResponseEntity<?>를 반환하면 실제 body는 re.getBody()

그래서 아래처럼 처리했다.

if (result instanceof ResponseEntity<?> re) return re.getBody();
return result;

4) JSON 직렬화 실패해도 API가 죽지 않도록 방어

로그를 찍다가 ObjectMapper.writeValueAsString()에서 예외가 터지면 AOP가 예외를 던져서 API가 실패할 수도 있다.
그래서 safeToJson()으로 감싸서 로그는 실패해도 요청 흐름은 유지되도록 했다.

느낀 점 / 개선 아이디어

  • 다음 단계로는 HTTP 상태 코드(status) 도 함께 로깅하면 더 운영 친화적이다.
  • 또, request/response에 password, token 같은 민감 정보가 있다면 마스킹 처리도 필요하다.
  • 예외 발생 시에도 로그를 남기려면 try/catch로 감싸서 EXCEPTION 로그를 추가하면 좋다.

0개의 댓글