Filter & Interceptors

로그인/회원 가입 기능을 구현하고 상품 관리 기능은 로그인 한 사용자만 이용할 수 있도록 하는 요구사항이 있다고 가정하자.

그러면, 매번 상품 관리 창을 접속하는 요청에 대해서 로그인 여부를 조사해야한다. 그러면, 상품 등록,수정,삭제 창에서의 접속에 대해 로그인 여부를 검증하는 로직을 작성해서 구현할 수 있다. 하지만 이렇게 모든 부분에 코드로 직접 구현하는 부분을 첨가하게 되면 구현해야 될 코드의 양이 많을 뿐더러, 로그인 로직이 변경되게 되면 적용한 모든 로그인 검증 코드를 수정해야한다.

이와 같이 여러 로직에서 공통으로 처리해야되는 로직을 공통 관심사라고 한다. AOP를 이용한 공통 관심사를 처리할 수 있지만, 웹 관련된 기능을 활용하기 위해서는 필터와 인터셉터를 활용하는 것이 좋다.

Filter

동작 과정

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출X) //비 로그인 사용자
HTTP 요청 -> WAS -> 필터1-> 필터2->필터3->서블릿 -> 컨트롤러

필터는 위의 요청 프로세스 과정을 살펴봤을 때, 서블릿 디스패처가 실행되기 전에 호출된다. 해당 필터를 이용해서 모든 고객에 대한 요청을 남길 수도 있고, 특정 요청에 대한 제한을 걸수도 있다. 또한 여러 필터를 순차적으로 적용하는 것도 가능하다.

필터 인터페이스

public interface Filter {
    //init() 필터 초기화 메서드로, 서블릿 컨테이너가 실행되기 전에 실행된다.
    public default void init(FilterConfig filterConfig) throws ServletException
    {}

    //고객의 요청이 올때 마다 실행되는 메소드로, 필터의 핵심 로직이다.
    public void doFilter(ServletRequest request, ServletResponse response,
    FilterChain chain) throws IOException, ServletException;

    //필터의 종료 메서드, 서블릿 컨테이너가 종료될 때 실행된다.
    public default void destroy() {}
}

Log Filter

Filter

@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 httpServletRequest = (HttpServletRequest) request;
        String requestURI = httpServletRequest.getRequestURI();
        String uuid = UUID.randomUUID().toString();
        try{
            log.info("REQUEST: [{}][{}]",uuid,requestURI);
            //chain.doFilter을 수행하게 되면 다음 필터가 호출된다. 없으면 다음 로직으로 넘어간다.
            chain.doFilter(request,response);
        }catch(Exception e){
            throw e;
        }
        finally{
            log.info("RESPONSE: [{}][{}]",uuid,requestURI);
        }

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

위와 같이 필터의 모든 메소드를 구현해서 로그를 남기도록 한다. 이때, 필터의 핵심 로직인, doFilter에서는 requestURI 와 uuid을 통해 요청 url과 식별자 정보를 로그로 남긴다. ServletRequest는 HttpServletRequest가 상속하는 부모 클래스로, HttpServletRequest으로 다운 캐스팅 하는 것이 가능하다.

Filter Register

@Configuration
    public class WebConfig {
        @Bean
        public FilterRegistrationBean logFilter() {
            FilterRegistrationBean<Filter> filterRegistrationBean = new
            FilterRegistrationBean<>();
            //setFilter을 이용해서 위의 logfilter을 등록해준다.
            filterRegistrationBean.setFilter(new LogFilter());
            //filter의 순서를 지정해준다.
            filterRegistrationBean.setOrder(1);
            //해당 필터를 적용할 URL 패턴을 지정한다. (/*)으로 설정하면 모든 요청에 대해 필터를 적용하게 된다.
            filterRegistrationBean.addUrlPatterns("/*");
            return filterRegistrationBean;
    }
}

LoginCheckFilter

Filter

