서블릿 필터(Filter) vs 스프링 인터셉터(Intercepter) vs AOP 본문

백앤드 개발일지/스프링부트

서블릿 필터(Filter) vs 스프링 인터셉터(Intercepter) vs AOP

giron 2021. 9. 19. 19:48
728x90

WHEN? 언제 무엇이 왜 사용되는가? 

- 로그인 처리 방식에 대해서 고민하면서 필터와 인터셉터를 공부했는데 둘이 비슷하다고 생각되고 둘 중에 어느 때에 무엇을 선택해야 할지 명확한 해답이 안 나와서 글을 적으면서 정리해보려고 한다. 

 

 *공부를 하던 중 AOP와도 비교를 하는 글들이 보여서 짧게 추가해보았다.

(개인적인 생각!) 우선 AOP는 앞서 필터와 인터셉터와는 다르게 비즈니스적 관점에서 사용할 때, 사용된다고 생각된다. ex) 로직의 시간 측정, 트랜잭션 관리, 에러 처리 등,

반면에 필터나 인터셉터는 인증/인가, 세션 체크, 인코딩 확인 등 좀더 과 관련된 공통 관심사 처리하는 느낌으로 구별하면 될 것 같다.

 

이제 본격적으로 필터와 인터셉터를 분석해 보겠다.

제목에서도 보았듯이 필터는 Dispatcher Servlet에서 제공하고 인터셉터는 스프링 MVC가 제공하는 기술이다.

필터

  • 필터를 적용하면 필터가 호출된 후에 서블릿이 호출된다.
  • 만약 적절하지 않는 호출이 오면 자신의 상태에서 종료할 수 있다.
  • 또한 체인이 가능하다. ex) 로그를 남기는 필터 적용 후, 로그인 여부를 체크하는 필터를 적용할 수 있다.

메서드

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() {}
}
  • default 메서드인 init, destroy는 따로 구현하지 않아도 된다. (필수 X)
  • init(): 필터 초기화 메서드로 서블릿 컨테이너가 생성될 때 호출된다.
  • doFilter(): 요청이 들어올 때마다 해당 메서드가 호출된다.
  • destroy(): 필터 종료 메서드로서 컨테이너가 종료될 때 호출된다.

1. 요청에 대해 로그를 남기는 필터

LogFilter

@Slf4j
public class LogFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }

    @Override
    public void doFilter(HttpServletRequest httpRequest, HttpServletResponse httpResponse, FilterChain chain) throws IOException, ServletException {
        log.info("log filter doFilter");

        String requestURI = httpRequest.getRequestURI();
        String uuid = UUID.randomUUID().toString();

        try{
            log.info("REQUEST [{}][{}]", uuid, requestURI);
            chain.doFilter(httpRequest, httpResponse);
        }catch(Exception e){
            throw e;
        }finally {
            log.info("RESPONSE [{}][{}]", uuid, requestURI);
        }
    }

    @Override
    public void destroy() {
        log.info("log filter destroy");
    }
}
  • doFilter
    • HTTP요청이 오면 작동한다. (HTTP통신이라고 가정하고 HttpServletRequest를 사용했으므로, Http요청이 아닌 경우에는 ServletRequest/Response를 사용하면 된다. 
    • 예측할 수 없을 땐, 미리 ServletRequest/Response를 사용하고 다운캐스팅 사용하자!(HttpServletRequest) reques
  • UUID.randomUUID().toString()
    • HTTP 요청을 구분하기 위해 요청당 임의의 uuid를 만든다. (uuid로 만든 값은 중복 확률이 희박하다고 한다!)
  • chain.doFilter()
    • 다음 필터가 있으면 다음 필터를 호출하고 없으면 서블릿을 호출한다.
    • 이 로직이 없으면 더 이상 진행이 안되므로 꼭 적어야 한다.

WebConfig

Configuration으로 만든 필터를 빈으로 등록해주자.

@Configuration
public class WebConfig implements WebMvcConfigurer {
		@Bean
		public FilterRegistrationBean logFilter() {
		    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
		    filterRegistrationBean.setFilter(new LogFilter());
		    filterRegistrationBean.setOrder(1);
		    filterRegistrationBean.addUrlPatterns("/*");
		
		    return filterRegistrationBean;
		}
}
  • setFilter: 등록할 필터를 지정한다. (위에서 만든 LogFilter())
  • setOrder(1): 필터는 체인으로 동작하므로 순서가 필요하다. 낮은 번호일수록 먼저 동작한다.
  • addUrlPatterns("/*"): 필터를 적용할 URL 패턴을 지정한다. 하나 이상의 패턴 지정하기도 가능

2. 로그인 인증 체크 필터

LoginCheckFilter

whitelist를 제외한 경로는 모두 로그인을 상태를 검사한다.

@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);
                    //로그인으로 리다이렉트
                    httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    return;
                }
            }
            chain.doFilter(request, response);

        } catch (Exception e) {
            throw e;
        } finally {
            log.info("인증 체크 필터 종료 {}", requestURI);
        }
    }

    /**
     * whiteList의 경우 인증 체크를 안하도록 한다.
     */
    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }

}
  • isLoginCheckPath(): 매개변수로 전달받은 requestURI가 화이트리스트와 일치하는지 검사한다. 
  • httpResponse.sendRedirect(): 로그인을 안 한 상태로 보고 있는 페이지에서 로그인을 하고 오면 다시 그 페이지로 돌아가기 위해 쿼리스트링을 사용했다.
  • return: 빼먹지 말고 적어줘야 한다. 필터가 더 이상 진행되지 않음을 알린다.

