스프링 MVC 2편(백엔드 웹 개발 활용 기술)

Ch07. 로그인 처리(필터, 인터셉터) - 서블릿 필터

webmaster 2022. 3. 18. 11:06
728x90

공통 관심 사항

  • 로그인하지 않은 사용자가 URL을 직접 입력하면 상품 관리 화면에 들어갈 수 있는 문제가 있다.
  • 상품 관리 컨트롤러에서 로그인 여부를 체크하는 로직을 하나하나 작성하면 되겠지만, 등록, 수정, 삭제, 조회 등등 상품관리의 모든 컨트롤러 로직에 공통으로 로그인 여부를 확인해야 한다.
    • 더 큰 문제는 향후 로그인과 관련된 로직이 변경될 때 작성한 모든 로직을 수정해야 할 수 있다.
  • 이러한 공통 관심사는 스프링의 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사는 지금부터 설명할 서블릿 필터 또는 스프링 인터셉터를 사용하는 것이 좋다
  • 웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL의 정보들이 필요한데, 서블릿 필터나 스프링 인터셉터는 HttpServletRequest를 제공한다.

서블릿 필터 

  • 필터 흐름
    • HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
    • 필터를 적용하면 필터가 호출 된 다음에 서블릿이 호출된다.
    • 그래서 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 된다. 참
    • 고로 필터는 특정 URL 패턴에 적용할 수 있다. /*이라고 하면 모든 요청에 필터가 적용된다.
  • 필터 제한
    • HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출 X) //비 로그인 사용자
    • 필터에서 적절하지 않은 요청이라고 판단하면 거기에서 끝을 낼 수도 있다.
    • 그래서 로그인 여부를 체크하기에 딱 좋다
  • 필터 체인
    • 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(): 고객의 요청이 올 때 마다 해당 메서드가 호출된다. 필터의 로직을 구현하면 된다.
    • destroy(): 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다

요청 로그

Filter 만들기

@Slf4j
public class LogFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }

    @Override
    public void destroy() {
        log.info("log filter destroy");
    }

    @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();

        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);
        }
    }
}
  • javax.servlet의 Filter 인터페이스를 구현한다.
  • doFilter 
    • Http 요청이 들어오게 되면 doFilter가 호출된다.
    • 파라미터로 받는 ServletRequest는 Http 요청이 아닌 경우까지 고려해서 만들었기 때문에 다운 캐스팅을 해서 HttpServletRequest 기능들의 사용하면 된다.
  • chain.doFilter를 통해 다음 필터를 호출한다.
    • 필터가 없으면 서블릿을 호출한다.

WebConfig

@Configuration
public class WebConfig {
    @Bean
    public FilterRegistrationBean logFilter(){
        FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
        filterFilterRegistrationBean.setFilter(new LogFilter());
        filterFilterRegistrationBean.setOrder(1);
        filterFilterRegistrationBean.addUrlPatterns("/*");

        return filterFilterRegistrationBean;
    }
}
  • SpringBoot를 사용하면 Filter를 등록할때 FilterRegustrationBean을 사용해서 등록하면 된다.
    • setFilter(new LogFilter()) : 등록할 필터를 지정한다.
    • setOrder(1) : 필터는 체인으로 동작한다. 따라서 순서가 필요하다. 낮을수록 먼저 동작한다.
    • addUrlPatterns("/*") : 필터를 적용할 URL 패턴을 지정한다. 한번에 여러 패턴을 지정할 수 있다
  • 참고
    • @ServletComponentScan, @WebFilter 로 필터 등록이 가능하지만 순서 조절이 안된다.(FilterRegustrationBean을 사용하자)
    • 실무에서 HTTP 요청시 같은 요청의 로그에 모두 같은 식별자를 자동으로 남기는 방법은 logback mdc로 검색해보자.

인증 체크

LoginCheckFilter

@Slf4j
public class LoginCheckFilter implements Filter {
    private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};

    //init, destroy 이는 default라 구현 안해도 된다.
    @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);
        }
    }

    /**
     * 화이트 list의 경우 인증 체크 X
     */
    private boolean isLoginCheckPath(String requestURI){
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
}
  • whiteList
    • 인증 필터를 적용해도 홈, 회원가입, 로그인 화면, css 같은 리소스에는 접근할 수 있어야 한다.
    • 이렇게 화이트 리스트 경로는 인증과 무관하게 항상 허용한다.
    • 화이트 리스트를 제외한 나머지 모든 경로에는 인증 체크 로직을 적용한다.
  • httpResponse.sendRedirect()
    • 미인증 사용자는 로그인 화면으로 리다이렉트 한다. 그런데 로그인 이후에 다시 홈으로 이동해버리면, 원하는 경로를 다시 찾아가야 하는 불편함이 있다.
    • 예를 들어서 상품 관리 화면을 보려고 들어갔다가 로그인 화면으로 이동하면, 로그인 이후에 다시 상품 관리 화면으로 들어가는 것이 좋다
    • 이러한 기능을 위해 현재 요청한 경로인 requestURI를 /login에 쿼리 파라미터로 함께 전달한다
  • return
    • 필터를 더는 진행하지 않는다. 이후 필터는 물론 서블릿, 컨트롤러가 더는 호출되지 않는다.
    • 앞서 redirect 를 사용했기 때문에 redirect 가 응답으로 적용되고 요청이 끝난다.

Bean 등록

@Bean
public FilterRegistrationBean loginCheckFilter() {
    FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
    filterFilterRegistrationBean.setFilter(new LoginCheckFilter());
    filterFilterRegistrationBean.setOrder(2);
    filterFilterRegistrationBean
        .addUrlPatterns("/*");  //미래에 새로운 패턴이 생기더라도 적용시키기 위해서 whiteList에 있는 것들만 제외한 모든 요청을 실행했다

    return filterFilterRegistrationBean;
}

loginControllerV4

@PostMapping("/login")
public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, @RequestParam(defaultValue = "/") String redirectURL, HttpServletRequest request){
    if(bindingResult.hasErrors()){
        return "login/loginForm";
    }
    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
    if(loginMember == null){
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }
    HttpSession session = request.getSession(true);
    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

    return "redirect:" + redirectURL;
}
  • 더 이상 / 가 아닌 파라미터 요청으로 들어온 곳으로 Redirect 한다.
  • 참고
    • 필터에는 다음에 설명할 스프링 인터셉터는 제공하지 않는, 아주 강력한 기능이 있는데 chain.doFilter(request, response); 를 호출해서 다음 필터 또는 서블릿을 호출할 때 request , response를 다른 객체로 바꿀 수 있다.
    • ServletRequest , ServletResponse를 구현한 다른 객체를 만들어서 넘기면 해당 객체가 다음 필터 또는 서블릿에서 사용된다. 잘 사용하는 기능은 아니니 참고만 해두자
728x90