스프링 MVC-스프링 로그인-필터와 인터셉터

공통 관심 사항

이전 포스팅의 페이지 요구사항을 보면, 로그인 하지 않은 사용자는 상품 관리 페이지에 접근이 불가능해야 한다.
하지만 현재의 페이지에서는 접근하는 버튼은 없지만, url을 직접 입력하면 접근이 가능하다.
로그인 여부를 페이지에 접근할 때 체크하면 구현할 수 있겠지만, 이렇게 하면 관련된 모든 로직에서 로그인 여부를 일일이 체크헤야 하고, 로그인 관련 로직이 변경되면 모든 로직을 일일이 수정해 주어야 한다.
이렇게 핵심 비즈니스 로직이 아니면서 여기저기서 쓰이는 공통 관심 사항은 스프링의 AOP로 처리가 가능하지만, 웹에 관련된 공통 관심사항은 서블릿 필터, 또는 스프링 인터셉터를 사용하는 것이 좋다.
왜냐하면 웹과 관련된 공통 관심사를 처리하기 위해선 HttpServletRequest 등을 매개변수로 받아야 하는데, 필터와 인터셉터에선 기본적으로 제공하기 때문이다.

기본적으로 필터와 인터셉터는 다음과 같은 순서로 작동한다.

HTTP 요청 -> WAS -> 필터 -> 서블릿(Dispatcher Servler 포함) -> 스프링 인터셉터 -> 컨트롤러 -> 스프링 인터셉터(afterCompletion 등)

서블릿 필터

서블릿 필터는 기본적으로 인터페이스이다. Filter 인터페이스를 구현하여 사용하는데, 아래와 같이 이루어져 있다.

  
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는 FilterRegistrationBean 객체에서 등록하여 사용할 수 있다.

인증 필터

로그 필터를 작성하는 과정은 중복이 많으니 스킵하고, 우리가 페이지에 사용할 인증 필터를 서블릿 필터를 통해 만들어보자.

  
@Slf4j
public class LoginCheckFilter implements Filter {
    static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpRequest.getRequestURI();
        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;

        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);
                    httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    return;
                }
            }
            filterChain.doFilter(servletRequest, servletResponse);
        } catch(Exception e) {
            throw e;
        } finally {
            log.info("인증 체크 필터 종료 {}", requestURI);
        }
    }

    boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
}

init()과 destroy()는 default 메서드이므로 의무적으로 구현하지 않아도 된다.
Filter에서 제공하는 ServletRequest는 우리가 사용하던 HttpServletRequest의 부모이므로 캐스팅을 해주어야 한다.

먼저 로그인 하지 않아도 접근할 수 있는 url들을 화이트리스트로 배열에 미리 등록해두고, 해당 url들이 아닌 url에 접근했다면 로직을 실행한다.
세션을 받아와 로그인 되어있는 상태인지 체크한 후 되어있다면 넘어가고, 로그인이 되어있지 않다면 로그인 화면으로 리다이렉트 시키고 필터가 종료된다.

  
@Bean
public FilterRegistrationBean loginCheckFilter() {
    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
    filterRegistrationBean.setFilter(new LoginCheckFilter());
    filterRegistrationBean.setOrder(2);
    filterRegistrationBean.addUrlPatterns("/*");
    return filterRegistrationBean;
}