WebConfig

위에서 작성했던, Webconfig에 추가해준다.

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

    return filterRegistrationBean;
}
  • 위와 같이 만든 LoginCheckFilter를 적용해주고, order순서는 2번째로 해준다.
  • 허용 URI를 /*로 모두 허용했지만 필터 내부에 화이트리스트가 다시 검사하므로 괜찮다.

Login 컨트롤러

@Validated검증 애노테이션을 사용해 구현한다.

@PostMapping("login")
public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, @RequestParam(defaultValue = "/") String redirectURL, HttpServletResponse response, 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);

    if (redirectURL != null) {
        return "redirect:" + redirectURL;
    }

    return "redirect:/";
}

아래는 HttpSession에 데이터를 보관하고 조회할 때, 같은 이름이 중복 되어 사용되므로, 상수로 정의

public abstract class SessionConst {
    public static final String LOGIN_MEMBER = "loginMember";

}
    • 로그인이 성공했을 경우 redirectURL이라는 @RequestParam을 조회해 만약 다른 페이지로 접근을 시도하다 로그인 페이지로 온 경우 다시 되돌아간다.
    • BindingResult: 검증 오류가 발생할 경우 오류 내용을 보관하는 스프링 프레임워크에서 제공하는 객체입니다.
    • BindingResult 객체의 파라미터 위치는 반드시 @ModelAttribute 어노테이션이 붙은 객체 다음에 위치해야 한다는 점입니다.- (@ModelAttribute A, BindingResult B) - 이 이유는 정확히 모르겠다..🤔 혹시 아는 사람 있으면 댓글로 알려주시면 감사하겠습니다! 이유는 아래에 바로 적겠다. 검증 순서와 관련이 있었다!!

검증 순서(@Validate)

1. @ModelAttribute 각각의 필드에 타입 변환 시도

  • 성공하면 다음 필드 진행
  • 실패하면 BindingResult에 typeMismatch로 FieldError 추가

2. Validator 적용

즉, 각각의 필드에 바인딩이 된 필드만 Bean Validation이 적용된다.

---

인터셉터

  • 필터와 유사하게 인터셉터도 요청이 적절하지 않을 경우 자신의 상태에서 종료할 수 있다. (공통점)
  • 자유롭게 체인을 추가할 수 있다.(공통점) ex) 필터-인터셉터-필터 (공통점)
  • 인터셉터는 서블릿까지 통과 후 제한이 된다. (차이점)
  • 인터셉터는 서블릿 호출 이후 호출되므로 이전에 서블릿에서 예외가 발생하면 호출되지 않는다.

인터셉터 인터페이스

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

		return true;
	}

	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 {
	}
}
  • 필터는 doFilter 하나로 수행했지만 인터셉터는 3가지 단계로 호출한다.
  • PreHandler(컨트롤러 호출 전): 반환 타입이 boolean으로서 반환 값이 false이면 진행을 멈춘다.
  • PostHandle(컨트롤러 호출 후): 핸들러 어댑터 호출 후 호출된다.
  • afterCompletion(요청 완료 후): 뷰가 렌더링 된 후에 호출된다. 항상 호출되므로 예외 처리가 필요하다면 afterCompletion을 사용해야 한다.

LogIntercepter

필터로 했던 로그 기록 남기기를 스프링 인터셉터로 구현하기

@Slf4j
public class LoginInterceptor 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("pohstHandler [{}]", 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("RESPONSE [{}][{}][{}]", uuid, requestURI, handler);

        if (ex != null) {
            log.error("afterCompletion error:", ex);
        }
    }
}
  • request.setAttribute(): 필터와 다르게 메서드가 분리되어 있으므로 변수들의 값이 유지가 되지 않는다. 따라서 preHandler에서 지정한 갑을 이후 2 메서드에서도 사용하려고 request 인스턴스에 담아둔 것이다. 이 인터셉터는 싱글톤처럼 사용되기 때문에 멤버 변수를 사용하면 안 된다고 한다.(공유되기 때문이다.) request에 다음은 LOG_ID는 afterCompletion에서 getAttribute로 찾아 사용한다.
  • HandlerMethod hm = (HandlerMethod) handler; : @Controller, @RequestMapping을 활용해 들어올 경우, 스프링 인터셉터의 Object handler 매개변수에는 핸들러 정보로 HandlerMethod가 넘어온다.

WebConfig

생성한 스프링 인터셉터를 빈에 등록해준다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(logInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
    }

    @Bean
    public LoginInterceptor logInterceptor() {
        return new LogInterceptor();
    }
}
  • WebMvcConfigurer인터페이스를 상속받아 addIntercepter를 재정의하여 인터셉터 등록이 가능하다.
  • order(1): 필터 때와 마찬가지로 낮은 순서가 먼저 호출된다.
  • addPathPatterns("/**"): 인터셉터에 적용 할 URL 패턴 (모두 적용한다.)
  • excludePathPatterns(): 인터셉터에서 제외할 패턴을 지정한다.

ArgumentResolver

클라이언트로 받은 Request정보에서 Session정보를 꺼내 해당하는 세션키로 로그인 정보를 찾는 방법이다.

자동으로 세션에 있는 로그인 회원을 찾아주되 만약 세션이 없는 경우 null을 반환하는 기능을 가진 애노테이션 구현

@Login

@Target(ElementType.PARAMETER) // 파라미터에만 붙힐 수 있는 애노테이션이다.
@Retention(RetentionPolicy.RUNTIME) // 리플랙션을 할용할 수 있도록 런타임까지 애노테이션 정보가 남아있도록 해준다.
public @interface Login {
}

LoginMemberArgumentResolver

LoginMemberArgumentResolver는 HandlerMethodArgumentResolver를 구현하면 된다.

@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());
		//login 애노테이션이 붙어있고, Member객체인 경우 지원이 가능하게 해준다.
        return hasLoginAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        log.info("resolverArgument 실행");

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

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}
  • supportsParameter(): 각각의 ArgumentResolver는 이 메서드를 이용해 매핑 가능 여부를 boolean 타입으로 반환한다.
  • resolverArgument(): 컨트롤러에 필요한 파라미터 정보를 생성해주는 메서드로, 여기서는 세션에서 로그인 회원 정보인 member 객체를 찾아 반환해준다.

WebMvc

WebMvcConfigure에 리졸버를 등록해줘야 한다.

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

		//...
}

정리를 해보니 나는 잘 이해했는데 글의 핵심을 이해하기 어려운것 같다.. (제목이 차이인데 둘다 구현해보고 있으니..)

그래서 다른 블로그에서 명확한 표가 있길래 핵심만 파악하실 분들은 아래 표와 그림을 보면 이해하기 수월할 것이다!!!👍

 

Refernce

 

[카카오 면접] Spring Filter, Interceptor, AOP

카카오 면접을 준비하면서, 공부했던 내용을 정리해놓고 다시 기억하기 위한 포스팅 자바 웹 개발을 하다보면, 공통적으로 처리해야 할 업무들이 많다. 예를들어 로그인 관련(세션체크)처리,

baek-kim-dev.site

 

[Spring] 필터(Filter) vs 인터셉터(Interceptor) 차이 및 용도

Spring은 공통적으로 여러 작업을 처리함으로써 중복된 코드를 제거할 수 있도록 많은 기능들을 지원하고 있다. 이번에는 그 중에서 필터(Filter) vs 인터셉터(Interceptor)의 차이에 대해 알아보고자

mangkyu.tistory.com

  • 인프런 스프링 MVC 2편 (김영한 강사님)
728x90
Comments