@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 httpServletRequest = (HttpServletRequest) request;
        String requestURI = httpServletRequest.getRequestURI();
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;

        try{
            log.info("인증 체크 필터 시작 {}", request);
            if(isLoginCheckPath(requestURI)){
                log.info("인증 체크 로직 실행 {}", requestURI);
                HttpSession session = httpServletRequest.getSession(false);
                if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
                    log.info("미인증 사용자 요청:{}", requestURI);
                    //로그인 되지 않는 사용자는 로그인 창으로 보내는데, 이때 redirectURL을 쿼리 파라미터로 전달해서 로그인 후 다시 해당 창으로 돌아올 수 있도록 한다.
                    httpServletResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    //doFilter 대신에 return을 호출하게 되면 더 이상의 필터 처리 과정을 진행하지 않는다, 필터 뿐만아니라 서블릿, 컨트롤러 모두 실행되지 않는다.
                    return;
                }
            }
            chain.doFilter(request,response);
        }
        catch(Exception e){
            throw e;
        }
        finally{
            log.info("인증 체크 필터 종료: {} ", requestURI);
        }
    }

    private boolean isLoginCheckPath(String requestURI){
        //특정 url pattern이 whitelist에 포함되어 있는지 여부를 알아내기 위한 메소드
        return !PatternMatchUtils.simpleMatch(whiteList, requestURI);
    }
}

위의 filter을 활용해서 로그인 수행 여부를 조사할 수 있다. 이때, whitelist 리스트를 관리해서, 로그인이 필요없는 요청에 대해서는 검증 로직을 수행하지 않도록 한다.

로그인 여부는 클라이언트와 서버 간에 세션이 존재하는지 여부와 해당 세션에 멤버 값이 저장되어 있는지 확인하는 방식으로 진행한다.

로그인을 수행하고 나면 다시 사용자를 이전 창으로 redirect할 수도 있도록 해야한다.

Filter Register

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

Handling Redirect URL

@PostMapping("/login")
public String loginV4(
@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, @RequestParam(defaultValue = "/") String redirectURL, HttpServletRequest request) {
    ....
    //로그인 성공 처리
    //세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
    HttpSession session = request.getSession();
    //세션에 로그인 회원 정보 보관
    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
    //redirectURL 적용
    return "redirect:" + redirectURL;
}

기존의 Login Controller에서 query Parameter을 받아서 해당 url로 redirect을 할 수 있도록 지정한다.

Spring Interceptor

스프링 인터셉터 또한 동일한 기능을 제공하지만, 조금 더 효과적으로 해결할 수 있도록 지원한다.

동작 과정

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출
X) // 비 로그인 사용자

스프링 인터셉터 체인
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러

Spring Interceptor의 경우 Spring MVC가 제공하는 기능으로, 디스패처 서블릿 이후에 실행된다.

서블릿 필털와 매우 유사한 특징을 가지는데, 조금 더 정교하게 기능을 설계할 수 있다는 장점이 있다.

Spring Interceptor

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 {}
}

스프링 인터셉터 위와 같이 실행 시점에 따라 세분화되어 있다. postHandler, afterCompletion을 보면 handler 정보도 인자로 포함되어 있어 실행되는 핸들러의 정보도 조회할 수 있다.

spring_interceptor_mechanism

위의 Spring MVC 동작과정에서 Spring Interceptor가 어디에서 실행되는지 확인할 수 있다.

spring_interceptor_error_mechanism

컨틀롤러에서 예외가 발생하게 되면 postHandler는 실행되지 않지만, afterCompletion에 어떠한 경우에도 실행된다. 또한, 인자로, Exception 객체를 전달받기 때문에, 모든 예외에 대한 공통적으로 처리를 하기 위해서는 afterCompletion 메소드를 이용해야한다.

Spring MVC를 이용하는 환경이라면, Spring Interceptor을 이용하는 것이 효율적이다.

LogInterceptor

Interceptor

@Slf4j
public class LogInterceptor implements HandlerInterceptor {

    public static final String LOG_ID = "logId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();


        request.setAttribute(LOG_ID, uuid);

        if (handler instanceof HandlerMethod){
            HandlerMethod hm = (HandlerMethod) handler;
        }

        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);

        //return true를 수행하면 다음 로직이 정상적으로 실행된다.
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("post handle: {}", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        String uuid =(String) request.getAttribute(LOG_ID);

        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
        if (ex == null) {
            log.error("after Completion error!", ex);
        }

    }
}

각 메소드에 대해 uuid를 공유하기 위해, 지역변수에 uuid를 저장해서 이를 공유하는 방식으로 생각해 볼 수 있지만, Interceptor는 싱글톤으로 관리되기 때문에 state를 남겨서는 안된다. 따라서, request에 setAttribute을 통해 해당 request의 attribute에 저장해서 서로 공유하도록 한다.

