서블릿 필터
서블릿 필터
로그인을 한 상태로 접근 해야하는 서비스가 있다. 웹 브라우저에서 로그인을 해야 버튼이 보이게 구현하면 될 것 같지만 URL을 직접 호출하면 들어갈 수 있다. 해당 컨트롤러에서 로그인 여부를 체크하는 로직을 작성해 사용하면 되겠지만 관련된 모든 컨트롤러 로직에 공통으로 확인해야하고 만약 로그인과 관련된 로직이 변경되면 작성한 모든 로직을 수정해야 할 수 있다.
이처럼 애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사(cross-cutting-concern)이라고 한다.
공통 관심사는 스프링의 AOP로 해결할 수 있지만 AOP는 로깅, 트랜잭션, 에러처리 등 비즈니스단에서 메서드를 세밀하게 조정하기 위해 사용하고 웹과 관련된 공통 관심사는 서블릿 필터
또는 스프링 인터셉터
를 사용하는 것이 좋다. 웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL의 정보듣ㄹ이 필요한데, 서블릿 필터나 스프링 인터셉터는 HttpServletRequest를 제공한다.
필터 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 // 로그인 사용자가 접근
HTTP 요청 -> WAS -> 필터(적절하지 않는 요청이면 서블릿 호출X) //비로그인 사용자가 접근
- 필터를 적용하면 필터가 호출된 다음에 서블릿이 호출 되기 때문에 공통 관심사를 필터에 적용하면 된다. URL 패턴을 지정하여 특정 요청에만 필터를 적용할 수도 있는데 /*라고 지정하면 모든 요청에 필터가 적용된다.
- 필터에서 적절하지 않는 요청이라고 판단 되면 서블릿을 호출하지 않기 때문에 컨트롤러 역시 호출이 안된다.
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
필터는 체인으로 구성되는데 중간에 필터를 추가하여 여러 필터를 적용할 수 있다.
필터 인터페이스
public interface Filter {
public default void init(FilterConfig filterConfig) throws ServletException {
}
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
}
public default void destroy() {
}
}
필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고, 관리한다.
init()
: 필터 초기화 메서드, 서블릿 컨테이너가 생성될 떄 호출된다.doFilter()
: 요칭이 올 때 마다 해당 메서드가 호출, 필터의 로직을 구현하면 된다.destory()
: 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.
init과 destroy 메서드는 꼭 구현 안해도 된다.
서블릿 필터 - 로그
필터가 적용되는 걸 확인해 보기 위해 모든 요청에 대해 로그를 남기는 필터를 적용해본다.
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("log filter init");
}
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
log.info("log filter doFilter");
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
//HTTP 요청을 구분하기 위한 UUID 생성
String uuid = UUID.randomUUID().toString();
try {
log.info("REQUEST [{}][{}]", uuid, requestURI);
chain.doFilter(request, response);
} catch (Exception e) {
throw e;
} finally {
log.info("RESPONSE [{}][{}]", uuid, requestURI);
}
}
@Override
public void destroy() {
log.info("log filter destroy");
}
}
- 필터를 사용하려면 Filter 인터페이스를 구현하면 된다.
- doFilter의 파라미터에서 ServletRequest로 받는 이유는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스이다. 따라서 HTTP 요청을 사용하면 HttpServletRequest로 다운 캐스팅 필요
- chain.doFilter(request, response): 다음 필터가 있으면 필터를 호출하고 필터가 없으면 서블릿을 호출한다. 만약 이 로직을 수행하지 않으면 다음 단계로 진행되지 않는다.
필터 로직을 작성했으면 서블릿 컨테이너에 등록을 해야 적용할 수 있다.
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter());
filterFilterRegistrationBean.setOrder(1);
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
serFilter()
: 등록할 필터를 지정.setOrder
: 필터 체인의 순서를 지정. 낮을 수록 먼저 동작addUrlPattern()
: 필터를 적용할 URL 패턴을 지정. 여러 패턴을 지정할 수 있다.
서블릿 필터 - 인증 체크
특정 로직에 로그인 하지 않은 사용자가 요청을 보냈을 경우 접근하지 못하게 적용해본다.
@Slf4j
public class LoginCheckFilter implements Filter {
private static final String[] whiteList = {"/", "/members/add", "/login", "/logout", "/css/*"};
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
log.info("인증 체크 필터 시작 {}", requestURI);
if (isLoginCheckPath(requestURI)) {
log.info("인증 체크 로직 실행 {}", requestURI);
HttpSession session = httpRequest.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청 {}", requestURI);
//로그인으로 redirect
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return;
}
}
chain.doFilter(request, response);
} catch (Exception e) {
throw e; //에외 로깅 가능 하지만 톰캣까지 예외를 보내주어야 함
} finally {
log.info("인증 체크 필터 종료 {}", requestURI);
}
}
/**
* 화이트 리스트의 경우 인증 체크x
*/
private boolean isLoginCheckPath(String requestURI) {
return !PatternMatchUtils.simpleMatch(whiteList, requestURI);
}
}
- whiteList{}: 인증 필터를 적용해도 홈, 회원가입, 로그인 화면, css 같은 리소스에는 인증 없이 요청해도 접근이 가능한 URI를 제외한 나머지를 필터에 적용하기 위해 배열로 선언
PatternMatchUtils.simpleMatch(whiteList, requestURI)
: 요청 URI와 whiteList에 있는 URI의 패턴이 같은지 체크하는 메서드. 같으면 필터를 적용하지 않기 위해 !(not)을 붙여 반환httpResponse.sendRedirect()
: 해당 URL로 리다이렉트. 쿼리 파라미터로 컨트롤러에서 로그인 후 해당 경로로 다시 이동하기 위해 추가 로직 작성이 필요하다.
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter());
filterFilterRegistrationBean.setOrder(1);
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LoginCheckFilter());
filterFilterRegistrationBean.setOrder(2);
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
@PostMapping("/login")
public String loginV4(@Valid @ModelAttribute("loginForm") LoginForm loginForm,
@RequestParam(defaultValue = "/") String redirectURL,
BindingResult bindingResult,
HttpServletRequest request) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
//로그인 성공 처리 TODO
//세션이 있으면 있는 세션을 반환, 없으면 신규 세션을 생성
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:" + redirectURL;
}
WebConfig에 필터를 등록하고 컨트롤러에서 @RequestParam으로 쿼리 스트링의 URL로 리다이렉트하는 로직 수정하면 적용은 끝이다.
참고
필터에는 chain.doFilter(request, response)를 호출해서 다음 단계로 넘어갈 때 request, response를 다른 객체로 바꿔서 넘길 수 있다. 잘 사용하는 기능은 아니니 참고만 하자.