[Spring] 필터와 인터셉터 동작 과정

Loopy·2023년 3월 11일
0
post-thumbnail

1️⃣ Spring Filter

애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사라고 한다.
스프링 AOP를 사용해도 되지만, 웹과 관련된 공통 관심사를 처리할때는 서블릿에서 제공해주는 필터(Filter) 혹은 스프링에서 제공해주는 인터셉터를 이용하면 좋다.

HTTP HeaderURL 정보들이 관심사를 처리하는 로직에 필요하기 때문이다. 필터나 인터셉터는 HttpServletRequest 과 같은 웹과 관련된 부가 기능들을 제공해준다.

☁️ Filter 개념

필터는, 서블릿이 제공하는 문지기 같은 역할이다. 필터의 흐름은 다음과 같다.

Filter 흐름

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

중요한 것은, 필터가 호출된 다음에 서블릿이 호출된다는 것이다. (ex) Dispatcher Servlet) 참고로 필터는 특정 url 패턴에만 적용할 수 있다.

만약 공통 관심사로 로그인 인증을 처리하고자 한다면, 로그인이 안되어 있는 경우 필터에서 걸러져서 서블릿이 호출되지 않도록 하면 된다.

HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출 X)

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

필터 인터페이스를 구현하고 등록하면, 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고, 관리해준다.

  1. init() : 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출된다.
  2. doFilter(): 고객의 요청이 올 때 마다 해당 메서드가 호출(필터 로직 구현)된다.
  3. destroy() : 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.

서블릿 필터는 다음 필터가 있으면 필터를 호출하고, 더 이상 필터가 없으면 서블릿을 호출한다. 따라서 만약 다음의 로직을 호출하지 않으면, 다음 단계로 진행되지 않는다는 점에 주의하자.

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
	...
    chain.doFilter(request, response);
    ...
}

필터 설정

필터를 설정하려면 Filter 인터페이스를 구현한 이후에 설정 정보를 등록해줘야 한다.

@Configuration
public class WebConfig {

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

        return filterRegistrationBean;
    }
}
  • setOrder(1) : 필터는 체인으로 동작하기 때문에 순서가 필요, 낮을 수록 먼저 동작
  • addUrlPatterns("/*") : 필터를 적용할 URL 패턴을 지정

🔖 참고 사항
만약 HTTP 요청 시 같은 요청의 로그에 모두 같은 식별자를 공통으로 남기고 싶다면 LogBack mdc를 사용하면 된다.

☁️ 로그인 인증 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 httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();

        HttpServletResponse httpResponse = (HttpServletResponse) response;

        try{
            if(isLoginCheckPath(requestURI)){      //로그인 인증이 필요한 경로
                HttpSession session = httpRequest.getSession(false);   
                if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
                    httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    return;                    
                }
            }
            chain.doFilter(request,response);   
        }catch(Exception e){
            throw e;  
        }finally{
            log.info("인증 체크 필터 종료 {}", requestURI);
        }
    }

    //화이트 리스트의 경우 인증 체크 X
    private boolean isLoginCheckPath(String requestURI){
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
}

미인증 사용자의 경우 저장된 세션 정보가 없을테니 서블릿과 컨트롤러의 호출을 막기 위해 로그인 페이지로 리다이렉트 시키고 끝낸다. 즉, 요청이 끝나 이후 비즈니스 로직 동작에 영향을 주지 않도록 해야 하는 것이다.

이때, 로그인 하기 이전에 존재했던 페이지 경로를 기억해두기 위해 URL 쿼리 파라미터에 포함시킨다. 이후 로그인 성공 컨트롤러에서 저장해놨던 경로로 다시 리다이렉트 시켜주면 된다.

@PostMapping("/login")
public String loginV4(...@RequestParam(defaultValue = "/") String redirectURL..){
    ...
    return "redirect:" + redirectURL;   //없으면 홈(/)으로, 있으면 기존 화면으로
}

참고로 스프링 시큐리티도 필터 기반으로 동작한다.

2️⃣ Spring Iterceptor

☁️ 인터셉터 개념

스프링 인터셉터란, 웹과 관련된 공통 관심사를 효과적으로 처리할 수 있는 기술이다. 스프링 MVC가 제공하므로, 스프링 MVC 구조에 특화된 필터 기능을 제공한다.

인터셉터 호출 흐름

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

스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다. 필터와 마찬가지로 URL 패턴을 적용할 수 있지만 훨씬 정밀하게 설정 가능하다.

필터와 비슷하게 로그인 인증 흐름은 다음과 같으며 인터셉터 체인도 제공한다.

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

인터셉터 인터페이스

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) t  hrowsException {}
}

메서드 인자를 보면 알 수 있듯이 어떤 핸들러가 요청되었는지, 에러 정보 등 필터보다 훨씬 더 많은 기능을 제공해준다.

