들어가며
오늘은 프로젝트에서 로그 메시지에 대한 내용을 수정하기 위한 작업을 해볼 예정입니다.
로그 메세지를 왜 수정해야 할까요?
이미 스프링부트 환경에서는 자동으로 로그메세지도 제공해주고,
Error 면 Error, Info 면 Info 알아서 나오지 않나요? 라는 생각을 할 수 있습니다.
하지만 로그도 하나의 코드라는 생각으로 스프링에서 제공해주는 일반적인 로그가 아닌
내가 알아보기 편한, 즉 가독성(=읽기 편한) 로그를 만들어주는 것 또한 중요하다고 생각합니다.
실질적으로 서비스를 운영하면, 다양한 환경에서 Error 및 Info 로그를 체크해야 할 일이 많습니다.
그 로그들을 편리함,가독성, 팀원 과 공통적인 설정, 여러가지 보안적인 부분
프로젝트 스펙을 클라이언트와 맞추기 위해서 로그 스펙을 설정할 필요가 있습니다.
물론 그냥 써도 문제되는 건 없지만, 로그 설정을 한다면 가독성이 좋아지고, 가독성이 좋아지면
나중에 유지보수 차원에서 1분 1초라도 더 효율적일 것이라고 생각합니다.
필터의 동작 과정은
위 처럼 Request -> Filter -> DispatcherServlet -> HandlerInterceptor -> Controller 까지 왔다가,
역순으로 다시 돌아가여 Response 를 주는 과정 입니다.
그리고 filter 는 SpringContext 가 아닌 톰캣과 같은 서블릿 컨테이너에 의해 관리가 된다.
그리고 DispatcherServlet 전/후에 처리가 되는 것입니다.
저희는 필터의 3가지 메소드 중에서 저희가 필요한 doFilter() 메소드만 다루어 볼 것입니다.
코드에 들어가기전에 3가지 메소드의 역할만 간단하게 설명하고 본론으로 들어가겠습니다.
✅ init()
위 메소드는 필터 객체를 초기화하고 서비스에 추가하기 위한 메소드입니다.
즉 서블릿 컨테이너(=톰캣)이 init() 을 호출하여 필터 객체를 초기화하면 이후의 요청들은 doFilter 를 통해 처리된다.
⭐️ doFilter() ⭐️
위 메소드는 url-pattern 에 맞는 모든 HTTP 요청이 DispatcherServlet 으로 전달되기 전에 서블릿 컨테이너에 의해 실행되는 메소드 입니다. doFilter() 파라미터로는 FilterChain 이 있는데 FilterChain 객체의 doFilter 메소드를 통해 다음 대상으로 요청을 전달한다.
FilterChain fc = new FilterChain();
fc.doFilter()
fc.doFilter() 전/후에 우리가 필요한 처리 과정을 넣어줌으로써 원하는 처리를 진행이 가능합니다.
✅ destroy()
destroy 메소드는 필터 객체를 서비스에서 제거히고 사용하는 자원을 반환하기 위한 메소드이다. 이는 서블릿컨테이너에 의해 1번 호출되고 이후에는 doFilter 를 통해 처리되지 않습니다.
이제 직접적인 코드를 보겠습니다.
LoggerFileter
package org.example.api.filter;
import java.io.IOException;
import java.util.Enumeration;
import org.springframework.stereotype.Component;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class LoggerFilter implements Filter {
@Override
public void init (FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
// 우리가 수정할 부분은 이 부분 이다.
@Override
public void doFilter (ServletRequest request, ServletResponse response, FilterChain chain) throws
IOException,
ServletException {
// request 내용을 한번 읽어버리면, 뒷단에서 다시 읽어버릴 수 없도록 설정이 되어있다.
// 그것을 캐싱해주는 래퍼클래스가 있기 떄문에 그것을 설정해준다.
ContentCachingRequestWrapper req = new ContentCachingRequestWrapper( (HttpServletRequest) request);
ContentCachingResponseWrapper res = new ContentCachingResponseWrapper( (HttpServletResponse) response);
// 파라미터로 넘어온 request 와 response 를 넣어주는게 아닌, 우리가 직접 만들어 둔 req,res 객체를 필터에 넘겨준다.
// 객체가 넘어 갔기 때문에, 뒷단에 컨트롤러, 인터셉터 등 뒷단에서 받는 request 는 랩핑된 객체들이 넘아가게 된다.
// 필터 같은 경우는 request 가 들어오면 doFilter 를 기준으로 위에 로직이 실행전, 아래 로직이 실행 후 response 가 나가는 로직이다.
chain.doFilter(req,res);
// 가장 좋은 방법은 요청이 들어왔을 때, 헤더정보와 바디 정보를 찍어준다 (실행 전)
// 그러기 위해서는 ContentCachingRequestWrapper 말고 별도의 다른 객체를 만들어서 사용하는게 좋다.
// 우리는 ContentCachingRequestWrapper 가 제공하는 이후의 단에서 로그를 남기겠다.(필터 실행 후)
// request 정보
Enumeration<String> headerNames = req.getHeaderNames(); // header Name
StringBuilder headerValues = new StringBuilder();
// key value 구조 이므로 headerName 에 대한 값을 찍어본다.
headerNames.asIterator().forEachRemaining(headerKey -> {
String headerValue = req.getHeader(headerKey);
// authorization-token : ???, user-agent : ??? 이런 형식으로 찍히게 작성함
headerValues.append("[")
.append(headerKey)
.append(" : ")
.append(headerValue)
.append(" , ")
.append("] ");
});
// RequestBody 정보 -> 요청이 들어올 때 로그
// Client --------------------------------------------------> Server
String requestBody = new String(req.getContentAsByteArray());
String requestURI = req.getRequestURI();
String method = req.getMethod();
log.info("Request Info : URI : {}, Method : {}, header : {} , body : {}", requestURI, method, headerValues, requestBody); // toString 을 안하는 이유는 Logger 에서 자동으로 toString() 을 호출 해준다.
// Response 정보
StringBuilder responseHeaderValues = new StringBuilder();
res.getHeaderNames().forEach(headerKey -> {
String headerValue = res.getHeader(headerKey);
// authorization-token : ???, user-agent : ??? 이런 형식으로 찍히게 작성함
responseHeaderValues.append("[")
.append(headerKey)
.append(" : ")
.append(headerValue)
.append(" , ")
.append("] ");
});
// ResponseBody 정보 -> 응답이 나갈 때 로그
// Server --------------------------------------------------> Client
String responseBody = new String(res.getContentAsByteArray());
log.info("Response Info : URI : {}, Method : {}, header : {} , body : {}", requestURI, method, responseHeaderValues, responseBody); // toString 을 안하는 이유는 Logger 에서 자동으로 toString() 을 호출 해준다.
// ⭐️ 중요한게 이미 req,res 내용을 읽어버렸기 때문에 다시 초기화를 시켜 호출을 해줘야 한다.
// 아래 로직을 쓰지 않으면 responseBody 가 비어져서 나옵니다.
res.copyBodyToResponse();
}
@Override
public void destroy () {
Filter.super.destroy();
}
}
위 코드에 주석과 함께 모든 설명이 나와 있습니다.
일단 기본적으로 filter 를 수정시키기 위해선 Filter 인터페이스를 구현받아서 직접 수정을 했습니다.
Filter 인터페이스 안에는 3개의 구현메소드가 있는데, 그 중에서 저희가 필요한 건 doFilter 입니다.
doFilter 는 클라이언트 요청이 오면 체인을 통해 컨테이너를 호출하여 실행 됩니다.
위 메소드의 FilterChain 객체에 의해 요청, 응답이 필터링이 됩니다.
FilterChain 인터페이스에는 doFilter 라는 구현 메소드가 하나 있고, 그 안에 request 와 response 를 파라미터로 받고 있습니다.
public interface FilterChain {
/**
* Causes the next filter in the chain to be invoked, or if the calling filter is the last filter in the chain,
* causes the resource at the end of the chain to be invoked.
*
* @param request the request to pass along the chain.
* @param response the response to pass along the chain.
*
* @throws IOException if an I/O error occurs during the processing of the request
* @throws ServletException if the processing fails for any other reason
*/
void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException;
}
즉 chain 에 request 와 response 가 아 알맞게 있으면 리소스가 호출이 된다라는 의미 정도로 알고있으면 될 것같습니다.
( 위 코드에 주석으로 자세한 설명들이 나와 있습니다)
위 코드를 작성후 서버를 실행시키고 보면 API 호출을 해보면
이런식으로 저희가 Custom 한 로그가 나오게 됩니다.
현재는 그냥 API 호출시 요청 스펙 및 응답 스펙이 INFO 로 찍히게 됩니다.
나중에 필요에 의해서 INFO 말고 debug,error 등등 다양한 로그를 변형 시킬 수 있습니다.