앞서 AOP를 사용한 로그 관리를 살펴보았다.
하지만 본인이 느끼기에 몇가지 문제가 있었다. 그래서 이번에는 AOP가 아닌 Filter를 활용하여 Spring boot에서 로그 관리를 해볼려고 한다.
우선 본인은 아래와 같이 LogFilter를 구현했다
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.uni_bag.uni_bag_spring_boot_app.config.CustomHttpRequestWrapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Slf4j
public class LogFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
long startTime = System.currentTimeMillis();
CustomHttpRequestWrapper requestWrapper = new CustomHttpRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
requestWrapper.getInputStream();
boolean isShowingRequestLog = checkRequestLogShow(requestWrapper);
if(isShowingRequestLog) {
// 요청 로그 출력
log.info("[REQUEST] {} {} | IP: {} | User-Agent: {} | RequestBody:{}",
request.getMethod(),
request.getRequestURI(),
request.getRemoteAddr(),
request.getHeader("User-Agent"),
getRequestBody(requestWrapper)
);
}
// 필터 체인 호출
filterChain.doFilter(requestWrapper, responseWrapper);
long duration = System.currentTimeMillis() - startTime;
boolean isShowingResponseLog = checkResponseLogShow(requestWrapper);
if(isShowingResponseLog) {
// 응답 로그 출력
log.info("[RESPONSE] {} {} | Status: {} | Time Taken: {}ms | ResponseBody:{}",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
duration,
new String(responseWrapper.getContentAsByteArray(), StandardCharsets.UTF_8)
);
}
responseWrapper.copyBodyToResponse(); // 요청을 전달
}
private String getRequestBody(CustomHttpRequestWrapper requestWrapper) {
byte[] content = requestWrapper.getRequestBody();
if (content.length == 0) {
return "";
}
try {
ObjectMapper objectMapper = new ObjectMapper();
Object json = objectMapper.readValue(content, Object.class);
return objectMapper.writeValueAsString(json); // 한 줄 JSON 문자열 반환
} catch (IOException e) {
return new String(content, StandardCharsets.UTF_8);
}
}
private boolean checkRequestLogShow(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/actuator");
}
private boolean checkResponseLogShow(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/actuator");
}
}
이 LogFilter 클래스는 Spring Boot에서 HTTP 요청과 응답을 로깅하기 위한 커스텀 필터로 다음과 같은 기능을 한다.
CustomHttpRequestWrapper requestWrapper = new CustomHttpRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
requestWrapper.getInputStream();
Spring Boot에서는 특정 필터에서 한번 요청과 응답에 대한 객체를 읽을 경우 다른 필터에서 또 다시 요청과 응답 객체를 읽을 수 없다. 그래서 해당 정보들을 다른 필터(logFilter)에서 사용하기 위해 CustomHttpRequestWrapper와 ContentCachingResponseWrapper를 사용해 캐시를 진행했다.
요청 캐싱 객체에 대해서는 아래에서 설명한다.
long startTime = System.currentTimeMillis();
boolean isShowingRequestLog = checkRequestLogShow(requestWrapper);
if(isShowingRequestLog) {
// 요청 로그 출력
log.info("[REQUEST] {} {} | IP: {} | User-Agent: {} | RequestBody:{}",
request.getMethod(),
request.getRequestURI(),
request.getRemoteAddr(),
request.getHeader("User-Agent"),
getRequestBody(requestWrapper)
);
}
LogFilter는 먼저 요청 정보에 대한 로그를 작성한다. 여기서는 IP, User-Agent, HTTP 메서드, URI, 요청 본문을 로깅한다.
또한 특정 요청에 대해서는 로깅을 피하기 위해 요청을 로깅할지 말지 판단하는 checkRequestLogShow() 메서드를 거치게 된다. 본인은 actuactor에서 발생한 로그를 숨기기 위해 해당 메소드를 사용했다.
// 필터 체인 호출
filterChain.doFilter(requestWrapper, responseWrapper); // 다음 필터를 호출하고 남은 비지니스 로직을 수행
long duration = System.currentTimeMillis() - startTime;
boolean isShowingResponseLog = checkResponseLogShow(requestWrapper);
if(isShowingResponseLog) {
// 응답 로그 출력
log.info("[RESPONSE] {} {} | Status: {} | Time Taken: {}ms | ResponseBody:{}",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
duration,
new String(responseWrapper.getContentAsByteArray(), StandardCharsets.UTF_8)
);
}
responseWrapper.copyBodyToResponse();
LogFilter는 앞서 요청 로그를 작성하고 요청에 대한 비지니스 로직을 실행한 뒤 응답 로그를 작성한다.
응답에서도 요청과 동일하게 로그 작성 여부를 확인하고 IP, User-Agent, HTTP 메서드, URI, 응답 본문을 로깅 한다.
여기서는 responseWrapper.copyBodyToResponse(); 가 사용되었는데 이는 응답 본문을 다시 실제 response에 복사해서 클라이언트로 전달되도록 한다. ContentCachingResponseWrapper는 응답 내용을 캐시에 저장하므로, 이렇게 복사하지 않으면 응답이 사라진다.
본인은 아래와 같이 코드를 구성하여 요청 객체에 대한 캐싱을 진행했다.
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import lombok.Getter;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
@Getter
public class CustomHttpRequestWrapper extends HttpServletRequestWrapper {
private final byte[] requestBody;
public CustomHttpRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
// Request Body를 바이트 배열로 저장해 여러 번 읽을 수 있도록 캐싱
InputStream is = request.getInputStream();
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) != -1) {
byteArrayOutputStream.write(buffer, 0, length);
}
this.requestBody = byteArrayOutputStream.toByteArray();
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.requestBody);
return new ServletInputStream() {
@Override
public int read() {
return byteArrayInputStream.read();
}
@Override
public boolean isFinished() {
return byteArrayInputStream.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
}
InputStream is = request.getInputStream();
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) != -1) {
byteArrayOutputStream.write(buffer, 0, length);
}
this.requestBody = byteArrayOutputStream.toByteArray();
여기서 요청 바디를 한 번 읽고, 메모리에 저장해둔다. 이로써 InputStream을 다시 만들 수 있게 된다.
앞서 만든 logFilter가 제대로 동작하기 위해서는 filterChain() 메소드에 로그 필터가 언제 실행될지 명시를 해야한다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 중략...
.addFilterBefore(new LogFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
본인은 유저 로그인 정보까지 로깅하기 위해 UsernamePasswordAuthenticationFilter 이전에 logFilter가 실행되도록 구현했다. 이 부분은 자신의 환경에 맞게 커스터마이징해도 괜찮을 거 같다.
AOP 방식에서 몇가지 불편한 점을 느껴 필터 방식으로 로그 관리를 진행했다. 확실히 AOP보다 깔끔하고 좋은 거 같다고 생각한다. 하짐나 아직 로그 관리에 대해 많은 것을 알고 있지 않기 때문에 배워야 할 점이 많다고 느끼며 해당 코드도 개선의 여지가 분명히 존재한다고 생각한다. 실제로 서비스르 배포하면서 개선할 점이 보인다면 더 공부해서 로그 관리 코드를 개선하여 다시 포스팅하도록 하겠다.
모두들 HappyCoding!😁
감사합니다. 구현에 도움이 많이 되었습니다..
🐳