Filter, Interceptor

YH·2023년 2월 27일
0

들어가기 전

  • 애플리케이션의 여러 로직에서 공통으로 관심 있는 것을 공통 관심사(cross-cutting)라고 함
    이러한 공통 관심사를 처리할 때 사용되는 것이 Filter, Interceptor, AOP 임.
  • 특히 웹과 관련된 공통 관심사를 처리할 때는 Filter(서블릿) 또는 Interceptor(스프링)을 사용하는 것이 좋음
  • 웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL 정보들이 필요한데, Filter나 Interceptor는 HttpServletRequest를 제공 함

✅ Filter (DispatcherServlet)

✔️ 필터란, J2EE 표준 스펙 기능으로 디스패처 서블릿(Dispatcher Servlet)에 요청이 전달되기 전/후에 URL 패턴에 맞는 모든 요청에 대해 부가작업을 처리할 수 있는 기능을 제공한다.

  1. Filter의 흐름

    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러

  2. Filter 사용
  • 필터를 적용하면 필터가 적용된 다음 서블릿이 호출
  • 모든 고객의 요청 로그를 남기는 요구사항 등의 경우 필터를 사용하면 적합 함
  • 필터는 특정 URL 패턴에 적용 가능하며, /* 라고 하면 모든 요청에 필터가 적용 됨
  1. Filter 제한

    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 (적절한 요청)
    HTTP 요청 -> WAS -> 필터(적절하지 않은 요청, 서블릿 호출하지 않음)

  2. Filter 체인
  • 필터는 체인으로 구성되며, 여러 필터를 자유롭게 추가 가능 함.
  • 예시) 로그를 남기는 필터를 적용하고, 로그인 여부를 체크 하는 필터를 적용

    HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러

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(): 고객의 요청이 올 때 마다 해당 메소드가 호출 됨. 필터의 로직을 구현하는 부분
  • destory(): 필터 종료 메소드, 서블릿 컨테이너가 종료될 때 호출 됨

WebConfig - 필터 설정

@Configuration
public class WebConfig {

    @Bean
    public FilterRegistrationBean logFilter() {
      FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
      filterRegistrationBean.setFilter(new LogFilter());
      filterRegistrationBean.setOrder(1);
      filterRegistrationBean.addUrlPatterns("/*");
      
      return filterRegistrationBean;
	}
}
  • 필터를 등록하는 방법은 여러가지가 있지만, 스프링 부트를 사용하면 FilterRegistrationBean을 사용해서 등록하면 된다.
  • SetFilter(new LogFilter()) : 등록할 필터를 지정한다.
  • SetOrder(1) : 필터를 동작할 순서를 등록한다. 낮을 수록 먼저 동작한다.
  • addUrlPatterns("/*") : 필터를 적용할 URL 패턴을 지정한다. 한 번에 여러 패턴 지정도 가능하다.

💡 참고 1 : URL 패턴은 필터와 서블릿이 동일하다. 서플릿 URL 패턴을 참고하자.

💡 참고 2 : 실무에서 HTTP 요청 시 해당 요청에 대한 로그에 모두 같은 식별자를 남기고 싶은 경우, logback mdc 를 사용하면 된다. 사용 방법은 검색해서 알아보자.

로그인 필터 예제

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

    /**
     * 화이트 리스트의 경우 인증 체크 패스
     * @param requestURI {@link String}
     * @return boolean {@link Boolean}
     */
    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
}
  • httpResponse.sendRedirect("/login?redirectURL=" + requestURI); : 미인증 사용자가 로그인 후 자신이 보고 있던 화면을 그대로 보여주기 위해 현재 요청 경로인 reuqestURI를 쿼리 파라미터로 함께 전달한다.

💡 참고

  • 필터는 chain.doFilter(request, response)를 호출하여 다음 필터 또는 서블릿을 호출할 때 request, response에 다른 객체로 바꾸어서 전달할 수 있다. ServletRequest, ServletResponse를 구현한 다른 객체를 넘기면 해당 객체가 다음 필터 또는 서블릿에서 사용된다. 잘 사용되지는 않는다.

