서블릿 필터

로그인을 한 상태로 접근 해야하는 서비스가 있다. 웹 브라우저에서 로그인을 해야 버튼이 보이게 구현하면 될 것 같지만 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를 다른 객체로 바꿔서 넘길 수 있다. 잘 사용하는 기능은 아니니 참고만 하자.

카테고리:

업데이트:


Comments