기존의 로그 필터가 order 1이므로 2로 해주어 등록해준다. addUrlPatterns()에 “/*“로 매개변수를 넣으면 모든 url을 대상으로 적용된다.

위의 인증 필터는 로그인 화면으로 리다이렉트 할 때 쿼리 파라미터에 현재의 url을 담아서 보낸다. 이 정보를 받아서 로그인 완료 후 원래의 화면으로 돌아가는 기능을 구현해보자.

  
    @PostMapping("/login")
    public String loginV4(@Validated @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();
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

        return "redirect:" + redirectURL;
    }

기존 코드에서 @RequestParam으로 쿼리 파라미터에 담긴 url 정보를 받아 원래 있던 화면으로 리다이렉트 시켜주는 기능이 추가되었다.

서블릿 필터를 활용하여 웹의 공통 관심 사항을 성공적으로 분리하였다. 이후 로그인 하지 않아도 들어갈 수 있는 url이 추가되는 등의 로그인 관련 정책 변경이 있더라도 우리는 서블릿 필터 부분만 수정하면 된다.

스프링 인터셉터

위에서 지금까지 본 것은 서블릿이 제공하는 서블릿 필터였다. 인터셉터는 스프링 MVC가 제공하는 기능이다. 둘 다 기능은 비슷하지만, 사용방법과 적용되는 시점 등 약간의 차이가 있다.

스프링 인터셉터를 사용하기 위해서는 다음의 인터페이스를 구현하면 된다.

  
    public interface HandlerInterceptor {
    default boolean preHandle(HttpServletRequest request, HttpServletResponse 
response, Object handler) throws Exception {}  

    default void postHandle(HttpServletRequest request, HttpServletResponse 
response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {}  

    default void afterCompletion(HttpServletRequest request, HttpServletResponse 
response, Object handler, @Nullable Exception ex) throws Exception {}
    }

총 세 개의 메서드가 있는데 각각 적용 시점이 다르다. 다음의 그림을 보면 알 수 있다.

인터셉터

  • preHandle : 컨트롤러 호출 이전 시점에 호출된다. preHandle의 반환값이 false이면 컨트롤러도 호출하지 않고 그대로 실행이 종료된다.
  • postHandle : 컨트롤러 호출 이후 시점에 호출된다. 컨트롤러 예외 발생 시 호출되지 않는다.
  • afterCompletion : 뷰가 렌더링 된 이후에 호출된다. 컨트롤러에서 예외가 발생하더라도 호출되고 예외를 파라미터로 받아 로그를 출력할 수 있다.

스프링 인터셉터는 스프링 MVC에서 지원하는 기능인 만큼 스프링의 구조에 특화된 필터 기능들을 가지고 있다.
이후 설명할 url의 include, exclude 기능덕에 서블릿 필터보다 사용하기도 용이하니 꼭 서블릿 필터를 사용할 필요가 없다면 인터셉터를 사용하는 것이 좋다.

인증 인터셉터

이전과 같이 로그는 생략하고 인증 체크 기능을 하는 인터셉터를 구현하고 등록해보자.

  
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();

        log.info("인증 체크 인터셉터 실행 {}", requestURI);
        HttpSession session = request.getSession();
        if(session==null || session.getAttribute(SessionConst.LOGIN_MEMBER)==null) {
            log.info("미인증 사용자 요청");
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }
        return true;
    }
}

인증이라는 것은 컨트롤러 호출 이전에 하기 때문에 preHandle만 구현하면 된다.
필터와 비교해보면 의무적으로 넣어야 했던 chain 관련 코드가 빠지고, ServletRequest로 제공되었던 요청이 처음부터 HttpServletRequest로 제공되어 사용이 편리하고 코드 또한 간결해졌다.
whitelist 관련도 이후 등록과정에서 편리하게 처리하기 때문에 코드에서 제외되었다.
별다른 문법이 없이 내부에서 로직을 처리한 뒤 true, false만 반환해주면 되기에 매우 편리하다.

  
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
        registry.addInterceptor(new LoginCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "/*.ico", "/error");

    }

인터셉터의 등록 코드이다. WebMvcConfigurer 인터페이스의 addInterceptors() 메서드를 구현하여 사용하는데 InterceptorRegistry의 여러 메서드를 이용하여 등록하면 된다.
스프링 MVC의 문법은 서블릿과는 약간 달라서 모든 url을 대상으로 해주고 싶다면 “/*” 대신 “/**“으로 해줘야 한다. 세세한 문법 차이는 이후 필요할 때 검색해서 찾아보자.

기존 서블릿 필터에서 whitelist로 처리하던 부분을 addPathPatterns(), excludePathPatterns() 두 메서드로 손쉽게 처리가 가능하다. 각각 적용할 부분, 적용하지 않을 부분을 입력하면 된다.

ArgumentResolver

필터나 인터셉터와는 상관이 없지만 알고있으면 편리하게 활용이 가능한 ArgumentResolver에 대해 알아보자.

  
    @GetMapping("/")
    public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
    //세션에 회원 데이터가 없으면 home
    if (loginMember == null) {
        return "home";
    }

    //세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
    }

우리는 세션에서 로그인 회원을 찾을 필요가 없이 @Login 어노테이션이 있다면 자동으로 ArgumentResolver가 작동하여 세션에서 회원을 찾아 객체에 넣어주는 기능을 구현하고 싶다.
아래와 같이 구현한다면 가능하다.

  
    @Target(ElementType.PARAMETER)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface Login {
    }

일단 먼저 사용할 어노테이션을 새로 만들어준다. 그리고 ArgumentResolver를 구현한 뒤 등록해주어야 한다.

  
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    private static final Logger log = LoggerFactory.getLogger(LoginMemberArgumentResolver.class);

    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        log.info("supportsParameter 실행");
        boolean hasLoginAnnotation = methodParameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(methodParameter.getParameterType());
        return hasMemberType && hasLoginAnnotation;
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
        log.info("resolveArguement 실행");
        HttpServletRequest request=(HttpServletRequest) nativeWebRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if(session==null) return null;
        Object attribute = session.getAttribute(SessionConst.LOGIN_MEMBER);

        return attribute;
    }
}

HandlerMethodArgumentResolver 인터페이스를 받아 구현한 코드이다.
supportsParameter() 메서드는 매개변수가 원하는 어노테이션이 붙어있는지, 매개변수의 타입이 원하는 타입이 맞는지를 검사하는 역할을 한다. 조건이 모두 맞다면 ArgumentResolver가 동작한다.
resolveArgument()는 컨트롤러 호출 직전에 호출되어 필요한 매개변수를 생성해준다. 이 코드에서는 조건이 맞는 매개변수라면 세션에서 로그인된 회원을 찾아서 반환하는 역할을 한다.
이후 스프링 MVC에서 반환된 객체를 매개변수에 전달해준다.

  
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }

등록은 마찬가지로 WebMvcConfigurer 인터페이스의 addArgumentResolvers() 메서드를 구현하여 진행하면 된다.

이렇게 ArgumentResolver를 적절히 사용한다면 코드 여러군데에서 자주 사용되는 기능을 중복 없이, 더 편리하게 사용할 수 있다.

로그인 파트가 끝이 났다. 로그인 기능을 구현하는 쿠키와 세션 그리고 로그인에 필요한 여러 공통 관심 사항을 편리하게 처리하는 필터와 인터셉터에 대해 배웠으니 이제 로그인 기능을 무리 없이 구현할 수 있을 것 같다는 생각이 든다.
다음 포스팅에서는 스프링의 예외 처리에 대해 알아볼 예정이다.