1. 정상 흐름

  1. preHandle : 컨트롤러 호출 전에 호출된다. (핸들러 어댑터 호출 전)
    응답값이 true 이면 다음으로 진행하고, false 이면 더이상 진행하지 않아 나머지 인터셉터나 핸들러 어댑터도 호출되지 않는다.
  2. postHandle : 컨트롤러 호출 후에 호출된다. (핸들러 어댑터 호출 후)
  3. afterCompletion : 뷰가 렌더링 된 이후 마지막에 호출된다.

2. 예외 흐름

  1. preHandle : 컨트롤러 호출 전에 호출된다.
  2. postHandle : 컨트롤러에서 예외가 발생하면, postHandle 은 호출되지 않는다.
  3. afterCompletion : 예외와 무관하게 항상 호출되며, 포함된 예외 정보를 인자로 받을 수 있기 때문에 로그로 출력 가능하다.

☁️ 인터셉터 구현

요청 로그 인터셉터 구현

@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);   // 요청 ID 저장

        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();
        Object logId = request.getAttribute(LOG_ID);
        log.info("RESPONSE [{}][{}][{}]", logId, requestURI, handler);
        if (ex != null) {
            log.error("afterCompletion error", ex);
        }
    }
}

HandlerMethod 란, @Controller@RequestMapping 을 활용한 핸들러 매핑의 경우 넘어오는 핸들러 정보(컨트롤러)이다.

또한 인터셉터는 필터와 달리 호출 시점이 완전히 분리되기 때문에, request.setAttribute(LOG_ID, uuid) 을 통해 request 에 담아두고 사용하면 된다. 인터셉터 역시 싱글톤으로 관리되기 때문에 절대 전역 변수로 저장하면 안된다는 것 주의하자.

인터셉터 등록 : WEBCONFIG

@Configuration
public class WebConfig implements WebMvcConfigurer {

   @Override
   public void addInterceptors(InterceptorRegistry registry) {
       registry.addInterceptor(new LogInterceptor())
               .order(1)
               .addPathPatterns("/**")  //하위 전부 다
               .excludePathPatterns("/css/**", "/*.ico", "/error");
   }
}
  • addPathPatterns("/**") : 인터셉터를 적용할 URL 패턴을 지정

로그인 인증 인터셉터 구현

인증과 같은 경우는 컨트롤러 호출 전에만 필요하므로 preHandle 만 구현하면 되므로 필터에 비해 매우 간단해진 것을 볼 수 있다.

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        HttpSession session = request.getSession();

        if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
            // 미인증 사용자면 로그인으로 redirect
            response.sendRedirect("/login?redirectURL=" + requestURI);       
            return false;
        }
        return true;
    }
}
@Configuration
public class WebConfig implements WebMvcConfigurer {

        registry.addInterceptor(new LoginCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")   //세밀하게 적용 가능
                .excludePathPatterns("/", "/members/add", "/login", "/logout",
                        "/css/**", "/*.ico", "/error");
}

excludePathPatterns 에는 인터셉터를 사용 하지 않을 부분을 명시해주면 된다. 따라서 인터셉터에서 직접 제외할 경로를 정하는 것이 아닌, 인터셉터를 '설정'할 때 설정 정보로 넘겨주기만 하면 되기 때문에 필터보다 편리하다.

☁️ ArgumentResolver 활용

ArgumentResolver를 활용하면, 다음과 같이 로그인 회원을 더 간편하게 찾을 수 있다.

@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember) {
    ...
}

구현

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

직접 만든 ArgumentResolver 가 동작하여, 자동으로 세션에 있는 로그인 회원을 찾아주고 없다면 null을 반환한다.

 @Slf4j
 public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
     @Override
     public boolean supportsParameter(MethodParameter parameter) {
         boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
         boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());

         return hasLoginAnnotation && hasMemberType;  //true면 아래 메소드 실행
     }
     
     @Override
     public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
         HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
         HttpSession session = request.getSession(false); // 의미 없는 세션 방지
         if(session == null){
             return null;
         }
         return session.getAttribute(SessionConst.LOGIN_MEMBER); //세션 존재 시 멤버 반환
     }
 }
  1. supportsParameter()
    @Login 애노테이션이 있으면서 Member 타입이면 해당 ArgumentResolver가 사용된다.
  2. resolveArgument()
    컨트롤러 호출 직전에 호출되어, 필요한 파라미터 정보를 생성한다. 즉 세션에 있는 로그인 회원 정보인 member 객체를 찾아서 반환한다. 스프링 MVC는 컨트롤러의 메서드를 호출하면서 반환된 Member 객체를 파라미터로 전달하는 것이다.
@Configuration
public class WebConfig implements WebMvcConfigurer {

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

출처
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

profile
개인용으로 공부하는 공간입니다. 피드백 환영합니다 🙂

0개의 댓글