✅ Interceptor (Spring MVC)

✔️ 인터셉터란, Spring이 제공하는 기술로써 디스패처 서블릿(Dispatcher Servlet)이 컨트롤러를 호출하기 전과 후요청과 응답을 참조하거나 가공할 수 있는 기능을 제공한다.
✔️ 웹 컨테이너(서블릿 컨테이너)에서 동작하는 필터와 달리 인터셉터스프링 컨텍스트에서 동작한다.

  1. Interceptor의 흐름

    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> Interceptor -> 컨트롤러

  2. Interceptor 사용
  • 인터셉터는 DispatcherServlet과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출 됨
  • 인터셉터는 스프링 MVC가 제공하므로 DispatcherServlet 이후에 동작한다. 스프링 MVC의 시작점이 DispatcherServlet이라고 보면 된다.
  • 인터셉터에도 URL 패턴을 적용할 수 있는데, 필터(서블릿)의 URL 패턴과는 다르고, 정밀하게 설정 가능 함
  1. Interceptor 제한

    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> Interceptor -> 컨트롤러 (적절한 요청)
    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> Interceptor(적절하지 않은 요청, 컨트롤러 호출하지 않음)

  2. Interceptor 체인

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

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(HttpServeltReqeust request
    , HttpServletResponse response
    , Object handler
    , @Nullable Exception ex) throws Exception {}
  • 서블릿 필터는 doFilter() 하나만 제공되지만 인터셉터는 호출 전(preHandle), 호출 후(postHandle), 요청 완료 이후(afterCompletion) 와 같이 단계적으로 세분화 되어 있다.
  • 또한, 인터셉터에는 호출된 컨트롤러(handler)반환된 ModelAndView도 받을 수 있다.

WebConfig - 인터셉터 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
	@Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
    }
}
  • WebMvcConfigurer가 제공하는 addInterceptors()를 사용해서 인터셉터를 등록할 수 있다.

  • order(1) : 호출할 인터셉터의 순서를 지정한다. 낮을 수록 먼저 호출된다.

  • addPathPatterns("/**") : 인터셉터를 적용할 URL 패턴을 지정한다.

  • excludePathPatterns("/css/**", "/*.ico", "/error") : 인터셉터에서 제외할 패턴을 지정한다.

  • 💡 참고 : 스프링이 제공하는 URL 경로는 서블릿 URL 경로와 완전히 다르다. 스프링에서 제공하는 URL이 더욱 자세하고 세밀하게 설정할 수 있다.

로그 인터셉터 예제

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

        //@RequestMapping : HandlerMethod
        //정적 리소스 : ResourceHttpRequestHandler
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메소드의 모든 정보가 포함되어 있다.
        }

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

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

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        String logId = (String) request.getAttribute(LOG_ID);
        log.info("RESPONSE [{}][{}]", logId, requestURI);

        if (ex != null) {
            log.error("afterCompletion error!!", ex);
        }
    }
}
  • String uuid = UUID.randomUUID().toString() : 요청 로그를 구분하기 위한 uuid
  • request.setAttribute(LOG_ID, uuid); : 인터셉터는 각 메소드의 호출 시점이 분리되어 있기 때문에 preHandle()에서 사용했던 값을 다른 메소드에서 사용하기 위해 request.Settribute()를 통해 request에 담아서 사용할 수 있다.
  • 주의할 점은 인터셉터도 싱글톤 처럼 사용되기 때문에 멤버변수를 사용하면 위험하다.
  • handler 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라진다. @RequestMapping이나 @Controller를 사용하면 HandlerMethod가 넘어온다.
  • /resources/static와 같은 정적 리소스가 호출되면 ResourceHttpRequestHandler가 넘어온다.

로그인 인터셉터 예제

@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("미인증 사용자 요청");
            //로그인으로 redirect
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }

        return true;
    }

