스프링 웹 MVC 2. 필터
[ 필터 ]
필터는 스프링의 프론트 컨트롤러인 Dispatcher Servlet에 요청이 도달하기 전 URL 패턴에 맞는 모든 요청에 대해 부가작업을 처리하는 기능을 제공한다. 해당 코드를 작성한 사람이 남긴 목적에 의하면 총 9가지 용도로 세분화해서 사용할 수 있다고 적혀있다. 하지만 요약해보면 데이터 압축 및 변환, 로그인 인증 및 인가, 로그 이렇게 3개로 요약이 가능할 것 같다.
아래의 코드는 Filter 인터페이스에 코드로 메서드는 총 3가지가 있다. 그런데 init이나 destory를 써본 적이 없다. 생성자 주입 방식으로 필요한 객체를 주입 받는 방식을 보통 사용했었기 때문이다. 그래서 제일 중요한 메서드는 doFilter인데 외부의 request 요청 값을 받아 여러 처리를 하여 쓰레드 로컬로 된 홀더에 인증 정보를 보관하거나 로그를 남길 수 있다. 또한 원치 않는 값이 들어오거나 에러가 난 경우 어떻게 응답을 내보낼지에 관한 처리도 이곳에서 해줄 수 있다.
public interface Filter {
default void init(FilterConfig filterConfig) throws ServletException {
}
void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException;
default void destroy() {
}
}
[ 필터로 URL 요청 정보 로그 남기기 ]
말로 설명하는 것보다 코드로 확인하는 게 좋을 것 같다는 생각이 든다. 그래서 제일 간단한 로그 남기기 코드를 작성해보았다. 우선 Filter는 여러 스펙에도 적용이 가능하도록 request와 response로 HTTP 요청을 받는다. 사용하기 더 좋은 것은 HttpServletRequest이니 캐스팅해서 사용하면 된다.
로직은 단순하다. 여기서는 스프링 시큐리티를 사용하지 않지만 Authorization 같은 헤더 값을 꺼내서 내부 정보를 가져오고 거기서 유저의 닉네임을 꺼낸다. 그리고 해당 유저와 요청 URL을 매핑해서 로그를 남긴다. 아래의 Response부분은 Filter에서는 응답 값을 변경할 수 있음을 알려주기 위해서이다. 응답 값을 변경하는 경우는 로그인하고 인증이 완료된 뒤 세션용 토큰을 발급했을 때 Header에 넣는 경우 등에 사용된다.
@Slf4j
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
// 스프링 시큐리티를 사용하는 경우 Authorization 헤더 값을 통해서 내부 정보를 가져올 수 있다.
// 여기선 그 과정까지 넣으면 너무 복잡하니 그렇게 가져왔다고 생각을 하자.
String userName = "Recfli";
log.info("REQUEST ID[{}], REQUEST URI:[{}]", userName, requestURI);
// 다음 Filter 혹은 Dispatcher Servlet으로 넘어가기 전 값 변경 가능
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("BlaBla", "BlaBla");
chain.doFilter(request, response);
}
}
이렇게 코드를 작성했으면 해당 로그 필터를 스프링 빈에 등록을 해주어야 한다. FilterRegistrationBean에 직접 만든 로그 필터를 아래처럼 등록해주자. 내부 메서드 명칭을 보면 어떤 목적으로 사용할 수 있는지 단번에 알 수 있다.
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter()); // 추가하려는 필터
filterFilterRegistrationBean.setOrder(1); // Filter 순서
filterFilterRegistrationBean.addUrlPatterns("/*"); // 필터 URL 매칭 패턴
return filterFilterRegistrationBean;
}
}
그리고 확인용으로 다음처럼 컨트롤러를 작성했다. 단순히 아무것도 안 받고 메시지만 하나 내보내는데, 중간에 로그로 해당 컨트롤러 메서드까지 동작하는지 눈으로 확인하기 위해 로그 한 줄을 남겼다.
@Slf4j
@RestController
public class filterController {
@GetMapping("/filter")
public String filter() {
log.info("컨트롤러가 동작했습니다.");
return "filter Method Operated";
}
}
포스트맨이나 아니면 인터넷을 키고 본인이 설정한 주소로 요청을 보내면 당연히 잘 나오고 찍힌 로그로 컨트롤러까지 동작이 잘됨을 확인할 수 있다.
[ 필터와 에러 ]
여기서 궁금했었던 건 만약에 필터에서 오류가 발생하면 어떻게 처리하는가였다. 필터는 가벼운 로직만 다루는 게 좋아서 DB와 관련된 걸 넣으면 안되지만 구현 상황을 위해 어쩔 수 없이 들어가는 경우도 있다. 아니면 들어온 토큰 값이 유효하지 않은 경우도 있을 것이다. 확인을 위해 doFilter부분에 단순 에러를 추가시켜주었다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
// 스프링 시큐리티를 사용하는 경우 Authorization 헤더 값을 통해서 내부 정보를 가져올 수 있다.
// 여기선 그 과정까지 넣으면 너무 복잡하니 그렇게 가져왔다고 생각을 하자.
if(true){
throw new RuntimeException("요청 오류입니다.");
// throw new IllegalArgumentException("요청 오류입니다.");
}
String userName = "Recfli";
log.info("REQUEST ID[{}], REQUEST URI:[{}]", userName, requestURI);
// 다음 Filter 혹은 Dispatcher Servlet으로 넘어가기 전 값 변경 가능
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("BlaBla", "BlaBla");
chain.doFilter(request, response);
}
실행을 해보면 컨트롤러까지 요청이 가지 않은 걸 확인할 수 있다. 또한 체크 예외건 언체크 예외건 상관없이 둘 다 포스트맨에서 온 결과 값은 500번 에러였다. 서버는 정상적으로 예외처리를 해준 것인데 예외의 경우 모두 다 500번처리를 해주면 곤란하다. 유저의 잘못인 경우 400번 대로 처리를 해주고 적절한 예외에 따라 코드를 추가해주는게 좋다. 가장 간단한 방법은 try-catch 문으로 해당 필터 내부에서 response 값을 수정해주는 방법이 있다. 그럼 아래처럼 나온다.
저기서 catch 후 return을 넣은 이유는 예외를 잡아주면 아무런 문제가 없는게 되어 결국엔 컨트롤러까지 요청이 가는데 그 현상을 방지하기 위해서이다. 또한 예외를 한번 더 던지는 경우는 그건 Dispatcher Servlet로 예외가 넘어가고 다시 Status를 바꿔버려서 처음과 똑같이 500번대 에러가 뜬다. 예외 처리를 안해준 것과 동일하게 처리된다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestURI = httpRequest.getRequestURI();
try{
if(true){
throw new RuntimeException("요청 오류입니다.");
}
} catch(RuntimeException e){
httpResponse.setStatus(HttpStatus.BAD_REQUEST.value());
return;
}
String userName = "Recfli";
log.info("REQUEST ID[{}], REQUEST URI:[{}]", userName, requestURI);
chain.doFilter(request, response);
}
[ 예외 처리를 한 곳에서 하는 방법 ]
그러면 이런 의문이 들 수 있다. 저렇게 퍼지면 나중에 필터 숫자가 늘어나면 관리하기 엄청 어려울 것 같은데 모든 필터에서 발생하는 예외를 한 곳에 모아서 처리할 수는 없을까? 당연히 가능하다! 맨 앞에 예외처리 필터를 세워놓고 try-catch문으로 놓은 뒤 잡힌 예외 별로 원하는 값들을 채워넣어주면 된다. 그래서 예외용 필터를 하나 아래처럼 만들었다.
@Slf4j
public class ExceptionFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
try{
chain.doFilter(request, response);
} catch(RuntimeException e) {
httpResponse.setStatus(HttpStatus.BAD_REQUEST.value());
return;
}
}
}
기존의 로그필터라는 이름의 예외를 날리는 필터를 아래처럼 수정해주자.
@Slf4j
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestURI = httpRequest.getRequestURI();
if (true) {
throw new RuntimeException("요청 오류입니다.");
}
String userName = "Recfli";
log.info("REQUEST ID[{}], REQUEST URI:[{}]", userName, requestURI);
chain.doFilter(request, response);
}
}
마지막으로 빈을 등록할 때 예외처리용 필터를 맨 앞에다가 세워놓으면 된다. 그러면 원하는데로 작동이 될 것이다. 대신 실제로 사용할 땐 실제로 발생할 수 있는 예외 종류별로 나누어서 고민을 해봐야 할 것 같다.
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean ExceptionFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new ExceptionFilter()); // 추가하려는 필터
filterFilterRegistrationBean.setOrder(1); // Filter 순서
filterFilterRegistrationBean.addUrlPatterns("/*"); // 필터 URL 매칭 패턴
return filterFilterRegistrationBean;
}
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter()); // 추가하려는 필터
filterFilterRegistrationBean.setOrder(2); // Filter 순서
filterFilterRegistrationBean.addUrlPatterns("/*"); // 필터 URL 매칭 패턴
return filterFilterRegistrationBean;
}
}
설명을 하다가 흐름이 필터의 사용 방법으로 넘어가게 된 것 같아 다시 정리를 해보도록 하겠다. 처음에 필터에 관해서 이야기를 한 이유는 웹과 관련된 문제에서 일어나는 에러 중 사전에 확인해야 하는 로그인 인증과 같은 상황을 컨트롤러에서 처리하지 않기 위함이었다. 그 이유는 컨트롤러에서 처리를 하면 모든 인증이 필요한 메서드마다 그 로직이 추가가 되는 문제가 생기기 때문이다.
그래서 필터로 그 내용을 잘 분리를 했지만 필터 내부에서는 검증 결과에 따라 예외처리가 필요하다. 따라서, 필터 내부에서 발생한 예외에 맞게 응답 코드를 내보내야하는데 그걸 당장 해당 필터에서 처리를 하면 필터 체인이 커질수록 문제가 생긴다. 따라서 필터 맨 앞에 예외처리 필터를 세워놓아 그 문제를 해결하는 방법까지 알아보았다.
다음에는 컨트롤러에 들어가기 전에 HTTP 요청에서 받은 파라미터 값들에 관한 검증 로직을 어떻게 수행할 지를 알아볼 예정이다. 또한 필터 파트인데, 필터가 여러 번 호출될 수 있는 부분을 다루지는 않았다. 이 부분은 컨트롤러의 예외처리 부분과 함께 알아보도록 하겠다.
[ 참고 자료 ]
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 by 김영한
https://mangkyu.tistory.com/173
https://stackoverflow.com/questions/34595605/how-to-manage-exceptions-thrown-in-filters-in-spring