Handlers

  • HandlerMethod
    • @Controller, @RequestMapping을 활용한 핸들러의 경우 HandlerMethod을 통해 Handler 정보가 넘어오게 된다.
  • ResourceHttpRequestHandler
    • @Controller가 아닌, /resources/static와 같은 정적 리소스가 호출되는 경우, ResourceHttpRequestHandler을 통해 핸들러 정보가 전달된다.

Interceptor Register

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())//인터셉터 등록
                .order(1) //인터셉터 순서 등록
                .addPathPatterns("/**") // 인터셉터를 적용할 url 패턴 등록
                .excludePathPatterns("/css/**", "/*.ico", "/error");//인터셉터를 적용하지 않을 패턴 지정
    }
}

이전에 생성한 WebConfig 파일에서, WebMvcConfigurer가 제공하는 addInterceptors을 이용해서 Interceptor을 등록한다.

위를 보면 필터와 달리 인터셉터는 등록 단계에서 인터셉터를 적용할 패턴, 적용하지 않을 패턴을 지정할 수 있다.

Url Pattern

? 한 문자 일치
* 경로(/) 안에서 0개 이상의 문자 일치
** 경로 끝까지 0개 이상의 경로(/) 일치
{spring} 경로(/)와 일치하고 spring이라는 변수로 캡처
{spring:[a-z]+} matches the regexp [a-z]+ as a path variable named "spring"
{spring:[a-z]+} regexp [a-z]+ 와 일치하고, "spring" 경로 변수로 캡처
{*spring} 경로가 끝날 때 까지 0개 이상의 경로(/)와 일치하고 spring이라는 변수로 캡처
/pages/t?st.html — matches /pages/test.html, /pages/tXst.html but not /pages/
toast.html
/resources/*.png — matches all .png files in the resources directory
/resources/** — matches all files underneath the /resources/ path, including /
resources/image.png and /resources/css/spring.css
/resources/{*path} — matches all files underneath the /resources/ path and
captures their relative path in a variable named "path"; /resources/image.png
will match with "path" → "/image.png", and /resources/css/spring.css will match
with "path" → "/css/spring.css"
/resources/{filename:\\w+}.dat will match /resources/spring.dat and assign the
value "spring" to the filename variable

스프링에서 제공하는 URL 패턴은 위와 같다.

LoginCheckInterceptor

Interceptor

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

        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
            log.info("미 인증 사용자 요청");
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }

        return true;
    }
}

필터와 비교해, 더 간결하게 코드를 구현할 수 있다. 화이트 리스트 인지 여부를 판단할 필요도 없고, parameter로 HttpServletRequest가 전달되기 때문에 다운캐스팅도 필요하지 않다.

Interceptor Register

@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/*","/error");
}

Argument Resolver 만들어보기

LoginMemberArgumentResolver을 구현해서 기존의

@SessionAttribute(name=SessionConst.LOGIN_MEMBER,required = false)

@SessionAttribute 방식을 @Login annotation 방식으로 로그인된 회원 정보를 조회할 수 있다.

Home Controller

@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member member, Model model) {
    if (member == null) {
        return "home";
    }

    model.addAttribute("member", member);
    return "loginHome";
}

@Login Annotaion

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

|Annotations|Descriptions| |–|–| |@Target(ElementType.PARAMETER)|파라미터에만 적용할 수 있도록 한다.| |@Retention(RetentionPolicy.RUNTIME)|런타임까지 annotation이 남아있도록 한다.|

LoginMemberArgumentResolver

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");
        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasMember = Member.class.isAssignableFrom(parameter.getParameterType());

        return hasLoginAnnotation && hasMember;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        log.info("resolve Argument 실행");

        HttpServletRequest request =(HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);

        if (session == null) {
            return null;
        }

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}

|Methods|Descriptions| |–|–| |supportParameter|파라미터가 해당 argument resolver에서 활용될 수 있는지 여부를 반환한다. 여기서는 @Login, Member 타입에 대한 파라미터를 처리할 수 있다.| |resolveArgument|컨트롤러가 수행되기 전에 Argument Resolver을 통해 해당 필요한 파라미터를 생성해서 전달한다.|

Register Argument Resolver

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }
}

References

link: inflearn

link:springmvc

댓글남기기