Interceptor 호출 흐름

  • preHandle: 컨트롤러 호출 전에 호출 됨 (정확히는 핸들러 어댑터 호출 전)
    • preHandle 응답 값이 true이면 다음으로 진행하고, false 이면 더는 진행하지 않음
    • false인 경우, 1번에서 끝남
  • postHandle: 컨트롤러 호출 후에 호출 됨 (정확히는 핸들러 어댑터 호출 후)
  • afterCompletion: 뷰가 렌더링 된 이후에 호출 됨

Interceptor 예외 상황

  • 예외 발생 시
    • preHandle: 컨트롤러 호출 전에 호출 됨
    • postHandle: 컨트롤러에서 예외가 발생하면 postHandler은 호출되지 않음
    • afterCompletion: afterCompletion은 항상 호출 됨. 예외 발생 시 ex를 포함해서 호출되며, 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력 가능
      • 예외가 발생하면 preHandle()은 호출되지 않으므로, 예외와 무관하게 처리하려면 afterCompltion()을 사용해야 함

Filter 와 Interceptor 차이점

  1. Filter는 서블릿, Interceptor는 Spring MVC에서 제공하는 기술이다
  2. 호출 시점이 다르다
    • Filter는 WAS와 서블릿 사이, Interceptor는 서블릿과 컨트롤러 사이
  3. Interceptor가 좀 더 세분화 되어 있다.
    • Filter의 경우 doFilter 하나만 제공되지만, Interceptor는 호출 전(preHandle), 호출 후(postHandle), 요청 완료 이후(afterCompletion)와 같이 세분화 되어 있음
  4. 제공되는 객체의 종류가 다르다.
    • Filter는 request, response만 있는 반면, Interceptor는 handler, modelAndView, Exception 등 다양한 정보가 더 들어있음

정리

Interceptor는 스프링 MVC 구조에 특화된 Filter 기능을 제공한다고 보면 됨. 특별히 Filter를 사용해야 하는 상황이 아니면, Interceptor를 사용하는 것이 편리함

✅ ArgumentResolver 활용

✔️ 이 전에 포스팅했던 로그인 처리 - 쿠키 세션에서 로그인 회원 정보를 찾을 때 아래와 같이 사용했었다.

//HomeController
public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginmember,
                                    Model model) {
        if (loginmember == null) {
            return "home";
        }

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

✔️ 위 방법보다 좀 더 편리하게 회원 정보를 찾을 수 있도록 ArgumentResolver를 사용하여 작성해본다.

//HomeController
@GetMapping("/")
    public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {

        //세션에 회원 데이터가 없으면 home
        if (loginMember == null) {
            return "home";
        }

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

//@Login Annotation
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
  • 위 코드에서는 직접 만든 @Login 이라는 어노테이션을 사용한다.
  • 아래와 같이 @Login 어노테이션이 있으면 직접 만든 LoginMemberArgumentResolver 가 동작해서 자동으로 세션에 있는 로그인 회원을 찾아주고 없다면 null을 반환해주도록 ArgumentResolver를 만들어본다.

✔️ 커스텀 ArgumentResolver

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");

        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());

        return hasLoginAnnotation && hasMemberType;
    }

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

        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}
  • supportsParameter() : @Login 어노테이션이 있고 Member 타입이면 해당 LoginMemberArgumentResolver가 실행된다.

  • resolveArgument() : 컨트롤러 호출 직전에 호출되어서 필요한 파라미터 정보를 생성해준다. 위 예제에서는 세션에 있는 member 객체를 찾아서 반환해준다. 이후 스프링 MVC에서 컨트롤러 메소드를 호출하면서 해당 member 객체를 파라미터에 전달해준다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

정리

  • 위와 같이 공통 작업이 필요할 때 ArgumentResvoler를 활용하면 컨트롤러를 더 편리하게 사용할 수 있다.

참고 Reference

profile
하루하루 꾸준히 포기하지 말고

0개의 댓글