스프링 인터셉터(Spring Interceptor)로 API로그 DB에 저장하기
왜 적용했는가?
기존 AOP를 통해 모든 API 호출에 대한 로깅을 진행하였다.
그런데 PR 리뷰를 받던 중, 다음과 같은 피드백을 받게 되었다.
1. 맞지 않는 URL로 요청을 보내는 경우
2. Http Method를 이상하게 보내는 경우
아예 Pointcut으로 지정한 컨트롤러 메서드를 타지 않기 때문에 로깅이 전혀 불가능하다.
또한 치명적인 문제는, 컨트롤러에서 @Valid 어노테이션 조건을 걸어놓은 경우 AOP가 작동하지 않는다. (Valid 여부가 컨트롤러 호출보다 먼저 실행)
따라서 본 프로젝트에는 AOP에서 Interceptor로 로깅 요청을 변경하였다.
Interceptor로 변경하기
프로젝트의 log 도메인이다.
LoggingInterceptor와, RequestBodyWrappingFilter로 구분되어 있다.
RequestBodyWrappingFilter는 Filter에서 @RequestBody 어노테이션을 읽게 되면, requestBody는 일회성 inputstream이기 때문에, interceptor에서 더이상 requestBody를 읽지 못한다.
따라서 RequestBodyWrappingFilter를 통해 HttpServletRequest를 한번 감싸줘야 한다.
[LoggingInterceptor]
@Slf4j
@Component
@RequiredArgsConstructor
public class LoggingInterceptor implements HandlerInterceptor {
private final ObjectMapper objectMapper;
private final ApiLogRepository apiLogRepository;
@Override
public void afterCompletion(
final HttpServletRequest request,
final HttpServletResponse response,
final Object handler,
final Exception ex
) throws Exception {
final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request;
String requestIp = request.getHeader("X-Forwarded-For");
if (requestIp == null) requestIp = request.getRemoteAddr();
ApiLog apiLog = ApiLog.builder()
.httpMethod(request.getMethod())
.requestURI(request.getRequestURI())
.accessTokenExist(StringUtils.hasText(request.getHeader(HttpHeaders.AUTHORIZATION)))
.requestBody(String.valueOf(objectMapper.readTree(cachingRequest.getContentAsByteArray())))
.requestIP(requestIp)
.build();
apiLogRepository.save(apiLog);
log.info(
"\n HTTP Method : {} " +
"\n Request URI : {} " +
"\n AccessToken Exist : {} " +
"\n Request Body : {}" +
"\n Request Time : {}" +
"\n Request IP : {}",
apiLog.getHttpMethod(),
apiLog.getRequestURI(),
apiLog.isAccessTokenExist(),
apiLog.getRequestBody(),
apiLog.getRequestTime(),
apiLog.getRequestIP()
);
}
}
prehandle()은 실제 핸들러가 실행하기 이전,
postHandle()은 핸들러가 실행된 이후
aftercompletion은 요청이 모두 완료된 이후에 실행이 된다.
afterCompletion()은 말 그대로 요청이 모두 완료된 이후, 즉 예외 발생여부와 관계 없이 클라이언트에 응답이 전달될 때 실행되므로, 예외 처리(컨트롤러에서 호출이 실패)하더라도, DB에 저장하도록 aftercompletion() 메서드를 사용하였다.
APILog에는 사용자가 호출한 HttpMethod, RequestURI, 토큰 여부, requestBody, 요청 시간, 요청IP를 저장한다.
[RequestBodyWrappingFilter]
@Component
@RequiredArgsConstructor
public class RequestBodyWrappingFIlter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request);
filterChain.doFilter(wrappingRequest, response);
}
}
InputStream을 캐싱할 수 있는 'ContentCachingRequestWrapper'라는 클래스이다.
HttpServletRequest를 포장하면 사용한 바디를 캐싱해두고 불러오는 메서드이다. Filter에서는 @RequestBody 어노테이션을 통하면 더 이상 읽어드릴수 없으므로, WrappingFilter로 래핑해준다.
[APILog]
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ApiLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String requestIP;
private String httpMethod;
private String requestURI;
private boolean accessTokenExist;
private String requestBody;
private LocalDateTime requestTime;
@Builder
public ApiLog(String requestIP, String httpMethod, String requestURI, boolean accessTokenExist, String requestBody) {
this.requestIP = requestIP;
this.httpMethod = httpMethod;
this.requestURI = requestURI;
this.accessTokenExist = accessTokenExist;
this.requestBody = requestBody;
this.requestTime = LocalDateTime.now();
}
}
APILog Repository이다. 사용자가 API를 요청하면 DB에 저장하는 엔터티이다.
[개선할 점]
현재 개인정보(사용자의 비밀번호)가 RequestBody에 그대로 노출되어, 로그에 저장된다.
비밀번호와 같은 민감한 정보는 암호화하여 저장하도록 리팩토링할 예정이다.